Skip to main content

rpc/natives/
account.rs

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
13use finance::tag::Tag;
14#[cfg(test)]
15use num_rational::Rational64;
16use 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)]
21use server::command::CommodityInfo;
22use server::command::account::{
23    CreateAccount, GetAccount, GetAccountCommodities, GetAccountForManage, GetBalance,
24    ListAccounts, ListAccountsForManage, SetAccountTag,
25};
26use server::command::{CmdError, CmdResult, FinanceEntity};
27use uuid::Uuid;
28use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
29
30use crate::session::SessionData;
31
32pub 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
43pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
44    register_readonly(linker)?;
45    register_mutators(linker)?;
46    Ok(())
47}
48
49pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
50    linker.func_wrap_async(
51        "nomi",
52        "account_list_accounts",
53        |mut caller: Caller<'_, SessionData>,
54         ()|
55         -> Box<
56            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
57        > {
58            Box::new(async move {
59                let user_id = caller.data().ctx().user_id;
60                let result = ListAccounts::new().user_id(user_id).run().await;
61                let entries = list_account_entries("list-accounts", result)?;
62                alloc_account_chain(&mut caller, entries).await
63            })
64        },
65    )?;
66    linker.func_wrap_async(
67        "nomi",
68        "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        > {
74            Box::new(async move {
75                let user_id = caller.data().ctx().user_id;
76                let key = read_string_arg(&mut caller, key_arg)?;
77                run_get_account(&mut caller, user_id, key).await
78            })
79        },
80    )?;
81    linker.func_wrap_async(
82        "nomi",
83        "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        > {
89            Box::new(async move {
90                let user_id = caller.data().ctx().user_id;
91                let id = read_string_arg(&mut caller, id_arg)?;
92                let (numer, denom) = run_get_balance_single(user_id, id).await?;
93                Ok(Some(alloc_ratio_ref(&mut caller, numer, denom)?))
94            })
95        },
96    )?;
97    linker.func_wrap_async(
98        "nomi",
99        "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        > {
105            Box::new(async move {
106                let user_id = caller.data().ctx().user_id;
107                let id = read_string_arg(&mut caller, id_arg)?;
108                run_get_account_commodities(&mut caller, user_id, id).await
109            })
110        },
111    )?;
112    linker.func_wrap_async(
113        "nomi",
114        "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        > {
120            Box::new(async move {
121                let user_id = caller.data().ctx().user_id;
122                let result = ListAccountsForManage::new().user_id(user_id).run().await;
123                let entries = list_account_entries("list-accounts-for-manage", result)?;
124                alloc_account_chain(&mut caller, entries).await
125            })
126        },
127    )?;
128    linker.func_wrap_async(
129        "nomi",
130        "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        > {
136            Box::new(async move {
137                let user_id = caller.data().ctx().user_id;
138                let id = read_string_arg(&mut caller, id_arg)?;
139                run_get_account_for_manage(&mut caller, user_id, id).await
140            })
141        },
142    )?;
143    linker.func_wrap_async(
144        "nomi",
145        "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    linker.func_wrap_async(
156        "nomi",
157        "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        > {
163            Box::new(async move {
164                let user_id = caller.data().ctx().user_id;
165                let id = read_string_arg(&mut caller, id_arg)?;
166                let (numer, denom, commodity_id) = resolve_balance(user_id, id).await?;
167                let ref_ = alloc_commodity_ref(&mut caller, numer, denom, commodity_id).await?;
168                Ok(Some(ref_))
169            })
170        },
171    )?;
172    Ok(())
173}
174
175pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
176    linker.func_wrap_async(
177        "nomi",
178        "account_set_account_tag",
179        |mut caller: Caller<'_, SessionData>,
180         (id_arg, name_arg, value_arg): super::StringArgTriple|
181         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
182            Box::new(async move {
183                let user_id = caller.data().ctx().user_id;
184                let id = read_string_arg(&mut caller, id_arg)?;
185                let name = read_string_arg(&mut caller, name_arg)?;
186                let value = read_string_arg(&mut caller, value_arg)?;
187                run_set_account_tag(user_id, id, name, value).await
188            })
189        },
190    )?;
191    linker.func_wrap_async(
192        "nomi",
193        "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        > {
199            Box::new(async move {
200                let user_id = caller.data().ctx().user_id;
201                let name = read_string_arg(&mut caller, name_arg)?;
202                let id = run_create_account(user_id, name).await?;
203                Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
204            })
205        },
206    )?;
207    Ok(())
208}
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.
220async fn resolve_balance(
221    user_id: Uuid,
222    id_arg: Option<String>,
223) -> wasmtime::Result<(i64, i64, Uuid)> {
224    let raw = id_arg
225        .filter(|s| !s.is_empty())
226        .ok_or_else(|| wasmtime::Error::msg("account-balance: missing or empty :account-id arg"))?;
227    let account_id = Uuid::parse_str(&raw).map_err(|err| {
228        wasmtime::Error::msg(format!("account-balance: invalid uuid '{raw}': {err}"))
229    })?;
230    let commodity_id = single_commodity_for(user_id, account_id).await?;
231    let (numer, denom) = single_rational_for(user_id, account_id).await?;
232    Ok((numer, denom, commodity_id))
233}
234
235async fn single_commodity_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<Uuid> {
236    match GetAccountCommodities::new()
237        .user_id(user_id)
238        .account_id(account_id)
239        .run()
240        .await
241    {
242        Ok(Some(CmdResult::CommodityInfoList(items))) => match items.as_slice() {
243            [info] => Ok(info.commodity_id),
244            [] => Err(wasmtime::Error::msg(
245                "account-balance: account has no commodity yet (no splits); cannot produce \
246                 Commodity-typed value",
247            )),
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}
262
263async fn single_rational_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<(i64, i64)> {
264    match GetBalance::new()
265        .user_id(user_id)
266        .account_id(account_id)
267        .run()
268        .await
269    {
270        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}
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.
287async 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.
297async fn run_get_account_for_manage(
298    caller: &mut Caller<'_, SessionData>,
299    user_id: Uuid,
300    id_arg: Option<String>,
301) -> wasmtime::Result<Option<Rooted<StructRef>>> {
302    let account_id = parse_get_account_for_manage_id(id_arg)?;
303    let result = GetAccountForManage::new()
304        .user_id(user_id)
305        .account_id(account_id)
306        .run()
307        .await;
308    let entries = list_account_entries("get-account-for-manage", result)?;
309    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        None => Ok(None),
314    }
315}
316
317fn parse_get_account_for_manage_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
318    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
319        wasmtime::Error::msg("get-account-for-manage: missing or empty :account-id arg")
320    })?;
321    Uuid::parse_str(&raw).map_err(|err| {
322        wasmtime::Error::msg(format!(
323            "get-account-for-manage: invalid uuid '{raw}': {err}"
324        ))
325    })
326}
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)]
332fn format_manage_tree(
333    entities: &[(
334        FinanceEntity,
335        std::collections::HashMap<String, FinanceEntity>,
336    )],
337) -> String {
338    let mut out = String::from("(:accounts-tree (");
339    for (idx, (entity, tags)) in entities.iter().enumerate() {
340        if idx > 0 {
341            out.push(' ');
342        }
343        match entity {
344            FinanceEntity::Account(account) => {
345                let parent = match account.parent {
346                    Some(p) => format!("\"{p}\""),
347                    None => "nil".to_string(),
348                };
349                out.push_str(&format!("(:id \"{}\" :parent-id {}", account.id, parent));
350                if let Some(name) = tags.get("name").and_then(|t| match t {
351                    FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
352                    _ => None,
353                }) {
354                    out.push_str(&format!(" :name {}", quote_string(name)));
355                }
356                out.push(')');
357            }
358            other => {
359                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
360            }
361        }
362    }
363    out.push_str("))");
364    out
365}
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.
377async fn run_create_account(user_id: Uuid, name_arg: Option<String>) -> wasmtime::Result<String> {
378    let name = name_arg
379        .filter(|s| !s.is_empty())
380        .ok_or_else(|| wasmtime::Error::msg("create-account: missing or empty :name arg"))?;
381    match CreateAccount::new().name(name).user_id(user_id).run().await {
382        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}
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.
398async fn run_set_account_tag(
399    user_id: Uuid,
400    id_arg: Option<String>,
401    name_arg: Option<String>,
402    value_arg: Option<String>,
403) -> wasmtime::Result<i32> {
404    let raw = id_arg
405        .filter(|s| !s.is_empty())
406        .ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :account-id arg"))?;
407    let account_id = Uuid::parse_str(&raw).map_err(|err| {
408        wasmtime::Error::msg(format!("set-account-tag: invalid uuid '{raw}': {err}"))
409    })?;
410    let tag_name = name_arg
411        .filter(|s| !s.is_empty())
412        .ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :tag-name arg"))?;
413    let tag_value =
414        value_arg.ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing :tag-value arg"))?;
415    SetAccountTag::new()
416        .user_id(user_id)
417        .account_id(account_id)
418        .tag_name(tag_name)
419        .tag_value(tag_value)
420        .run()
421        .await
422        .map(|_| 1)
423        .map_err(|err| wasmtime::Error::msg(format!("set-account-tag: {err}")))
424}
425
426async fn run_get_account_commodities(
427    caller: &mut Caller<'_, SessionData>,
428    user_id: Uuid,
429    id_arg: Option<String>,
430) -> wasmtime::Result<Option<Rooted<StructRef>>> {
431    let account_id = parse_account_commodities_id(id_arg)?;
432    let result = GetAccountCommodities::new()
433        .user_id(user_id)
434        .account_id(account_id)
435        .run()
436        .await;
437    let items = match result {
438        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    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(items.len());
452    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    alloc_pair_chain(caller, anyrefs).await
465}
466
467fn parse_account_commodities_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
468    let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
469        wasmtime::Error::msg("get-account-commodities: missing or empty :account-id arg")
470    })?;
471    Uuid::parse_str(&raw).map_err(|err| {
472        wasmtime::Error::msg(format!(
473            "get-account-commodities: invalid uuid '{raw}': {err}"
474        ))
475    })
476}
477
478#[cfg(test)]
479fn format_commodity_info_list(items: &[CommodityInfo]) -> String {
480    let mut out = String::from("(:account-commodities (");
481    for (idx, info) in items.iter().enumerate() {
482        if idx > 0 {
483            out.push(' ');
484        }
485        out.push_str(&format!(
486            "(:commodity-id \"{}\" :symbol {} :name {})",
487            info.commodity_id,
488            quote_string(&info.symbol),
489            quote_string(&info.name),
490        ));
491    }
492    out.push_str("))");
493    out
494}
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.
501async fn run_get_balance_single(
502    user_id: Uuid,
503    id_arg: Option<String>,
504) -> wasmtime::Result<(i64, i64)> {
505    let raw = id_arg
506        .filter(|s| !s.is_empty())
507        .ok_or_else(|| wasmtime::Error::msg("get-balance: missing or empty :account-id arg"))?;
508    let account_id = Uuid::parse_str(&raw)
509        .map_err(|err| wasmtime::Error::msg(format!("get-balance: invalid uuid '{raw}': {err}")))?;
510    match GetBalance::new()
511        .user_id(user_id)
512        .account_id(account_id)
513        .run()
514        .await
515    {
516        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}
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)]
532fn format_rational(r: &Rational64) -> String {
533    if *r.denom() == 1 {
534        r.numer().to_string()
535    } else {
536        format!("{}/{}", r.numer(), r.denom())
537    }
538}
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.
545async fn run_get_account(
546    caller: &mut Caller<'_, SessionData>,
547    user_id: Uuid,
548    key_arg: Option<String>,
549) -> wasmtime::Result<Option<Rooted<StructRef>>> {
550    let key = validate_lookup_key("get-account", key_arg)?;
551    let mut runner = GetAccount::new().user_id(user_id);
552    let result = match Uuid::parse_str(&key) {
553        Ok(id) => runner.account_id(id).run().await,
554        Err(_) => {
555            runner = runner.account_name(key);
556            runner.run().await
557        }
558    };
559    let entries = list_account_entries("get-account", result)?;
560    match entries.into_iter().next() {
561        Some((id, name, parent)) => Ok(Some(
562            alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?,
563        )),
564        None => Ok(None),
565    }
566}
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`.
571fn validate_lookup_key(name: &str, key_arg: Option<String>) -> wasmtime::Result<String> {
572    key_arg
573        .filter(|s| !s.is_empty())
574        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty lookup key")))
575}
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.
579type 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.
585fn list_account_entries(
586    name: &str,
587    result: Result<Option<CmdResult>, CmdError>,
588) -> wasmtime::Result<Vec<AccountEntry>> {
589    match result {
590        Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
591            .into_iter()
592            .filter_map(|(entity, tags)| match entity {
593                FinanceEntity::Account(a) => Some((
594                    a.id.to_string(),
595                    tag_value_str(&tags, "name"),
596                    a.parent.map(|u| u.to_string()),
597                )),
598                _ => None,
599            })
600            .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}
608
609fn tag_value_str(
610    tags: &std::collections::HashMap<String, FinanceEntity>,
611    key: &str,
612) -> Option<String> {
613    tags.get(key).and_then(|t| match t {
614        FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.clone()),
615        _ => None,
616    })
617}
618
619async fn alloc_account_entity(
620    caller: &mut Caller<'_, SessionData>,
621    id: &str,
622    name: Option<&str>,
623    parent: Option<&str>,
624) -> wasmtime::Result<Rooted<StructRef>> {
625    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
626    let name_ref = match name {
627        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
628        None => None,
629    };
630    let parent_ref = match parent {
631        Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
632        None => None,
633    };
634    let args = [
635        Val::AnyRef(Some(id_ref.to_anyref())),
636        Val::AnyRef(name_ref.map(|r| r.to_anyref())),
637        Val::AnyRef(parent_ref.map(|r| r.to_anyref())),
638    ];
639    alloc_entity_via_export(caller, "alloc_account", &args).await
640}
641
642async fn alloc_account_chain(
643    caller: &mut Caller<'_, SessionData>,
644    entries: Vec<(String, Option<String>, Option<String>)>,
645) -> wasmtime::Result<Option<Rooted<StructRef>>> {
646    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
647    for (id, name, parent) in entries {
648        let entity_ref =
649            alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?;
650        anyrefs.push(entity_ref.to_anyref());
651    }
652    alloc_pair_chain(caller, anyrefs).await
653}
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)]
660fn format_tagged_entities(
661    entities: &[(
662        FinanceEntity,
663        std::collections::HashMap<String, FinanceEntity>,
664    )],
665) -> String {
666    let mut out = String::from("(:accounts (");
667    for (idx, (entity, tags)) in entities.iter().enumerate() {
668        if idx > 0 {
669            out.push(' ');
670        }
671        let id = match entity {
672            FinanceEntity::Account(a) => a.id,
673            other => {
674                out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
675                continue;
676            }
677        };
678        let name = tags.get("name").and_then(|t| match t {
679            FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
680            _ => None,
681        });
682        out.push_str(&format!("(:id \"{id}\""));
683        if let Some(name) = name {
684            out.push_str(&format!(" :name {}", quote_string(name)));
685        }
686        out.push(')');
687    }
688    out.push_str("))");
689    out
690}
691
692#[cfg(test)]
693fn quote_string(s: &str) -> String {
694    let mut q = String::with_capacity(s.len() + 2);
695    q.push('"');
696    for ch in s.chars() {
697        match ch {
698            '"' => q.push_str("\\\""),
699            '\\' => q.push_str("\\\\"),
700            other => q.push(other),
701        }
702    }
703    q.push('"');
704    q
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use finance::account::Account;
711    use std::collections::HashMap;
712    use uuid::Uuid;
713
714    fn account_entity(id: Uuid) -> FinanceEntity {
715        FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"))
716    }
717
718    #[test]
719    fn format_empty_list() {
720        assert_eq!(format_tagged_entities(&[]), "(:accounts ())");
721    }
722
723    #[test]
724    fn format_single_account_no_tags() {
725        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
726        let out = format_tagged_entities(&[(account_entity(id), HashMap::new())]);
727        assert_eq!(
728            out,
729            "(:accounts ((:id \"550e8400-e29b-41d4-a716-446655440000\")))"
730        );
731    }
732
733    #[test]
734    fn format_account_with_name_tag() {
735        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
736        let mut tags = HashMap::new();
737        tags.insert(
738            "name".to_string(),
739            FinanceEntity::Tag(Tag {
740                id: Uuid::nil(),
741                tag_name: "name".to_string(),
742                tag_value: "Checking".to_string(),
743                description: None,
744            }),
745        );
746        let out = format_tagged_entities(&[(account_entity(id), tags)]);
747        assert!(out.contains(":name \"Checking\""));
748        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
749    }
750
751    #[test]
752    fn format_escapes_quotes_in_name() {
753        let id = Uuid::nil();
754        let mut tags = HashMap::new();
755        tags.insert(
756            "name".to_string(),
757            FinanceEntity::Tag(Tag {
758                id: Uuid::nil(),
759                tag_name: "name".to_string(),
760                tag_value: "He said \"hi\"".to_string(),
761                description: None,
762            }),
763        );
764        let out = format_tagged_entities(&[(account_entity(id), tags)]);
765        assert!(out.contains("\"He said \\\"hi\\\"\""));
766    }
767
768    #[test]
769    fn quote_string_round_trip_safe() {
770        assert_eq!(quote_string("simple"), "\"simple\"");
771        assert_eq!(quote_string("a\"b"), "\"a\\\"b\"");
772        assert_eq!(quote_string("a\\b"), "\"a\\\\b\"");
773    }
774
775    #[test]
776    fn validate_lookup_key_rejects_missing_arg() {
777        let err = validate_lookup_key("get-account", None).unwrap_err();
778        assert!(err.to_string().contains("missing or empty"), "got: {err}");
779    }
780
781    #[test]
782    fn validate_lookup_key_rejects_empty_string() {
783        let err = validate_lookup_key("get-account", Some(String::new())).unwrap_err();
784        assert!(err.to_string().contains("missing or empty"), "got: {err}");
785    }
786
787    #[test]
788    fn format_rational_integer_drops_denom() {
789        assert_eq!(format_rational(&Rational64::new(42, 1)), "42");
790        assert_eq!(format_rational(&Rational64::new(0, 1)), "0");
791    }
792
793    #[test]
794    fn format_rational_fraction_preserves_denom() {
795        assert_eq!(format_rational(&Rational64::new(5000, 100)), "50");
796        assert_eq!(format_rational(&Rational64::new(1, 3)), "1/3");
797        assert_eq!(format_rational(&Rational64::new(-7, 2)), "-7/2");
798    }
799
800    #[tokio::test]
801    async fn run_get_balance_single_with_no_arg_emits_error() {
802        let err = run_get_balance_single(Uuid::nil(), None).await.unwrap_err();
803        assert!(err.to_string().contains("missing or empty"), "got: {err}");
804    }
805
806    #[tokio::test]
807    async fn run_get_balance_single_with_invalid_uuid_emits_error() {
808        let err = run_get_balance_single(Uuid::nil(), Some("not-uuid".into()))
809            .await
810            .unwrap_err();
811        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
812    }
813
814    #[test]
815    fn format_account_commodities_empty() {
816        assert_eq!(format_commodity_info_list(&[]), "(:account-commodities ())");
817    }
818
819    #[test]
820    fn format_account_commodities_multi() {
821        let id1 = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
822        let id2 = Uuid::parse_str("71ddfbdb-1f00-4403-9548-dc973b43e443").unwrap();
823        let items = vec![
824            CommodityInfo {
825                commodity_id: id1,
826                symbol: "USD".into(),
827                name: "US Dollar".into(),
828            },
829            CommodityInfo {
830                commodity_id: id2,
831                symbol: "JPY".into(),
832                name: "Japanese Yen".into(),
833            },
834        ];
835        let out = format_commodity_info_list(&items);
836        assert!(out.contains(":commodity-id \"550e8400-e29b-41d4-a716-446655440000\""));
837        assert!(out.contains(":symbol \"USD\""));
838        assert!(out.contains(":name \"US Dollar\""));
839        assert!(out.contains(":commodity-id \"71ddfbdb-1f00-4403-9548-dc973b43e443\""));
840        assert!(out.contains(":symbol \"JPY\""));
841    }
842
843    #[test]
844    fn parse_account_commodities_rejects_missing() {
845        let err = parse_account_commodities_id(None).unwrap_err();
846        assert!(err.to_string().contains("missing or empty"), "got: {err}");
847    }
848
849    #[test]
850    fn parse_account_commodities_rejects_invalid_uuid() {
851        let err = parse_account_commodities_id(Some("nope".into())).unwrap_err();
852        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
853    }
854
855    #[tokio::test]
856    async fn run_set_account_tag_missing_id_emits_error() {
857        let err = run_set_account_tag(Uuid::nil(), None, Some("k".into()), Some("v".into()))
858            .await
859            .unwrap_err();
860        assert!(err.to_string().contains(":account-id"), "got: {err}");
861    }
862
863    #[tokio::test]
864    async fn run_set_account_tag_invalid_uuid_emits_error() {
865        let err = run_set_account_tag(
866            Uuid::nil(),
867            Some("not-uuid".into()),
868            Some("k".into()),
869            Some("v".into()),
870        )
871        .await
872        .unwrap_err();
873        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
874    }
875
876    #[tokio::test]
877    async fn run_set_account_tag_missing_name_emits_error() {
878        let id = "11111111-1111-1111-1111-111111111111";
879        let err = run_set_account_tag(Uuid::nil(), Some(id.into()), None, Some("v".into()))
880            .await
881            .unwrap_err();
882        assert!(err.to_string().contains(":tag-name"), "got: {err}");
883    }
884
885    #[tokio::test]
886    async fn run_set_account_tag_missing_value_emits_error() {
887        let id = "11111111-1111-1111-1111-111111111111";
888        let err = run_set_account_tag(Uuid::nil(), Some(id.into()), Some("k".into()), None)
889            .await
890            .unwrap_err();
891        assert!(err.to_string().contains(":tag-value"), "got: {err}");
892    }
893
894    #[tokio::test]
895    async fn run_create_account_missing_name_emits_error() {
896        let err = run_create_account(Uuid::nil(), None).await.unwrap_err();
897        assert!(err.to_string().contains(":name"), "got: {err}");
898    }
899
900    #[tokio::test]
901    async fn run_create_account_empty_name_emits_error() {
902        let err = run_create_account(Uuid::nil(), Some(String::new()))
903            .await
904            .unwrap_err();
905        assert!(err.to_string().contains(":name"), "got: {err}");
906    }
907
908    #[test]
909    fn format_manage_tree_empty() {
910        assert_eq!(format_manage_tree(&[]), "(:accounts-tree ())");
911    }
912
913    #[test]
914    fn format_manage_tree_root_emits_nil_parent() {
915        let id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
916        let root =
917            FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"));
918        let out = format_manage_tree(&[(root, HashMap::new())]);
919        assert!(out.contains(":id \"11111111-1111-1111-1111-111111111111\""));
920        assert!(out.contains(":parent-id nil"));
921    }
922
923    #[test]
924    fn parse_get_account_for_manage_id_rejects_missing() {
925        let err = parse_get_account_for_manage_id(None).unwrap_err();
926        assert!(err.to_string().contains(":account-id"), "got: {err}");
927    }
928
929    #[test]
930    fn parse_get_account_for_manage_id_rejects_invalid_uuid() {
931        let err = parse_get_account_for_manage_id(Some("not-uuid".into())).unwrap_err();
932        assert!(err.to_string().contains("invalid uuid"), "got: {err}");
933    }
934
935    #[test]
936    fn format_manage_tree_child_surfaces_parent_uuid_and_name() {
937        let id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap();
938        let parent = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
939        let child = FinanceEntity::Account(
940            Account::builder()
941                .id(id)
942                .parent(parent)
943                .build()
944                .expect("account builder"),
945        );
946        let mut tags = HashMap::new();
947        tags.insert(
948            "name".to_string(),
949            FinanceEntity::Tag(Tag {
950                id: Uuid::nil(),
951                tag_name: "name".to_string(),
952                tag_value: "Sub".to_string(),
953                description: None,
954            }),
955        );
956        let out = format_manage_tree(&[(child, tags)]);
957        assert!(out.contains(":parent-id \"11111111-1111-1111-1111-111111111111\""));
958        assert!(out.contains(":name \"Sub\""));
959    }
960}