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
7433
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
27
7433
    register_readonly(linker)?;
28
7433
    register_mutators(linker)?;
29
7433
    Ok(())
30
7433
}
31

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

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

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

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

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

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