1
//! Commodity-domain natives. Wraps `server::command::{GetCommodity,
2
//! CreateCommodity, ListCommodities}`.
3

            
4
use finance::tag::Tag;
5
use scripting::runtime::{
6
    alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_string_ref,
7
    read_commodity_arg, read_string_arg,
8
};
9
use server::command::commodity::{
10
    ConvertCommodity, CreateCommodity, GetCommodity, ListCommodities,
11
};
12
use server::command::{CmdError, CmdResult, FinanceEntity};
13
use uuid::Uuid;
14
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
15

            
16
use crate::session::SessionData;
17

            
18
pub const REGISTERED_COMMANDS: &[&str] = &[
19
    "get-commodity",
20
    "create-commodity",
21
    "list-commodities",
22
    "convert-commodity",
23
];
24

            
25
2559
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
26
2559
    register_readonly(linker)?;
27
2559
    register_mutators(linker)?;
28
2559
    Ok(())
29
2559
}
30

            
31
2660
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
32
2660
    linker.func_wrap_async(
33
2660
        "nomi",
34
2660
        "commodity_list_commodities",
35
        |mut caller: Caller<'_, SessionData>,
36
         ()|
37
         -> Box<
38
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
39
54
        > {
40
54
            Box::new(async move {
41
54
                let user_id = caller.data().ctx().user_id;
42
54
                let result = ListCommodities::new().user_id(user_id).run().await;
43
54
                let entities = list_commodity_entities("list-commodities", result)?;
44
54
                alloc_commodity_chain(&mut caller, entities).await
45
54
            })
46
54
        },
47
    )?;
48
2660
    linker.func_wrap_async(
49
2660
        "nomi",
50
2660
        "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
181
        > {
56
181
            Box::new(async move {
57
181
                let user_id = caller.data().ctx().user_id;
58
181
                let id = read_string_arg(&mut caller, id_arg)?;
59
181
                run_get_commodity(&mut caller, user_id, id).await
60
181
            })
61
181
        },
62
    )?;
63
2660
    linker.func_wrap_async(
64
2660
        "nomi",
65
2660
        "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
36
        > {
71
36
            Box::new(async move {
72
36
                let user_id = caller.data().ctx().user_id;
73
36
                let amount = read_commodity_arg(&mut caller, amount_arg)?;
74
36
                let target = read_string_arg(&mut caller, target_arg)?;
75
36
                let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
76
18
                let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
77
18
                Ok(Some(ref_))
78
36
            })
79
36
        },
80
    )?;
81
2660
    Ok(())
82
2660
}
83

            
84
2559
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
85
2559
    linker.func_wrap_async(
86
2559
        "nomi",
87
2559
        "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
306
        > {
93
306
            Box::new(async move {
94
306
                let user_id = caller.data().ctx().user_id;
95
306
                let symbol = read_string_arg(&mut caller, symbol_arg)?;
96
306
                let name = read_string_arg(&mut caller, name_arg)?;
97
306
                let id = run_create_commodity(user_id, symbol, name).await?;
98
306
                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
99
306
            })
100
306
        },
101
    )?;
102
2559
    Ok(())
103
2559
}
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.
111
36
async fn resolve_convert(
112
36
    user_id: Uuid,
113
36
    amount_arg: Option<(i64, i64, Uuid)>,
114
36
    target_arg: Option<String>,
115
36
) -> wasmtime::Result<(i64, i64, Uuid)> {
116
36
    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
36
    let raw = target_arg
120
36
        .filter(|s| !s.is_empty())
121
36
        .ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
122
36
    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
36
    let result = ConvertCommodity::new()
128
36
        .user_id(user_id)
129
36
        .amount_num(amount_num)
130
36
        .amount_denom(amount_denom)
131
36
        .source_commodity_id(source_id)
132
36
        .target_commodity_id(target_id)
133
36
        .run()
134
36
        .await
135
36
        .map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
136
18
    let rational = match result {
137
18
        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
18
    Ok((*rational.numer(), *rational.denom(), target_id))
150
36
}
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.
156
308
async fn run_create_commodity(
157
308
    user_id: Uuid,
158
308
    symbol_arg: Option<String>,
159
308
    name_arg: Option<String>,
160
308
) -> wasmtime::Result<String> {
161
308
    let symbol = symbol_arg
162
308
        .filter(|s| !s.is_empty())
163
308
        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
164
307
    let name = name_arg
165
307
        .filter(|s| !s.is_empty())
166
307
        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
167
306
    match CreateCommodity::new()
168
306
        .symbol(symbol)
169
306
        .name(name)
170
306
        .user_id(user_id)
171
306
        .run()
172
306
        .await
173
    {
174
306
        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
308
}
184

            
185
181
async fn run_get_commodity(
186
181
    caller: &mut Caller<'_, SessionData>,
187
181
    user_id: Uuid,
188
181
    id_arg: Option<String>,
189
181
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
190
181
    let raw = id_arg
191
181
        .filter(|s| !s.is_empty())
192
181
        .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
181
    let entry = match Uuid::parse_str(&raw) {
201
18
        Ok(commodity_id) => {
202
18
            let result = GetCommodity::new()
203
18
                .user_id(user_id)
204
18
                .commodity_id(commodity_id)
205
18
                .run()
206
18
                .await;
207
18
            list_commodity_entities("get-commodity", result)?
208
                .into_iter()
209
                .next()
210
        }
211
163
        Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
212
    };
213

            
214
144
    match entry {
215
108
        Some((id, symbol, name)) => Ok(Some(
216
108
            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
217
        )),
218
36
        None => Ok(None),
219
    }
220
181
}
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).
228
163
async fn resolve_commodity_symbol(
229
163
    user_id: Uuid,
230
163
    symbol: &str,
231
163
) -> wasmtime::Result<Option<CommodityEntry>> {
232
163
    let result = ListCommodities::new().user_id(user_id).run().await;
233
163
    let mut matches = list_commodity_entities("get-commodity", result)?
234
162
        .into_iter()
235
162
        .filter(|(_, sym, _)| {
236
162
            sym.as_deref()
237
162
                .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
238
162
        });
239
162
    let first = matches.next();
240
162
    if first.is_some() && matches.next().is_some() {
241
18
        return Err(wasmtime::Error::msg(format!(
242
18
            "get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
243
18
             share it); reference it by uuid instead"
244
18
        )));
245
144
    }
246
144
    Ok(first)
247
163
}
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.
253
type 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`.
258
235
fn list_commodity_entities(
259
235
    name: &str,
260
235
    result: Result<Option<CmdResult>, CmdError>,
261
235
) -> wasmtime::Result<Vec<CommodityEntry>> {
262
216
    match result {
263
216
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
264
216
            .into_iter()
265
216
            .filter_map(|(entity, tags)| match entity {
266
198
                FinanceEntity::Commodity(c) => Some((
267
198
                    c.id.to_string(),
268
198
                    tag_value(&tags, "symbol").map(str::to_string),
269
198
                    tag_value(&tags, "name").map(str::to_string),
270
198
                )),
271
                _ => None,
272
198
            })
273
216
            .collect()),
274
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
275
            "{name}: expected TaggedEntities, got {other:?}"
276
        ))),
277
        Ok(None) => Ok(Vec::new()),
278
19
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
279
    }
280
235
}
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)`.
285
144
async fn alloc_commodity_entity(
286
144
    caller: &mut Caller<'_, SessionData>,
287
144
    id: &str,
288
144
    symbol: Option<&str>,
289
144
    name: Option<&str>,
290
144
) -> wasmtime::Result<Rooted<StructRef>> {
291
144
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
292
144
    let symbol_ref = match symbol {
293
144
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
294
        None => None,
295
    };
296
144
    let name_ref = match name {
297
144
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
298
        None => None,
299
    };
300
144
    let args = [
301
144
        Val::AnyRef(Some(id_ref.to_anyref())),
302
144
        Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
303
144
        Val::AnyRef(name_ref.map(|r| r.to_anyref())),
304
    ];
305
144
    alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
306
144
}
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.
311
54
async fn alloc_commodity_chain(
312
54
    caller: &mut Caller<'_, SessionData>,
313
54
    entities: Vec<(String, Option<String>, Option<String>)>,
314
54
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
315
54
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
316
54
    for (id, symbol, name) in entities {
317
36
        let entity_ref =
318
36
            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
319
36
        anyrefs.push(entity_ref.to_anyref());
320
    }
321
54
    alloc_pair_chain(caller, anyrefs).await
322
54
}
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)]
328
3
fn format_tagged_commodities(
329
3
    entities: &[(
330
3
        FinanceEntity,
331
3
        std::collections::HashMap<String, FinanceEntity>,
332
3
    )],
333
3
) -> String {
334
3
    let mut out = String::from("(:commodities (");
335
3
    for (idx, (entity, tags)) in entities.iter().enumerate() {
336
2
        if idx > 0 {
337
            out.push(' ');
338
2
        }
339
2
        let id = match entity {
340
2
            FinanceEntity::Commodity(c) => c.id,
341
            other => {
342
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
343
                continue;
344
            }
345
        };
346
2
        out.push_str(&format!("(:id \"{id}\""));
347
2
        if let Some(symbol) = tag_value(tags, "symbol") {
348
1
            out.push_str(&format!(" :symbol {}", quote_string(symbol)));
349
1
        }
350
2
        if let Some(name) = tag_value(tags, "name") {
351
1
            out.push_str(&format!(" :name {}", quote_string(name)));
352
1
        }
353
2
        out.push(')');
354
    }
355
3
    out.push_str("))");
356
3
    out
357
3
}
358

            
359
400
fn tag_value<'a>(
360
400
    tags: &'a std::collections::HashMap<String, FinanceEntity>,
361
400
    key: &str,
362
400
) -> Option<&'a str> {
363
400
    tags.get(key).and_then(|t| match t {
364
398
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
365
        _ => None,
366
398
    })
367
400
}
368

            
369
#[cfg(test)]
370
2
fn quote_string(s: &str) -> String {
371
2
    let mut q = String::with_capacity(s.len() + 2);
372
2
    q.push('"');
373
12
    for ch in s.chars() {
374
12
        match ch {
375
            '"' => q.push_str("\\\""),
376
            '\\' => q.push_str("\\\\"),
377
12
            other => q.push(other),
378
        }
379
    }
380
2
    q.push('"');
381
2
    q
382
2
}
383

            
384
#[cfg(test)]
385
mod tests {
386
    use super::*;
387
    use finance::commodity::Commodity;
388
    use std::collections::HashMap;
389
    use uuid::Uuid;
390

            
391
2
    fn commodity_entity(id: Uuid) -> FinanceEntity {
392
2
        FinanceEntity::Commodity(Commodity { id })
393
2
    }
394

            
395
    #[test]
396
1
    fn format_empty_list() {
397
1
        assert_eq!(format_tagged_commodities(&[]), "(:commodities ())");
398
1
    }
399

            
400
    #[test]
401
1
    fn format_single_commodity_with_symbol_and_name() {
402
1
        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
403
1
        let mut tags = HashMap::new();
404
1
        tags.insert(
405
1
            "symbol".to_string(),
406
1
            FinanceEntity::Tag(Tag {
407
1
                id: Uuid::nil(),
408
1
                tag_name: "symbol".into(),
409
1
                tag_value: "USD".into(),
410
1
                description: None,
411
1
            }),
412
        );
413
1
        tags.insert(
414
1
            "name".to_string(),
415
1
            FinanceEntity::Tag(Tag {
416
1
                id: Uuid::nil(),
417
1
                tag_name: "name".into(),
418
1
                tag_value: "US Dollar".into(),
419
1
                description: None,
420
1
            }),
421
        );
422
1
        let out = format_tagged_commodities(&[(commodity_entity(id), tags)]);
423
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
424
1
        assert!(out.contains(":symbol \"USD\""));
425
1
        assert!(out.contains(":name \"US Dollar\""));
426
1
    }
427

            
428
    #[tokio::test]
429
1
    async fn run_create_commodity_missing_symbol_emits_error() {
430
1
        let err = run_create_commodity(Uuid::nil(), None, Some("name".into()))
431
1
            .await
432
1
            .unwrap_err();
433
1
        assert!(err.to_string().contains(":symbol"));
434
1
    }
435

            
436
    #[tokio::test]
437
1
    async fn run_create_commodity_missing_name_emits_error() {
438
1
        let err = run_create_commodity(Uuid::nil(), Some("sym".into()), None)
439
1
            .await
440
1
            .unwrap_err();
441
1
        assert!(err.to_string().contains(":name"));
442
1
    }
443

            
444
    #[test]
445
1
    fn format_commodity_without_tags_emits_id_only() {
446
1
        let id = Uuid::nil();
447
1
        let out = format_tagged_commodities(&[(commodity_entity(id), HashMap::new())]);
448
1
        assert_eq!(
449
            out,
450
            "(:commodities ((:id \"00000000-0000-0000-0000-000000000000\")))"
451
        );
452
1
    }
453
}