1
//! Account-domain natives. Wraps `server::command::{CreateAccount, ListAccounts,
2
//! ListAccountsForManage, GetAccountForManage, SetAccountTag, GetAccount,
3
//! GetAccountCommodities, GetBalance}` as nomiscript-callable host fns.
4
//!
5
//! `list-accounts` is the first DB-touching command in the registry; its
6
//! result (`CmdResult::TaggedEntities`) is marshalled into an S-expression
7
//! string via `format_tagged_entities`, captured through the streaming-string
8
//! path. The emacs client `(read)`s the resulting string to recover the
9
//! structured shape. Per-command marshalling lives next to each native rather
10
//! than in a single registry-wide serializer because each `CmdResult` variant
11
//! has different fields worth surfacing on the wire.
12

            
13
use finance::tag::Tag;
14
#[cfg(test)]
15
use num_rational::Rational64;
16
use scripting::runtime::{
17
    alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref,
18
    alloc_string_ref, read_string_arg,
19
};
20
#[cfg(test)]
21
use server::command::CommodityInfo;
22
use server::command::account::{
23
    CreateAccount, GetAccount, GetAccountCommodities, GetAccountForManage, GetBalance,
24
    ListAccounts, ListAccountsForManage, SetAccountTag,
25
};
26
use server::command::{CmdError, CmdResult, FinanceEntity};
27
use uuid::Uuid;
28
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
29

            
30
use crate::session::SessionData;
31

            
32
pub const REGISTERED_COMMANDS: &[&str] = &[
33
    "create-account",
34
    "list-accounts",
35
    "list-accounts-for-manage",
36
    "get-account-for-manage",
37
    "set-account-tag",
38
    "get-account",
39
    "get-account-commodities",
40
    "get-balance",
41
];
42

            
43
2559
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
44
2559
    register_readonly(linker)?;
45
2559
    register_mutators(linker)?;
46
2559
    Ok(())
47
2559
}
48

            
49
2660
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
50
2660
    linker.func_wrap_async(
51
2660
        "nomi",
52
2660
        "account_list_accounts",
53
        |mut caller: Caller<'_, SessionData>,
54
         ()|
55
         -> Box<
56
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
57
36
        > {
58
36
            Box::new(async move {
59
36
                let user_id = caller.data().ctx().user_id;
60
36
                let result = ListAccounts::new().user_id(user_id).run().await;
61
36
                let entries = list_account_entries("list-accounts", result)?;
62
36
                alloc_account_chain(&mut caller, entries).await
63
36
            })
64
36
        },
65
    )?;
66
2660
    linker.func_wrap_async(
67
2660
        "nomi",
68
2660
        "account_get_account",
69
        |mut caller: Caller<'_, SessionData>,
70
         (key_arg,): (Option<Rooted<ArrayRef>>,)|
71
         -> Box<
72
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
73
270
        > {
74
270
            Box::new(async move {
75
270
                let user_id = caller.data().ctx().user_id;
76
270
                let key = read_string_arg(&mut caller, key_arg)?;
77
270
                run_get_account(&mut caller, user_id, key).await
78
270
            })
79
270
        },
80
    )?;
81
2660
    linker.func_wrap_async(
82
2660
        "nomi",
83
2660
        "account_get_balance",
84
        |mut caller: Caller<'_, SessionData>,
85
         (id_arg,): (Option<Rooted<ArrayRef>>,)|
86
         -> Box<
87
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
88
18
        > {
89
18
            Box::new(async move {
90
18
                let user_id = caller.data().ctx().user_id;
91
18
                let id = read_string_arg(&mut caller, id_arg)?;
92
18
                let (numer, denom) = run_get_balance_single(user_id, id).await?;
93
18
                Ok(Some(alloc_ratio_ref(&mut caller, numer, denom)?))
94
18
            })
95
18
        },
96
    )?;
97
2660
    linker.func_wrap_async(
98
2660
        "nomi",
99
2660
        "account_get_account_commodities",
100
        |mut caller: Caller<'_, SessionData>,
101
         (id_arg,): (Option<Rooted<ArrayRef>>,)|
102
         -> Box<
103
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
104
18
        > {
105
18
            Box::new(async move {
106
18
                let user_id = caller.data().ctx().user_id;
107
18
                let id = read_string_arg(&mut caller, id_arg)?;
108
18
                run_get_account_commodities(&mut caller, user_id, id).await
109
18
            })
110
18
        },
111
    )?;
112
2660
    linker.func_wrap_async(
113
2660
        "nomi",
114
2660
        "account_list_accounts_for_manage",
115
        |mut caller: Caller<'_, SessionData>,
116
         ()|
117
         -> Box<
118
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
119
18
        > {
120
18
            Box::new(async move {
121
18
                let user_id = caller.data().ctx().user_id;
122
18
                let result = ListAccountsForManage::new().user_id(user_id).run().await;
123
18
                let entries = list_account_entries("list-accounts-for-manage", result)?;
124
18
                alloc_account_chain(&mut caller, entries).await
125
18
            })
126
18
        },
127
    )?;
128
2660
    linker.func_wrap_async(
129
2660
        "nomi",
130
2660
        "account_get_account_for_manage",
131
        |mut caller: Caller<'_, SessionData>,
132
         (id_arg,): (Option<Rooted<ArrayRef>>,)|
133
         -> Box<
134
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
135
18
        > {
136
18
            Box::new(async move {
137
18
                let user_id = caller.data().ctx().user_id;
138
18
                let id = read_string_arg(&mut caller, id_arg)?;
139
18
                run_get_account_for_manage(&mut caller, user_id, id).await
140
18
            })
141
18
        },
142
    )?;
143
2660
    linker.func_wrap_async(
144
2660
        "nomi",
145
2660
        "account_account_count",
146
        |caller: Caller<'_, SessionData>,
147
         ()|
148
         -> Box<dyn std::future::Future<Output = i32> + Send> {
149
            Box::new(async move {
150
                let user_id = caller.data().ctx().user_id;
151
                count_accounts(user_id).await
152
            })
153
        },
154
    )?;
155
2660
    linker.func_wrap_async(
156
2660
        "nomi",
157
2660
        "account_account_balance",
158
        |mut caller: Caller<'_, SessionData>,
159
         (id_arg,): (Option<Rooted<ArrayRef>>,)|
160
         -> Box<
161
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
162
180
        > {
163
180
            Box::new(async move {
164
180
                let user_id = caller.data().ctx().user_id;
165
180
                let id = read_string_arg(&mut caller, id_arg)?;
166
180
                let (numer, denom, commodity_id) = resolve_balance(user_id, id).await?;
167
162
                let ref_ = alloc_commodity_ref(&mut caller, numer, denom, commodity_id).await?;
168
162
                Ok(Some(ref_))
169
180
            })
170
180
        },
171
    )?;
172
2660
    Ok(())
173
2660
}
174

            
175
2559
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
176
2559
    linker.func_wrap_async(
177
2559
        "nomi",
178
2559
        "account_set_account_tag",
179
        |mut caller: Caller<'_, SessionData>,
180
         (id_arg, name_arg, value_arg): super::StringArgTriple|
181
18
         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
182
18
            Box::new(async move {
183
18
                let user_id = caller.data().ctx().user_id;
184
18
                let id = read_string_arg(&mut caller, id_arg)?;
185
18
                let name = read_string_arg(&mut caller, name_arg)?;
186
18
                let value = read_string_arg(&mut caller, value_arg)?;
187
18
                run_set_account_tag(user_id, id, name, value).await
188
18
            })
189
18
        },
190
    )?;
191
2559
    linker.func_wrap_async(
192
2559
        "nomi",
193
2559
        "account_create_account",
194
        |mut caller: Caller<'_, SessionData>,
195
         (name_arg,): (Option<Rooted<ArrayRef>>,)|
196
         -> Box<
197
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
198
450
        > {
199
450
            Box::new(async move {
200
450
                let user_id = caller.data().ctx().user_id;
201
450
                let name = read_string_arg(&mut caller, name_arg)?;
202
450
                let id = run_create_account(user_id, name).await?;
203
450
                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
204
450
            })
205
450
        },
206
    )?;
207
2559
    Ok(())
208
2559
}
209

            
210
/// Composable, commodity-bearing balance accessor. Single-currency only —
211
/// returns the `$commodity` struct ref directly. Multi-currency,
212
/// missing-commodity, or sqlx errors trap the running form: callers
213
/// compose with `+` / `-` / `*` expecting a single dimensioned scalar,
214
/// and any of those failure modes is genuinely invalid money arithmetic
215
/// at the point of use.
216
///
217
/// Resolves the commodity via `GetAccountCommodities` (must return exactly
218
/// one row) before calling `GetBalance`. The two are inherently coupled —
219
/// a Rational balance with no commodity context would be unsound.
220
180
async fn resolve_balance(
221
180
    user_id: Uuid,
222
180
    id_arg: Option<String>,
223
180
) -> wasmtime::Result<(i64, i64, Uuid)> {
224
180
    let raw = id_arg
225
180
        .filter(|s| !s.is_empty())
226
180
        .ok_or_else(|| wasmtime::Error::msg("account-balance: missing or empty :account-id arg"))?;
227
180
    let account_id = Uuid::parse_str(&raw).map_err(|err| {
228
        wasmtime::Error::msg(format!("account-balance: invalid uuid '{raw}': {err}"))
229
    })?;
230
180
    let commodity_id = single_commodity_for(user_id, account_id).await?;
231
162
    let (numer, denom) = single_rational_for(user_id, account_id).await?;
232
162
    Ok((numer, denom, commodity_id))
233
180
}
234

            
235
180
async fn single_commodity_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<Uuid> {
236
180
    match GetAccountCommodities::new()
237
180
        .user_id(user_id)
238
180
        .account_id(account_id)
239
180
        .run()
240
180
        .await
241
    {
242
180
        Ok(Some(CmdResult::CommodityInfoList(items))) => match items.as_slice() {
243
162
            [info] => Ok(info.commodity_id),
244
18
            [] => Err(wasmtime::Error::msg(
245
18
                "account-balance: account has no commodity yet (no splits); cannot produce \
246
18
                 Commodity-typed value",
247
18
            )),
248
            _ => Err(wasmtime::Error::msg(
249
                "account-balance: account holds multiple commodities; use get-balance instead",
250
            )),
251
        },
252
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
253
            "account-balance: expected CommodityInfoList, got {other:?}"
254
        ))),
255
        Ok(None) => Err(wasmtime::Error::msg(
256
            "account-balance: account has no commodity yet (no splits); cannot produce \
257
             Commodity-typed value",
258
        )),
259
        Err(err) => Err(wasmtime::Error::msg(format!("account-balance: {err}"))),
260
    }
261
180
}
262

            
263
162
async fn single_rational_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<(i64, i64)> {
264
162
    match GetBalance::new()
265
162
        .user_id(user_id)
266
162
        .account_id(account_id)
267
162
        .run()
268
162
        .await
269
    {
270
162
        Ok(Some(CmdResult::Rational(r))) => Ok((*r.numer(), *r.denom())),
271
        Ok(None) => Ok((0, 1)),
272
        Ok(Some(CmdResult::MultiCurrencyBalance(_))) => Err(wasmtime::Error::msg(
273
            "account-balance: account holds multiple commodities; use get-balance instead",
274
        )),
275
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
276
            "account-balance: unexpected variant {other:?}"
277
        ))),
278
        Err(err) => Err(wasmtime::Error::msg(format!("account-balance: {err}"))),
279
    }
280
162
}
281

            
282
/// First composable native: returns an i32 on the wasm stack rather than
283
/// streaming an S-expr string through the capture queue. Drops the
284
/// self-capturing restriction so forms like `(+ 10 (account-count))`
285
/// compile and run end-to-end. Counts the session user's accounts via
286
/// ListAccounts.
287
async fn count_accounts(user_id: Uuid) -> i32 {
288
    match ListAccounts::new().user_id(user_id).run().await {
289
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => entities.len() as i32,
290
        _ => 0,
291
    }
292
}
293

            
294
/// Single-account variant of list-accounts-for-manage. Same wire shape
295
/// (`:accounts-tree` head) — single-element list on hit, empty list on
296
/// miss — so emacs clients can use the same renderer for both.
297
18
async fn run_get_account_for_manage(
298
18
    caller: &mut Caller<'_, SessionData>,
299
18
    user_id: Uuid,
300
18
    id_arg: Option<String>,
301
18
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
302
18
    let account_id = parse_get_account_for_manage_id(id_arg)?;
303
18
    let result = GetAccountForManage::new()
304
18
        .user_id(user_id)
305
18
        .account_id(account_id)
306
18
        .run()
307
18
        .await;
308
18
    let entries = list_account_entries("get-account-for-manage", result)?;
309
18
    match entries.into_iter().next() {
310
        Some((id, name, parent)) => Ok(Some(
311
            alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?,
312
        )),
313
18
        None => Ok(None),
314
    }
315
18
}
316

            
317
20
fn parse_get_account_for_manage_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
318
20
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
319
1
        wasmtime::Error::msg("get-account-for-manage: missing or empty :account-id arg")
320
1
    })?;
321
19
    Uuid::parse_str(&raw).map_err(|err| {
322
1
        wasmtime::Error::msg(format!(
323
            "get-account-for-manage: invalid uuid '{raw}': {err}"
324
        ))
325
1
    })
326
20
}
327

            
328
/// Test-only legacy renderer; production now ships typed `pair<account>`
329
/// via `alloc_account_chain`. Retained for `format_manage_tree_*` test
330
/// assertions until A6 collapses the streaming-string envelope.
331
#[cfg(test)]
332
3
fn format_manage_tree(
333
3
    entities: &[(
334
3
        FinanceEntity,
335
3
        std::collections::HashMap<String, FinanceEntity>,
336
3
    )],
337
3
) -> String {
338
3
    let mut out = String::from("(:accounts-tree (");
339
3
    for (idx, (entity, tags)) in entities.iter().enumerate() {
340
2
        if idx > 0 {
341
            out.push(' ');
342
2
        }
343
2
        match entity {
344
2
            FinanceEntity::Account(account) => {
345
2
                let parent = match account.parent {
346
1
                    Some(p) => format!("\"{p}\""),
347
1
                    None => "nil".to_string(),
348
                };
349
2
                out.push_str(&format!("(:id \"{}\" :parent-id {}", account.id, parent));
350
2
                if let Some(name) = tags.get("name").and_then(|t| match t {
351
1
                    FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
352
                    _ => None,
353
1
                }) {
354
1
                    out.push_str(&format!(" :name {}", quote_string(name)));
355
1
                }
356
2
                out.push(')');
357
            }
358
            other => {
359
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
360
            }
361
        }
362
    }
363
3
    out.push_str("))");
364
3
    out
365
3
}
366

            
367
/// Creates a new account under the session user with the given `name`
368
/// tag. Optional `parent` field on the server command is skipped for
369
/// v1 — hierarchy nesting waits on either a follow-up 2-arg native
370
/// or keyword-pair extension to the capture queue. Returns
371
/// `(:account-id "<uuid>")` on success.
372
/// Returns the new account's UUID as a string so the caller can compose
373
/// it into follow-up calls (`(set-account-tag (create-account "Foo") ...)`).
374
/// Every failure mode surfaces as a `wasmtime::Error` — the trap classifier
375
/// renders the `:error` envelope; a typed `StringRef` return has no slot
376
/// for diagnostic payloads.
377
452
async fn run_create_account(user_id: Uuid, name_arg: Option<String>) -> wasmtime::Result<String> {
378
452
    let name = name_arg
379
452
        .filter(|s| !s.is_empty())
380
452
        .ok_or_else(|| wasmtime::Error::msg("create-account: missing or empty :name arg"))?;
381
450
    match CreateAccount::new().name(name).user_id(user_id).run().await {
382
450
        Ok(Some(CmdResult::Entity(FinanceEntity::Account(account)))) => Ok(account.id.to_string()),
383
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
384
            "create-account: expected Account entity, got {other:?}"
385
        ))),
386
        Ok(None) => Err(wasmtime::Error::msg(
387
            "create-account: command returned no entity",
388
        )),
389
        Err(err) => Err(wasmtime::Error::msg(format!("create-account: {err}"))),
390
    }
391
452
}
392

            
393
/// First 3-arg StringRef native. Sets `tag_name=tag_value` on the
394
/// account identified by UUID. Optional `description` field on the
395
/// server command is skipped for v1 — passing four args would force a
396
/// keyword-pair extension to the capture queue, which can land later
397
/// once a real use case shows up. Returns lisp `t` on success.
398
22
async fn run_set_account_tag(
399
22
    user_id: Uuid,
400
22
    id_arg: Option<String>,
401
22
    name_arg: Option<String>,
402
22
    value_arg: Option<String>,
403
22
) -> wasmtime::Result<i32> {
404
22
    let raw = id_arg
405
22
        .filter(|s| !s.is_empty())
406
22
        .ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :account-id arg"))?;
407
21
    let account_id = Uuid::parse_str(&raw).map_err(|err| {
408
1
        wasmtime::Error::msg(format!("set-account-tag: invalid uuid '{raw}': {err}"))
409
1
    })?;
410
20
    let tag_name = name_arg
411
20
        .filter(|s| !s.is_empty())
412
20
        .ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :tag-name arg"))?;
413
18
    let tag_value =
414
19
        value_arg.ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing :tag-value arg"))?;
415
18
    SetAccountTag::new()
416
18
        .user_id(user_id)
417
18
        .account_id(account_id)
418
18
        .tag_name(tag_name)
419
18
        .tag_value(tag_value)
420
18
        .run()
421
18
        .await
422
18
        .map(|_| 1)
423
18
        .map_err(|err| wasmtime::Error::msg(format!("set-account-tag: {err}")))
424
22
}
425

            
426
18
async fn run_get_account_commodities(
427
18
    caller: &mut Caller<'_, SessionData>,
428
18
    user_id: Uuid,
429
18
    id_arg: Option<String>,
430
18
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
431
18
    let account_id = parse_account_commodities_id(id_arg)?;
432
18
    let result = GetAccountCommodities::new()
433
18
        .user_id(user_id)
434
18
        .account_id(account_id)
435
18
        .run()
436
18
        .await;
437
18
    let items = match result {
438
18
        Ok(Some(CmdResult::CommodityInfoList(items))) => items,
439
        Ok(Some(other)) => {
440
            return Err(wasmtime::Error::msg(format!(
441
                "get-account-commodities: expected CommodityInfoList, got {other:?}"
442
            )));
443
        }
444
        Ok(None) => Vec::new(),
445
        Err(err) => {
446
            return Err(wasmtime::Error::msg(format!(
447
                "get-account-commodities: {err}"
448
            )));
449
        }
450
    };
451
18
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(items.len());
452
18
    for info in items {
453
        let id_ref = alloc_string_ref(caller, info.commodity_id.to_string().as_bytes())?;
454
        let symbol_ref = alloc_string_ref(caller, info.symbol.as_bytes())?;
455
        let name_ref = alloc_string_ref(caller, info.name.as_bytes())?;
456
        let args = [
457
            Val::AnyRef(Some(id_ref.to_anyref())),
458
            Val::AnyRef(Some(symbol_ref.to_anyref())),
459
            Val::AnyRef(Some(name_ref.to_anyref())),
460
        ];
461
        let entity_ref = alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await?;
462
        anyrefs.push(entity_ref.to_anyref());
463
    }
464
18
    alloc_pair_chain(caller, anyrefs).await
465
18
}
466

            
467
20
fn parse_account_commodities_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
468
20
    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
469
1
        wasmtime::Error::msg("get-account-commodities: missing or empty :account-id arg")
470
1
    })?;
471
19
    Uuid::parse_str(&raw).map_err(|err| {
472
1
        wasmtime::Error::msg(format!(
473
            "get-account-commodities: invalid uuid '{raw}': {err}"
474
        ))
475
1
    })
476
20
}
477

            
478
#[cfg(test)]
479
2
fn format_commodity_info_list(items: &[CommodityInfo]) -> String {
480
2
    let mut out = String::from("(:account-commodities (");
481
2
    for (idx, info) in items.iter().enumerate() {
482
2
        if idx > 0 {
483
1
            out.push(' ');
484
1
        }
485
2
        out.push_str(&format!(
486
2
            "(:commodity-id \"{}\" :symbol {} :name {})",
487
2
            info.commodity_id,
488
2
            quote_string(&info.symbol),
489
2
            quote_string(&info.name),
490
2
        ));
491
    }
492
2
    out.push_str("))");
493
2
    out
494
2
}
495

            
496
/// Runs `GetBalance` against `account_id`. Returns the single-currency
497
/// rational balance (numer, denom). Multi-currency accounts trap with a
498
/// structured error — `account-balance` is the commodity-bearing variant;
499
/// a future `get-balances` native will return `pair<commodity-balance>`
500
/// for the multi case once that entity shape lands.
501
20
async fn run_get_balance_single(
502
20
    user_id: Uuid,
503
20
    id_arg: Option<String>,
504
20
) -> wasmtime::Result<(i64, i64)> {
505
20
    let raw = id_arg
506
20
        .filter(|s| !s.is_empty())
507
20
        .ok_or_else(|| wasmtime::Error::msg("get-balance: missing or empty :account-id arg"))?;
508
19
    let account_id = Uuid::parse_str(&raw)
509
19
        .map_err(|err| wasmtime::Error::msg(format!("get-balance: invalid uuid '{raw}': {err}")))?;
510
18
    match GetBalance::new()
511
18
        .user_id(user_id)
512
18
        .account_id(account_id)
513
18
        .run()
514
18
        .await
515
    {
516
18
        Ok(Some(CmdResult::Rational(r))) => Ok((*r.numer(), *r.denom())),
517
        Ok(Some(CmdResult::MultiCurrencyBalance(_))) => Err(wasmtime::Error::msg(
518
            "get-balance: multi-currency account — use get-balances for the typed pair return",
519
        )),
520
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
521
            "get-balance: expected Rational, got {other:?}"
522
        ))),
523
        Ok(None) => Ok((0, 1)),
524
        Err(err) => Err(wasmtime::Error::msg(format!("get-balance: {err}"))),
525
    }
526
20
}
527

            
528
/// Renders a `Rational64` as `num` when denom is 1 else `num/denom`.
529
/// Matches nomiscript's Fraction printer so client-side `(read)`'s
530
/// number type recovers a native fraction.
531
#[cfg(test)]
532
5
fn format_rational(r: &Rational64) -> String {
533
5
    if *r.denom() == 1 {
534
3
        r.numer().to_string()
535
    } else {
536
2
        format!("{}/{}", r.numer(), r.denom())
537
    }
538
5
}
539

            
540
/// Resolves the lookup key against either `account_id` (UUID-shaped string)
541
/// or `account_name` (anything else). Mirrors how `cli-core` and the TUI
542
/// pick between the two columns from a single user-typed string — the
543
/// emacs client likewise hands us `(get-account "abc")` without committing
544
/// to a column.
545
270
async fn run_get_account(
546
270
    caller: &mut Caller<'_, SessionData>,
547
270
    user_id: Uuid,
548
270
    key_arg: Option<String>,
549
270
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
550
270
    let key = validate_lookup_key("get-account", key_arg)?;
551
270
    let mut runner = GetAccount::new().user_id(user_id);
552
270
    let result = match Uuid::parse_str(&key) {
553
162
        Ok(id) => runner.account_id(id).run().await,
554
        Err(_) => {
555
108
            runner = runner.account_name(key);
556
108
            runner.run().await
557
        }
558
    };
559
270
    let entries = list_account_entries("get-account", result)?;
560
270
    match entries.into_iter().next() {
561
216
        Some((id, name, parent)) => Ok(Some(
562
216
            alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?,
563
        )),
564
54
        None => Ok(None),
565
    }
566
270
}
567

            
568
/// Validates the lookup-key arg. Extracted from `run_get_account` so the
569
/// validation contract is reachable from unit tests that can't construct
570
/// a wasmtime `Caller`.
571
272
fn validate_lookup_key(name: &str, key_arg: Option<String>) -> wasmtime::Result<String> {
572
272
    key_arg
573
272
        .filter(|s| !s.is_empty())
574
272
        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty lookup key")))
575
272
}
576

            
577
/// (id, name-tag, parent-uuid) tuple flattened out of `TaggedEntities` so
578
/// the wasm marshalling site walks a single typed row per account.
579
type AccountEntry = (String, Option<String>, Option<String>);
580

            
581
/// Pulls (id, name-tag, parent-uuid) per account from the TaggedEntities
582
/// result. Account rows lacking the standard `name` tag surface as `None`
583
/// for the field — the typed wasm struct rides nullable string refs so
584
/// downstream `(account-name e)` returns null without trapping.
585
342
fn list_account_entries(
586
342
    name: &str,
587
342
    result: Result<Option<CmdResult>, CmdError>,
588
342
) -> wasmtime::Result<Vec<AccountEntry>> {
589
342
    match result {
590
342
        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
591
342
            .into_iter()
592
342
            .filter_map(|(entity, tags)| match entity {
593
234
                FinanceEntity::Account(a) => Some((
594
234
                    a.id.to_string(),
595
234
                    tag_value_str(&tags, "name"),
596
234
                    a.parent.map(|u| u.to_string()),
597
                )),
598
                _ => None,
599
234
            })
600
342
            .collect()),
601
        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
602
            "{name}: expected TaggedEntities, got {other:?}"
603
        ))),
604
        Ok(None) => Ok(Vec::new()),
605
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
606
    }
607
342
}
608

            
609
234
fn tag_value_str(
610
234
    tags: &std::collections::HashMap<String, FinanceEntity>,
611
234
    key: &str,
612
234
) -> Option<String> {
613
234
    tags.get(key).and_then(|t| match t {
614
234
        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.clone()),
615
        _ => None,
616
234
    })
617
234
}
618

            
619
234
async fn alloc_account_entity(
620
234
    caller: &mut Caller<'_, SessionData>,
621
234
    id: &str,
622
234
    name: Option<&str>,
623
234
    parent: Option<&str>,
624
234
) -> wasmtime::Result<Rooted<StructRef>> {
625
234
    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
626
234
    let name_ref = match name {
627
234
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
628
        None => None,
629
    };
630
234
    let parent_ref = match parent {
631
        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
632
234
        None => None,
633
    };
634
234
    let args = [
635
234
        Val::AnyRef(Some(id_ref.to_anyref())),
636
234
        Val::AnyRef(name_ref.map(|r| r.to_anyref())),
637
234
        Val::AnyRef(parent_ref.map(|r| r.to_anyref())),
638
    ];
639
234
    alloc_entity_via_export(caller, "alloc_account", &args).await
640
234
}
641

            
642
54
async fn alloc_account_chain(
643
54
    caller: &mut Caller<'_, SessionData>,
644
54
    entries: Vec<(String, Option<String>, Option<String>)>,
645
54
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
646
54
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
647
54
    for (id, name, parent) in entries {
648
18
        let entity_ref =
649
18
            alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?;
650
18
        anyrefs.push(entity_ref.to_anyref());
651
    }
652
54
    alloc_pair_chain(caller, anyrefs).await
653
54
}
654

            
655
/// Test-only: legacy textual envelope kept for the existing format
656
/// assertions until A6 collapses the streaming-string capture protocol.
657
/// Production paths now ship typed `pair<account>` / `EntityRef(Account)`
658
/// values via `alloc_account_chain` and `run_get_account`.
659
#[cfg(test)]
660
4
fn format_tagged_entities(
661
4
    entities: &[(
662
4
        FinanceEntity,
663
4
        std::collections::HashMap<String, FinanceEntity>,
664
4
    )],
665
4
) -> String {
666
4
    let mut out = String::from("(:accounts (");
667
4
    for (idx, (entity, tags)) in entities.iter().enumerate() {
668
3
        if idx > 0 {
669
            out.push(' ');
670
3
        }
671
3
        let id = match entity {
672
3
            FinanceEntity::Account(a) => a.id,
673
            other => {
674
                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
675
                continue;
676
            }
677
        };
678
3
        let name = tags.get("name").and_then(|t| match t {
679
2
            FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
680
            _ => None,
681
2
        });
682
3
        out.push_str(&format!("(:id \"{id}\""));
683
3
        if let Some(name) = name {
684
2
            out.push_str(&format!(" :name {}", quote_string(name)));
685
2
        }
686
3
        out.push(')');
687
    }
688
4
    out.push_str("))");
689
4
    out
690
4
}
691

            
692
#[cfg(test)]
693
10
fn quote_string(s: &str) -> String {
694
10
    let mut q = String::with_capacity(s.len() + 2);
695
10
    q.push('"');
696
62
    for ch in s.chars() {
697
62
        match ch {
698
3
            '"' => q.push_str("\\\""),
699
1
            '\\' => q.push_str("\\\\"),
700
58
            other => q.push(other),
701
        }
702
    }
703
10
    q.push('"');
704
10
    q
705
10
}
706

            
707
#[cfg(test)]
708
mod tests {
709
    use super::*;
710
    use finance::account::Account;
711
    use std::collections::HashMap;
712
    use uuid::Uuid;
713

            
714
3
    fn account_entity(id: Uuid) -> FinanceEntity {
715
3
        FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"))
716
3
    }
717

            
718
    #[test]
719
1
    fn format_empty_list() {
720
1
        assert_eq!(format_tagged_entities(&[]), "(:accounts ())");
721
1
    }
722

            
723
    #[test]
724
1
    fn format_single_account_no_tags() {
725
1
        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
726
1
        let out = format_tagged_entities(&[(account_entity(id), HashMap::new())]);
727
1
        assert_eq!(
728
            out,
729
            "(:accounts ((:id \"550e8400-e29b-41d4-a716-446655440000\")))"
730
        );
731
1
    }
732

            
733
    #[test]
734
1
    fn format_account_with_name_tag() {
735
1
        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
736
1
        let mut tags = HashMap::new();
737
1
        tags.insert(
738
1
            "name".to_string(),
739
1
            FinanceEntity::Tag(Tag {
740
1
                id: Uuid::nil(),
741
1
                tag_name: "name".to_string(),
742
1
                tag_value: "Checking".to_string(),
743
1
                description: None,
744
1
            }),
745
        );
746
1
        let out = format_tagged_entities(&[(account_entity(id), tags)]);
747
1
        assert!(out.contains(":name \"Checking\""));
748
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
749
1
    }
750

            
751
    #[test]
752
1
    fn format_escapes_quotes_in_name() {
753
1
        let id = Uuid::nil();
754
1
        let mut tags = HashMap::new();
755
1
        tags.insert(
756
1
            "name".to_string(),
757
1
            FinanceEntity::Tag(Tag {
758
1
                id: Uuid::nil(),
759
1
                tag_name: "name".to_string(),
760
1
                tag_value: "He said \"hi\"".to_string(),
761
1
                description: None,
762
1
            }),
763
        );
764
1
        let out = format_tagged_entities(&[(account_entity(id), tags)]);
765
1
        assert!(out.contains("\"He said \\\"hi\\\"\""));
766
1
    }
767

            
768
    #[test]
769
1
    fn quote_string_round_trip_safe() {
770
1
        assert_eq!(quote_string("simple"), "\"simple\"");
771
1
        assert_eq!(quote_string("a\"b"), "\"a\\\"b\"");
772
1
        assert_eq!(quote_string("a\\b"), "\"a\\\\b\"");
773
1
    }
774

            
775
    #[test]
776
1
    fn validate_lookup_key_rejects_missing_arg() {
777
1
        let err = validate_lookup_key("get-account", None).unwrap_err();
778
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
779
1
    }
780

            
781
    #[test]
782
1
    fn validate_lookup_key_rejects_empty_string() {
783
1
        let err = validate_lookup_key("get-account", Some(String::new())).unwrap_err();
784
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
785
1
    }
786

            
787
    #[test]
788
1
    fn format_rational_integer_drops_denom() {
789
1
        assert_eq!(format_rational(&Rational64::new(42, 1)), "42");
790
1
        assert_eq!(format_rational(&Rational64::new(0, 1)), "0");
791
1
    }
792

            
793
    #[test]
794
1
    fn format_rational_fraction_preserves_denom() {
795
1
        assert_eq!(format_rational(&Rational64::new(5000, 100)), "50");
796
1
        assert_eq!(format_rational(&Rational64::new(1, 3)), "1/3");
797
1
        assert_eq!(format_rational(&Rational64::new(-7, 2)), "-7/2");
798
1
    }
799

            
800
    #[tokio::test]
801
1
    async fn run_get_balance_single_with_no_arg_emits_error() {
802
1
        let err = run_get_balance_single(Uuid::nil(), None).await.unwrap_err();
803
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
804
1
    }
805

            
806
    #[tokio::test]
807
1
    async fn run_get_balance_single_with_invalid_uuid_emits_error() {
808
1
        let err = run_get_balance_single(Uuid::nil(), Some("not-uuid".into()))
809
1
            .await
810
1
            .unwrap_err();
811
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
812
1
    }
813

            
814
    #[test]
815
1
    fn format_account_commodities_empty() {
816
1
        assert_eq!(format_commodity_info_list(&[]), "(:account-commodities ())");
817
1
    }
818

            
819
    #[test]
820
1
    fn format_account_commodities_multi() {
821
1
        let id1 = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
822
1
        let id2 = Uuid::parse_str("71ddfbdb-1f00-4403-9548-dc973b43e443").unwrap();
823
1
        let items = vec![
824
1
            CommodityInfo {
825
1
                commodity_id: id1,
826
1
                symbol: "USD".into(),
827
1
                name: "US Dollar".into(),
828
1
            },
829
1
            CommodityInfo {
830
1
                commodity_id: id2,
831
1
                symbol: "JPY".into(),
832
1
                name: "Japanese Yen".into(),
833
1
            },
834
        ];
835
1
        let out = format_commodity_info_list(&items);
836
1
        assert!(out.contains(":commodity-id \"550e8400-e29b-41d4-a716-446655440000\""));
837
1
        assert!(out.contains(":symbol \"USD\""));
838
1
        assert!(out.contains(":name \"US Dollar\""));
839
1
        assert!(out.contains(":commodity-id \"71ddfbdb-1f00-4403-9548-dc973b43e443\""));
840
1
        assert!(out.contains(":symbol \"JPY\""));
841
1
    }
842

            
843
    #[test]
844
1
    fn parse_account_commodities_rejects_missing() {
845
1
        let err = parse_account_commodities_id(None).unwrap_err();
846
1
        assert!(err.to_string().contains("missing or empty"), "got: {err}");
847
1
    }
848

            
849
    #[test]
850
1
    fn parse_account_commodities_rejects_invalid_uuid() {
851
1
        let err = parse_account_commodities_id(Some("nope".into())).unwrap_err();
852
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
853
1
    }
854

            
855
    #[tokio::test]
856
1
    async fn run_set_account_tag_missing_id_emits_error() {
857
1
        let err = run_set_account_tag(Uuid::nil(), None, Some("k".into()), Some("v".into()))
858
1
            .await
859
1
            .unwrap_err();
860
1
        assert!(err.to_string().contains(":account-id"), "got: {err}");
861
1
    }
862

            
863
    #[tokio::test]
864
1
    async fn run_set_account_tag_invalid_uuid_emits_error() {
865
1
        let err = run_set_account_tag(
866
1
            Uuid::nil(),
867
1
            Some("not-uuid".into()),
868
1
            Some("k".into()),
869
1
            Some("v".into()),
870
1
        )
871
1
        .await
872
1
        .unwrap_err();
873
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
874
1
    }
875

            
876
    #[tokio::test]
877
1
    async fn run_set_account_tag_missing_name_emits_error() {
878
1
        let id = "11111111-1111-1111-1111-111111111111";
879
1
        let err = run_set_account_tag(Uuid::nil(), Some(id.into()), None, Some("v".into()))
880
1
            .await
881
1
            .unwrap_err();
882
1
        assert!(err.to_string().contains(":tag-name"), "got: {err}");
883
1
    }
884

            
885
    #[tokio::test]
886
1
    async fn run_set_account_tag_missing_value_emits_error() {
887
1
        let id = "11111111-1111-1111-1111-111111111111";
888
1
        let err = run_set_account_tag(Uuid::nil(), Some(id.into()), Some("k".into()), None)
889
1
            .await
890
1
            .unwrap_err();
891
1
        assert!(err.to_string().contains(":tag-value"), "got: {err}");
892
1
    }
893

            
894
    #[tokio::test]
895
1
    async fn run_create_account_missing_name_emits_error() {
896
1
        let err = run_create_account(Uuid::nil(), None).await.unwrap_err();
897
1
        assert!(err.to_string().contains(":name"), "got: {err}");
898
1
    }
899

            
900
    #[tokio::test]
901
1
    async fn run_create_account_empty_name_emits_error() {
902
1
        let err = run_create_account(Uuid::nil(), Some(String::new()))
903
1
            .await
904
1
            .unwrap_err();
905
1
        assert!(err.to_string().contains(":name"), "got: {err}");
906
1
    }
907

            
908
    #[test]
909
1
    fn format_manage_tree_empty() {
910
1
        assert_eq!(format_manage_tree(&[]), "(:accounts-tree ())");
911
1
    }
912

            
913
    #[test]
914
1
    fn format_manage_tree_root_emits_nil_parent() {
915
1
        let id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
916
1
        let root =
917
1
            FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"));
918
1
        let out = format_manage_tree(&[(root, HashMap::new())]);
919
1
        assert!(out.contains(":id \"11111111-1111-1111-1111-111111111111\""));
920
1
        assert!(out.contains(":parent-id nil"));
921
1
    }
922

            
923
    #[test]
924
1
    fn parse_get_account_for_manage_id_rejects_missing() {
925
1
        let err = parse_get_account_for_manage_id(None).unwrap_err();
926
1
        assert!(err.to_string().contains(":account-id"), "got: {err}");
927
1
    }
928

            
929
    #[test]
930
1
    fn parse_get_account_for_manage_id_rejects_invalid_uuid() {
931
1
        let err = parse_get_account_for_manage_id(Some("not-uuid".into())).unwrap_err();
932
1
        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
933
1
    }
934

            
935
    #[test]
936
1
    fn format_manage_tree_child_surfaces_parent_uuid_and_name() {
937
1
        let id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap();
938
1
        let parent = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
939
1
        let child = FinanceEntity::Account(
940
1
            Account::builder()
941
1
                .id(id)
942
1
                .parent(parent)
943
1
                .build()
944
1
                .expect("account builder"),
945
1
        );
946
1
        let mut tags = HashMap::new();
947
1
        tags.insert(
948
1
            "name".to_string(),
949
1
            FinanceEntity::Tag(Tag {
950
1
                id: Uuid::nil(),
951
1
                tag_name: "name".to_string(),
952
1
                tag_value: "Sub".to_string(),
953
1
                description: None,
954
1
            }),
955
        );
956
1
        let out = format_manage_tree(&[(child, tags)]);
957
1
        assert!(out.contains(":parent-id \"11111111-1111-1111-1111-111111111111\""));
958
1
        assert!(out.contains(":name \"Sub\""));
959
1
    }
960
}