Skip to main content

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