Skip to main content

rpc/natives/commodity/
mod.rs

1//! Commodity-domain natives. Wraps `server::command::{GetCommodity,
2//! CreateCommodity, ListCommodities}`.
3
4use finance::tag::Tag;
5use scripting::runtime::{
6    alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_string_ref,
7    read_commodity_arg, read_string_arg,
8};
9use server::command::commodity::{
10    ConvertCommodity, CreateCommodity, GetCommodity, ListCommodities,
11};
12use server::command::{CmdError, CmdResult, FinanceEntity};
13use uuid::Uuid;
14use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
15
16use crate::session::SessionData;
17
18pub const REGISTERED_COMMANDS: &[&str] = &[
19    "get-commodity",
20    "create-commodity",
21    "list-commodities",
22    "convert-commodity",
23    "convert-amount",
24];
25
26pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
27    register_readonly(linker)?;
28    register_mutators(linker)?;
29    Ok(())
30}
31
32pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
33    linker.func_wrap_async(
34        "nomi",
35        "commodity_list_commodities",
36        |mut caller: Caller<'_, SessionData>,
37         ()|
38         -> Box<
39            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
40        > {
41            Box::new(async move {
42                let user_id = caller.data().ctx().user_id;
43                let result = ListCommodities::new().user_id(user_id).run().await;
44                let entities = list_commodity_entities("list-commodities", result)?;
45                alloc_commodity_chain(&mut caller, entities).await
46            })
47        },
48    )?;
49    linker.func_wrap_async(
50        "nomi",
51        "commodity_get_commodity",
52        |mut caller: Caller<'_, SessionData>,
53         (id_arg,): (Option<Rooted<ArrayRef>>,)|
54         -> Box<
55            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
56        > {
57            Box::new(async move {
58                let user_id = caller.data().ctx().user_id;
59                let id = read_string_arg(&mut caller, id_arg)?;
60                run_get_commodity(&mut caller, user_id, id).await
61            })
62        },
63    )?;
64    linker.func_wrap_async(
65        "nomi",
66        "commodity_convert_commodity",
67        |mut caller: Caller<'_, SessionData>,
68         (amount_arg, target_arg): (Option<Rooted<StructRef>>, Option<Rooted<ArrayRef>>)|
69         -> Box<
70            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
71        > {
72            Box::new(async move {
73                let user_id = caller.data().ctx().user_id;
74                let amount = read_commodity_arg(&mut caller, amount_arg)?;
75                let target = read_string_arg(&mut caller, target_arg)?;
76                let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
77                let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
78                Ok(Some(ref_))
79            })
80        },
81    )?;
82    linker.func_wrap_async(
83        "nomi",
84        "commodity_convert_amount",
85        |mut caller: Caller<'_, SessionData>,
86         (amount_arg, from_arg, to_arg): super::StringArgTriple|
87         -> Box<
88            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
89        > {
90            Box::new(async move {
91                let user_id = caller.data().ctx().user_id;
92                let amount_str = read_string_arg(&mut caller, amount_arg)?.unwrap_or_default();
93                let from_str = read_string_arg(&mut caller, from_arg)?
94                    .filter(|s| !s.is_empty())
95                    .ok_or_else(|| {
96                        wasmtime::Error::msg("convert-amount: missing from-commodity id")
97                    })?;
98                let to_str = read_string_arg(&mut caller, to_arg)?;
99                let (num, denom) = parse_amount_str(&amount_str)?;
100                let from_id = Uuid::parse_str(&from_str).map_err(|err| {
101                    wasmtime::Error::msg(format!(
102                        "convert-amount: invalid from-commodity uuid '{from_str}': {err}"
103                    ))
104                })?;
105                let (result_num, result_denom, _) =
106                    resolve_convert(user_id, Some((num, denom, from_id)), to_str).await?;
107                let result = if result_denom == 1 {
108                    result_num.to_string()
109                } else {
110                    format!("{result_num}/{result_denom}")
111                };
112                Ok(Some(alloc_string_ref(&mut caller, result.as_bytes())?))
113            })
114        },
115    )?;
116    Ok(())
117}
118
119pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
120    linker.func_wrap_async(
121        "nomi",
122        "commodity_create_commodity",
123        |mut caller: Caller<'_, SessionData>,
124         (symbol_arg, name_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
125         -> Box<
126            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
127        > {
128            Box::new(async move {
129                let user_id = caller.data().ctx().user_id;
130                let symbol = read_string_arg(&mut caller, symbol_arg)?;
131                let name = read_string_arg(&mut caller, name_arg)?;
132                let id = run_create_commodity(user_id, symbol, name).await?;
133                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
134            })
135        },
136    )?;
137    Ok(())
138}
139
140/// Companion to `get-commodity`. Looks up the most recent Price row
141/// between source and target commodities, multiplies the supplied
142/// amount, and returns the converted `(numer, denom, target_id)`.
143/// Caller wraps the tuple into a `$commodity` ref via
144/// `alloc_commodity_ref`. Surfaces `wasmtime::Error::msg` on
145/// missing/invalid args or absent conversion path.
146async fn resolve_convert(
147    user_id: Uuid,
148    amount_arg: Option<(i64, i64, Uuid)>,
149    target_arg: Option<String>,
150) -> wasmtime::Result<(i64, i64, Uuid)> {
151    let (amount_num, amount_denom, source_id) = amount_arg.ok_or_else(|| {
152        wasmtime::Error::msg("convert-commodity: missing commodity-typed amount argument")
153    })?;
154    let raw = target_arg
155        .filter(|s| !s.is_empty())
156        .ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
157    let target_id = Uuid::parse_str(&raw).map_err(|err| {
158        wasmtime::Error::msg(format!(
159            "convert-commodity: invalid target uuid '{raw}': {err}"
160        ))
161    })?;
162    let result = ConvertCommodity::new()
163        .user_id(user_id)
164        .amount_num(amount_num)
165        .amount_denom(amount_denom)
166        .source_commodity_id(source_id)
167        .target_commodity_id(target_id)
168        .run()
169        .await
170        .map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
171    let rational = match result {
172        Some(CmdResult::Rational(r)) => r,
173        Some(other) => {
174            return Err(wasmtime::Error::msg(format!(
175                "convert-commodity: unexpected variant {other:?}"
176            )));
177        }
178        None => {
179            return Err(wasmtime::Error::msg(
180                "convert-commodity: command returned no rational",
181            ));
182        }
183    };
184    Ok((*rational.numer(), *rational.denom(), target_id))
185}
186
187/// Parses a ratio-token string `"num"` or `"num/denom"` into `(num, denom)`.
188/// Accepts an optional leading `-` on the numerator. Rejects empty strings,
189/// non-integer parts, and non-positive denominators (a non-positive `denom`
190/// would later overflow `Rational64` sign normalization).
191fn parse_amount_str(s: &str) -> wasmtime::Result<(i64, i64)> {
192    if s.is_empty() {
193        return Err(wasmtime::Error::msg(
194            "convert-amount: invalid amount '': empty string",
195        ));
196    }
197    match s.split_once('/') {
198        None => {
199            let n = s.parse::<i64>().map_err(|_| {
200                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
201            })?;
202            Ok((n, 1))
203        }
204        Some((n_str, d_str)) => {
205            let n = n_str.parse::<i64>().map_err(|_| {
206                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
207            })?;
208            let d = d_str.parse::<i64>().map_err(|_| {
209                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
210            })?;
211            if d <= 0 {
212                return Err(wasmtime::Error::msg(
213                    "convert-amount: denominator must be positive",
214                ));
215            }
216            Ok((n, d))
217        }
218    }
219}
220
221/// Writes a new commodity row for the session user with `symbol` and
222/// `name` tags. Returns the new entity's UUID in `(:commodity-id "...")`.
223/// Args ride the capture queue: compiler pushes symbol first, then name;
224/// host pops via FIFO take_arg in matching order.
225async fn run_create_commodity(
226    user_id: Uuid,
227    symbol_arg: Option<String>,
228    name_arg: Option<String>,
229) -> wasmtime::Result<String> {
230    let symbol = symbol_arg
231        .filter(|s| !s.is_empty())
232        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
233    let name = name_arg
234        .filter(|s| !s.is_empty())
235        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
236    match CreateCommodity::new()
237        .symbol(symbol)
238        .name(name)
239        .user_id(user_id)
240        .run()
241        .await
242    {
243        Ok(Some(CmdResult::String(id))) => Ok(id),
244        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
245            "create-commodity: expected String id, got {other:?}"
246        ))),
247        Ok(None) => Err(wasmtime::Error::msg(
248            "create-commodity: command returned no id",
249        )),
250        Err(err) => Err(wasmtime::Error::msg(format!("create-commodity: {err}"))),
251    }
252}
253
254async fn run_get_commodity(
255    caller: &mut Caller<'_, SessionData>,
256    user_id: Uuid,
257    id_arg: Option<String>,
258) -> wasmtime::Result<Option<Rooted<StructRef>>> {
259    let raw = id_arg
260        .filter(|s| !s.is_empty())
261        .ok_or_else(|| wasmtime::Error::msg("get-commodity: missing :commodity-id arg"))?;
262
263    // Accept a uuid OR a symbol, mirroring get-account's id/name fallback: a
264    // uuid arg is an id lookup (unchanged); a non-uuid arg is matched
265    // case-insensitively against commodity symbols, so `(get-commodity "USD")`
266    // works in templates without pasting a uuid. Like get-account, a
267    // uuid-SHAPED symbol is only reachable by its real id, not by the symbol
268    // string — an accepted, consistent limitation for a pathological name.
269    let entry = match Uuid::parse_str(&raw) {
270        Ok(commodity_id) => {
271            let result = GetCommodity::new()
272                .user_id(user_id)
273                .commodity_id(commodity_id)
274                .run()
275                .await;
276            list_commodity_entities("get-commodity", result)?
277                .into_iter()
278                .next()
279        }
280        Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
281    };
282
283    match entry {
284        Some((id, symbol, name)) => Ok(Some(
285            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
286        )),
287        None => Ok(None),
288    }
289}
290
291/// Resolves a commodity by its symbol (case-insensitive) for `get-commodity`.
292/// `None` when no symbol matches; an ERROR when more than one does — symbols
293/// aren't unique in the schema, and silently binding to an arbitrary one would
294/// draft a transaction against the wrong commodity. Failing loudly is the safe
295/// choice for a finance value (and is stricter than `get-account`, which is
296/// acceptable: a wrong currency is worse than a wrong account label).
297async fn resolve_commodity_symbol(
298    user_id: Uuid,
299    symbol: &str,
300) -> wasmtime::Result<Option<CommodityEntry>> {
301    let result = ListCommodities::new().user_id(user_id).run().await;
302    let mut matches = list_commodity_entities("get-commodity", result)?
303        .into_iter()
304        .filter(|(_, sym, _)| {
305            sym.as_deref()
306                .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
307        });
308    let first = matches.next();
309    if first.is_some() && matches.next().is_some() {
310        return Err(wasmtime::Error::msg(format!(
311            "get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
312             share it); reference it by uuid instead"
313        )));
314    }
315    Ok(first)
316}
317
318/// Unwraps a `CmdResult::TaggedEntities` and extracts the (id, symbol-tag,
319/// (id, symbol-tag, name-tag) triple per commodity, flattened from
320/// `TaggedEntities` so the wasm marshalling site walks one typed row
321/// per commodity.
322type CommodityEntry = (String, Option<String>, Option<String>);
323
324/// name-tag) triple per commodity. Returns the typed shape the host fn
325/// then folds through the entity allocator + pair chain. Wrong variant or
326/// command error surfaces as `wasmtime::Error`.
327fn list_commodity_entities(
328    name: &str,
329    result: Result<Option<CmdResult>, CmdError>,
330) -> wasmtime::Result<Vec<CommodityEntry>> {
331    match result {
332        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
333            .into_iter()
334            .filter_map(|(entity, tags)| match entity {
335                FinanceEntity::Commodity(c) => Some((
336                    c.id.to_string(),
337                    tag_value(&tags, "symbol").map(str::to_string),
338                    tag_value(&tags, "name").map(str::to_string),
339                )),
340                _ => None,
341            })
342            .collect()),
343        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
344            "{name}: expected TaggedEntities, got {other:?}"
345        ))),
346        Ok(None) => Ok(Vec::new()),
347        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
348    }
349}
350
351/// Re-enters wasm to construct a `$commodity_entity` struct ref carrying the
352/// id, symbol-tag, and name-tag as `$i8_array` payloads. Missing tags ride
353/// as null `(ref null $i8_array)`.
354async fn alloc_commodity_entity(
355    caller: &mut Caller<'_, SessionData>,
356    id: &str,
357    symbol: Option<&str>,
358    name: Option<&str>,
359) -> wasmtime::Result<Rooted<StructRef>> {
360    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
361    let symbol_ref = match symbol {
362        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
363        None => None,
364    };
365    let name_ref = match name {
366        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
367        None => None,
368    };
369    let args = [
370        Val::AnyRef(Some(id_ref.to_anyref())),
371        Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
372        Val::AnyRef(name_ref.map(|r| r.to_anyref())),
373    ];
374    alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
375}
376
377/// Allocates a pair chain of `$commodity_entity` refs from the typed
378/// triples extracted by `list_commodity_entities`. Returns the chain head,
379/// or `None` for an empty result set.
380async fn alloc_commodity_chain(
381    caller: &mut Caller<'_, SessionData>,
382    entities: Vec<(String, Option<String>, Option<String>)>,
383) -> wasmtime::Result<Option<Rooted<StructRef>>> {
384    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
385    for (id, symbol, name) in entities {
386        let entity_ref =
387            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
388        anyrefs.push(entity_ref.to_anyref());
389    }
390    alloc_pair_chain(caller, anyrefs).await
391}
392
393fn tag_value<'a>(
394    tags: &'a std::collections::HashMap<String, FinanceEntity>,
395    key: &str,
396) -> Option<&'a str> {
397    tags.get(key).and_then(|t| match t {
398        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
399        _ => None,
400    })
401}
402
403#[cfg(test)]
404mod tests;