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
    "convert-amount",
24
];
25

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

            
32
6045
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
33
6045
    linker.func_wrap_async(
34
6045
        "nomi",
35
6045
        "commodity_list_commodities",
36
        |mut caller: Caller<'_, SessionData>,
37
         ()|
38
         -> Box<
39
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
40
100
        > {
41
100
            Box::new(async move {
42
100
                let user_id = caller.data().ctx().user_id;
43
100
                let result = ListCommodities::new().user_id(user_id).run().await;
44
100
                let entities = list_commodity_entities("list-commodities", result)?;
45
100
                alloc_commodity_chain(&mut caller, entities).await
46
100
            })
47
100
        },
48
    )?;
49
6045
    linker.func_wrap_async(
50
6045
        "nomi",
51
6045
        "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
251
        > {
57
251
            Box::new(async move {
58
251
                let user_id = caller.data().ctx().user_id;
59
251
                let id = read_string_arg(&mut caller, id_arg)?;
60
251
                run_get_commodity(&mut caller, user_id, id).await
61
251
            })
62
251
        },
63
    )?;
64
6045
    linker.func_wrap_async(
65
6045
        "nomi",
66
6045
        "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
50
        > {
72
50
            Box::new(async move {
73
50
                let user_id = caller.data().ctx().user_id;
74
50
                let amount = read_commodity_arg(&mut caller, amount_arg)?;
75
50
                let target = read_string_arg(&mut caller, target_arg)?;
76
50
                let (numer, denom, target_id) = resolve_convert(user_id, amount, target).await?;
77
25
                let ref_ = alloc_commodity_ref(&mut caller, numer, denom, target_id).await?;
78
25
                Ok(Some(ref_))
79
50
            })
80
50
        },
81
    )?;
82
6045
    linker.func_wrap_async(
83
6045
        "nomi",
84
6045
        "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
125
        > {
90
125
            Box::new(async move {
91
125
                let user_id = caller.data().ctx().user_id;
92
125
                let amount_str = read_string_arg(&mut caller, amount_arg)?.unwrap_or_default();
93
125
                let from_str = read_string_arg(&mut caller, from_arg)?
94
125
                    .filter(|s| !s.is_empty())
95
125
                    .ok_or_else(|| {
96
                        wasmtime::Error::msg("convert-amount: missing from-commodity id")
97
                    })?;
98
125
                let to_str = read_string_arg(&mut caller, to_arg)?;
99
125
                let (num, denom) = parse_amount_str(&amount_str)?;
100
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
25
                let (result_num, result_denom, _) =
106
100
                    resolve_convert(user_id, Some((num, denom, from_id)), to_str).await?;
107
25
                let result = if result_denom == 1 {
108
25
                    result_num.to_string()
109
                } else {
110
                    format!("{result_num}/{result_denom}")
111
                };
112
25
                Ok(Some(alloc_string_ref(&mut caller, result.as_bytes())?))
113
125
            })
114
125
        },
115
    )?;
116
6045
    Ok(())
117
6045
}
118

            
119
5909
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
120
5909
    linker.func_wrap_async(
121
5909
        "nomi",
122
5909
        "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
825
        > {
128
825
            Box::new(async move {
129
825
                let user_id = caller.data().ctx().user_id;
130
825
                let symbol = read_string_arg(&mut caller, symbol_arg)?;
131
825
                let name = read_string_arg(&mut caller, name_arg)?;
132
825
                let id = run_create_commodity(user_id, symbol, name).await?;
133
825
                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
134
825
            })
135
825
        },
136
    )?;
137
5909
    Ok(())
138
5909
}
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.
146
150
async fn resolve_convert(
147
150
    user_id: Uuid,
148
150
    amount_arg: Option<(i64, i64, Uuid)>,
149
150
    target_arg: Option<String>,
150
150
) -> wasmtime::Result<(i64, i64, Uuid)> {
151
150
    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
150
    let raw = target_arg
155
150
        .filter(|s| !s.is_empty())
156
150
        .ok_or_else(|| wasmtime::Error::msg("convert-commodity: missing target commodity id"))?;
157
150
    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
150
    let result = ConvertCommodity::new()
163
150
        .user_id(user_id)
164
150
        .amount_num(amount_num)
165
150
        .amount_denom(amount_denom)
166
150
        .source_commodity_id(source_id)
167
150
        .target_commodity_id(target_id)
168
150
        .run()
169
150
        .await
170
150
        .map_err(|err| wasmtime::Error::msg(format!("convert-commodity: {err}")))?;
171
50
    let rational = match result {
172
50
        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
50
    Ok((*rational.numer(), *rational.denom(), target_id))
185
150
}
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).
191
134
fn parse_amount_str(s: &str) -> wasmtime::Result<(i64, i64)> {
192
134
    if s.is_empty() {
193
1
        return Err(wasmtime::Error::msg(
194
1
            "convert-amount: invalid amount '': empty string",
195
1
        ));
196
133
    }
197
133
    match s.split_once('/') {
198
        None => {
199
28
            let n = s.parse::<i64>().map_err(|_| {
200
26
                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
201
26
            })?;
202
2
            Ok((n, 1))
203
        }
204
105
        Some((n_str, d_str)) => {
205
105
            let n = n_str.parse::<i64>().map_err(|_| {
206
                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
207
            })?;
208
105
            let d = d_str.parse::<i64>().map_err(|_| {
209
1
                wasmtime::Error::msg(format!("convert-amount: invalid amount '{s}'"))
210
1
            })?;
211
104
            if d <= 0 {
212
2
                return Err(wasmtime::Error::msg(
213
2
                    "convert-amount: denominator must be positive",
214
2
                ));
215
102
            }
216
102
            Ok((n, d))
217
        }
218
    }
219
134
}
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.
225
827
async fn run_create_commodity(
226
827
    user_id: Uuid,
227
827
    symbol_arg: Option<String>,
228
827
    name_arg: Option<String>,
229
827
) -> wasmtime::Result<String> {
230
827
    let symbol = symbol_arg
231
827
        .filter(|s| !s.is_empty())
232
827
        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :symbol arg"))?;
233
826
    let name = name_arg
234
826
        .filter(|s| !s.is_empty())
235
826
        .ok_or_else(|| wasmtime::Error::msg("create-commodity: missing or empty :name arg"))?;
236
825
    match CreateCommodity::new()
237
825
        .symbol(symbol)
238
825
        .name(name)
239
825
        .user_id(user_id)
240
825
        .run()
241
825
        .await
242
    {
243
825
        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
827
}
253

            
254
251
async fn run_get_commodity(
255
251
    caller: &mut Caller<'_, SessionData>,
256
251
    user_id: Uuid,
257
251
    id_arg: Option<String>,
258
251
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
259
251
    let raw = id_arg
260
251
        .filter(|s| !s.is_empty())
261
251
        .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
251
    let entry = match Uuid::parse_str(&raw) {
270
25
        Ok(commodity_id) => {
271
25
            let result = GetCommodity::new()
272
25
                .user_id(user_id)
273
25
                .commodity_id(commodity_id)
274
25
                .run()
275
25
                .await;
276
25
            list_commodity_entities("get-commodity", result)?
277
                .into_iter()
278
                .next()
279
        }
280
226
        Err(_) => resolve_commodity_symbol(user_id, &raw).await?,
281
    };
282

            
283
200
    match entry {
284
150
        Some((id, symbol, name)) => Ok(Some(
285
150
            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?,
286
        )),
287
50
        None => Ok(None),
288
    }
289
251
}
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).
297
226
async fn resolve_commodity_symbol(
298
226
    user_id: Uuid,
299
226
    symbol: &str,
300
226
) -> wasmtime::Result<Option<CommodityEntry>> {
301
226
    let result = ListCommodities::new().user_id(user_id).run().await;
302
226
    let mut matches = list_commodity_entities("get-commodity", result)?
303
225
        .into_iter()
304
225
        .filter(|(_, sym, _)| {
305
225
            sym.as_deref()
306
225
                .is_some_and(|s| s.eq_ignore_ascii_case(symbol))
307
225
        });
308
225
    let first = matches.next();
309
225
    if first.is_some() && matches.next().is_some() {
310
25
        return Err(wasmtime::Error::msg(format!(
311
25
            "get-commodity: symbol '{symbol}' is ambiguous (multiple commodities \
312
25
             share it); reference it by uuid instead"
313
25
        )));
314
200
    }
315
200
    Ok(first)
316
226
}
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.
322
type 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`.
327
351
fn list_commodity_entities(
328
351
    name: &str,
329
351
    result: Result<Option<CmdResult>, CmdError>,
330
351
) -> wasmtime::Result<Vec<CommodityEntry>> {
331
325
    match result {
332
325
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
333
325
            .into_iter()
334
325
            .filter_map(|(entity, tags)| match entity {
335
300
                FinanceEntity::Commodity(c) => Some((
336
300
                    c.id.to_string(),
337
300
                    tag_value(&tags, "symbol").map(str::to_string),
338
300
                    tag_value(&tags, "name").map(str::to_string),
339
300
                )),
340
                _ => None,
341
300
            })
342
325
            .collect()),
343
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
344
            "{name}: expected TaggedEntities, got {other:?}"
345
        ))),
346
        Ok(None) => Ok(Vec::new()),
347
26
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
348
    }
349
351
}
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)`.
354
225
async fn alloc_commodity_entity(
355
225
    caller: &mut Caller<'_, SessionData>,
356
225
    id: &str,
357
225
    symbol: Option<&str>,
358
225
    name: Option<&str>,
359
225
) -> wasmtime::Result<Rooted<StructRef>> {
360
225
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
361
225
    let symbol_ref = match symbol {
362
225
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
363
        None => None,
364
    };
365
225
    let name_ref = match name {
366
225
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
367
        None => None,
368
    };
369
225
    let args = [
370
225
        Val::AnyRef(Some(id_ref.to_anyref())),
371
225
        Val::AnyRef(symbol_ref.map(|r| r.to_anyref())),
372
225
        Val::AnyRef(name_ref.map(|r| r.to_anyref())),
373
    ];
374
225
    alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await
375
225
}
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.
380
100
async fn alloc_commodity_chain(
381
100
    caller: &mut Caller<'_, SessionData>,
382
100
    entities: Vec<(String, Option<String>, Option<String>)>,
383
100
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
384
100
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entities.len());
385
100
    for (id, symbol, name) in entities {
386
75
        let entity_ref =
387
75
            alloc_commodity_entity(caller, &id, symbol.as_deref(), name.as_deref()).await?;
388
75
        anyrefs.push(entity_ref.to_anyref());
389
    }
390
100
    alloc_pair_chain(caller, anyrefs).await
391
100
}
392

            
393
604
fn tag_value<'a>(
394
604
    tags: &'a std::collections::HashMap<String, FinanceEntity>,
395
604
    key: &str,
396
604
) -> Option<&'a str> {
397
604
    tags.get(key).and_then(|t| match t {
398
602
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
399
        _ => None,
400
602
    })
401
604
}
402

            
403
#[cfg(test)]
404
mod tests;