Lines
85.71 %
Functions
47.16 %
Branches
100 %
//! Account-domain natives. Wraps `server::command::{CreateAccount, ListAccounts,
//! ListAccountsForManage, GetAccountForManage, SetAccountTag, GetAccount,
//! GetAccountCommodities, GetBalance}` as nomiscript-callable host fns.
//!
//! `list-accounts` is the first DB-touching command in the registry; its
//! result (`CmdResult::TaggedEntities`) is marshalled into an S-expression
//! string via `format_tagged_entities`, captured through the streaming-string
//! path. The emacs client `(read)`s the resulting string to recover the
//! structured shape. Per-command marshalling lives next to each native rather
//! than in a single registry-wide serializer because each `CmdResult` variant
//! has different fields worth surfacing on the wire.
use finance::tag::Tag;
#[cfg(test)]
use num_rational::Rational64;
use scripting::runtime::{
alloc_commodity_ref, alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref,
alloc_string_ref, read_string_arg,
};
use server::command::CommodityInfo;
use server::command::account::{
CreateAccount, GetAccount, GetAccountCommodities, GetAccountForManage, GetBalance,
ListAccounts, ListAccountsForManage, SetAccountTag,
use server::command::{CmdError, CmdResult, FinanceEntity};
use uuid::Uuid;
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
use crate::session::SessionData;
pub const REGISTERED_COMMANDS: &[&str] = &[
"create-account",
"list-accounts",
"list-accounts-for-manage",
"get-account-for-manage",
"set-account-tag",
"get-account",
"get-account-commodities",
"get-balance",
];
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
register_readonly(linker)?;
register_mutators(linker)?;
Ok(())
}
pub fn register_readonly(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
linker.func_wrap_async(
"nomi",
"account_list_accounts",
|mut caller: Caller<'_, SessionData>,
()|
-> Box<
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
> {
Box::new(async move {
let user_id = caller.data().ctx().user_id;
let result = ListAccounts::new().user_id(user_id).run().await;
let entries = list_account_entries("list-accounts", result)?;
alloc_account_chain(&mut caller, entries).await
})
},
)?;
"account_get_account",
(key_arg,): (Option<Rooted<ArrayRef>>,)|
let key = read_string_arg(&mut caller, key_arg)?;
run_get_account(&mut caller, user_id, key).await
"account_get_balance",
(id_arg,): (Option<Rooted<ArrayRef>>,)|
let id = read_string_arg(&mut caller, id_arg)?;
let (numer, denom) = run_get_balance_single(user_id, id).await?;
Ok(Some(alloc_ratio_ref(&mut caller, numer, denom)?))
"account_get_account_commodities",
run_get_account_commodities(&mut caller, user_id, id).await
"account_list_accounts_for_manage",
let result = ListAccountsForManage::new().user_id(user_id).run().await;
let entries = list_account_entries("list-accounts-for-manage", result)?;
"account_get_account_for_manage",
run_get_account_for_manage(&mut caller, user_id, id).await
"account_account_count",
|caller: Caller<'_, SessionData>,
-> Box<dyn std::future::Future<Output = i32> + Send> {
count_accounts(user_id).await
"account_account_balance",
let (numer, denom, commodity_id) = resolve_balance(user_id, id).await?;
let ref_ = alloc_commodity_ref(&mut caller, numer, denom, commodity_id).await?;
Ok(Some(ref_))
pub fn register_mutators(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
"account_set_account_tag",
(id_arg, name_arg, value_arg): super::StringArgTriple|
-> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
let name = read_string_arg(&mut caller, name_arg)?;
let value = read_string_arg(&mut caller, value_arg)?;
run_set_account_tag(user_id, id, name, value).await
"account_create_account",
(name_arg,): (Option<Rooted<ArrayRef>>,)|
dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
let id = run_create_account(user_id, name).await?;
Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?))
/// Composable, commodity-bearing balance accessor. Single-currency only —
/// returns the `$commodity` struct ref directly. Multi-currency,
/// missing-commodity, or sqlx errors trap the running form: callers
/// compose with `+` / `-` / `*` expecting a single dimensioned scalar,
/// and any of those failure modes is genuinely invalid money arithmetic
/// at the point of use.
///
/// Resolves the commodity via `GetAccountCommodities` (must return exactly
/// one row) before calling `GetBalance`. The two are inherently coupled —
/// a Rational balance with no commodity context would be unsound.
async fn resolve_balance(
user_id: Uuid,
id_arg: Option<String>,
) -> wasmtime::Result<(i64, i64, Uuid)> {
let raw = id_arg
.filter(|s| !s.is_empty())
.ok_or_else(|| wasmtime::Error::msg("account-balance: missing or empty :account-id arg"))?;
let account_id = Uuid::parse_str(&raw).map_err(|err| {
wasmtime::Error::msg(format!("account-balance: invalid uuid '{raw}': {err}"))
})?;
let commodity_id = single_commodity_for(user_id, account_id).await?;
let (numer, denom) = single_rational_for(user_id, account_id).await?;
Ok((numer, denom, commodity_id))
async fn single_commodity_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<Uuid> {
match GetAccountCommodities::new()
.user_id(user_id)
.account_id(account_id)
.run()
.await
{
Ok(Some(CmdResult::CommodityInfoList(items))) => match items.as_slice() {
[info] => Ok(info.commodity_id),
[] => Err(wasmtime::Error::msg(
"account-balance: account has no commodity yet (no splits); cannot produce \
Commodity-typed value",
)),
_ => Err(wasmtime::Error::msg(
"account-balance: account holds multiple commodities; use get-balance instead",
Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
"account-balance: expected CommodityInfoList, got {other:?}"
))),
Ok(None) => Err(wasmtime::Error::msg(
Err(err) => Err(wasmtime::Error::msg(format!("account-balance: {err}"))),
async fn single_rational_for(user_id: Uuid, account_id: Uuid) -> wasmtime::Result<(i64, i64)> {
match GetBalance::new()
Ok(Some(CmdResult::Rational(r))) => Ok((*r.numer(), *r.denom())),
Ok(None) => Ok((0, 1)),
Ok(Some(CmdResult::MultiCurrencyBalance(_))) => Err(wasmtime::Error::msg(
"account-balance: unexpected variant {other:?}"
/// First composable native: returns an i32 on the wasm stack rather than
/// streaming an S-expr string through the capture queue. Drops the
/// self-capturing restriction so forms like `(+ 10 (account-count))`
/// compile and run end-to-end. Counts the session user's accounts via
/// ListAccounts.
async fn count_accounts(user_id: Uuid) -> i32 {
match ListAccounts::new().user_id(user_id).run().await {
Ok(Some(CmdResult::TaggedEntities { entities, .. })) => entities.len() as i32,
_ => 0,
/// Single-account variant of list-accounts-for-manage. Same wire shape
/// (`:accounts-tree` head) — single-element list on hit, empty list on
/// miss — so emacs clients can use the same renderer for both.
async fn run_get_account_for_manage(
caller: &mut Caller<'_, SessionData>,
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
let account_id = parse_get_account_for_manage_id(id_arg)?;
let result = GetAccountForManage::new()
.await;
let entries = list_account_entries("get-account-for-manage", result)?;
match entries.into_iter().next() {
Some((id, name, parent)) => Ok(Some(
alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?,
None => Ok(None),
fn parse_get_account_for_manage_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
let raw = id_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
wasmtime::Error::msg("get-account-for-manage: missing or empty :account-id arg")
Uuid::parse_str(&raw).map_err(|err| {
wasmtime::Error::msg(format!(
"get-account-for-manage: invalid uuid '{raw}': {err}"
))
/// Test-only legacy renderer; production now ships typed `pair<account>`
/// via `alloc_account_chain`. Retained for `format_manage_tree_*` test
/// assertions until A6 collapses the streaming-string envelope.
fn format_manage_tree(
entities: &[(
FinanceEntity,
std::collections::HashMap<String, FinanceEntity>,
)],
) -> String {
let mut out = String::from("(:accounts-tree (");
for (idx, (entity, tags)) in entities.iter().enumerate() {
if idx > 0 {
out.push(' ');
match entity {
FinanceEntity::Account(account) => {
let parent = match account.parent {
Some(p) => format!("\"{p}\""),
None => "nil".to_string(),
out.push_str(&format!("(:id \"{}\" :parent-id {}", account.id, parent));
if let Some(name) = tags.get("name").and_then(|t| match t {
FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.as_str()),
_ => None,
}) {
out.push_str(&format!(" :name {}", quote_string(name)));
out.push(')');
other => {
out.push_str(&format!("(:error \"unexpected entity {other:?}\")"));
out.push_str("))");
out
/// Creates a new account under the session user with the given `name`
/// tag. Optional `parent` field on the server command is skipped for
/// v1 — hierarchy nesting waits on either a follow-up 2-arg native
/// or keyword-pair extension to the capture queue. Returns
/// `(:account-id "<uuid>")` on success.
/// Returns the new account's UUID as a string so the caller can compose
/// it into follow-up calls (`(set-account-tag (create-account "Foo") ...)`).
/// Every failure mode surfaces as a `wasmtime::Error` — the trap classifier
/// renders the `:error` envelope; a typed `StringRef` return has no slot
/// for diagnostic payloads.
async fn run_create_account(user_id: Uuid, name_arg: Option<String>) -> wasmtime::Result<String> {
let name = name_arg
.ok_or_else(|| wasmtime::Error::msg("create-account: missing or empty :name arg"))?;
match CreateAccount::new().name(name).user_id(user_id).run().await {
Ok(Some(CmdResult::Entity(FinanceEntity::Account(account)))) => Ok(account.id.to_string()),
"create-account: expected Account entity, got {other:?}"
"create-account: command returned no entity",
Err(err) => Err(wasmtime::Error::msg(format!("create-account: {err}"))),
/// First 3-arg StringRef native. Sets `tag_name=tag_value` on the
/// account identified by UUID. Optional `description` field on the
/// server command is skipped for v1 — passing four args would force a
/// keyword-pair extension to the capture queue, which can land later
/// once a real use case shows up. Returns lisp `t` on success.
async fn run_set_account_tag(
name_arg: Option<String>,
value_arg: Option<String>,
) -> wasmtime::Result<i32> {
.ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :account-id arg"))?;
wasmtime::Error::msg(format!("set-account-tag: invalid uuid '{raw}': {err}"))
let tag_name = name_arg
.ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing or empty :tag-name arg"))?;
let tag_value =
value_arg.ok_or_else(|| wasmtime::Error::msg("set-account-tag: missing :tag-value arg"))?;
SetAccountTag::new()
.tag_name(tag_name)
.tag_value(tag_value)
.map(|_| 1)
.map_err(|err| wasmtime::Error::msg(format!("set-account-tag: {err}")))
async fn run_get_account_commodities(
let account_id = parse_account_commodities_id(id_arg)?;
let result = GetAccountCommodities::new()
let items = match result {
Ok(Some(CmdResult::CommodityInfoList(items))) => items,
Ok(Some(other)) => {
return Err(wasmtime::Error::msg(format!(
"get-account-commodities: expected CommodityInfoList, got {other:?}"
)));
Ok(None) => Vec::new(),
Err(err) => {
"get-account-commodities: {err}"
let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(items.len());
for info in items {
let id_ref = alloc_string_ref(caller, info.commodity_id.to_string().as_bytes())?;
let symbol_ref = alloc_string_ref(caller, info.symbol.as_bytes())?;
let name_ref = alloc_string_ref(caller, info.name.as_bytes())?;
let args = [
Val::AnyRef(Some(id_ref.to_anyref())),
Val::AnyRef(Some(symbol_ref.to_anyref())),
Val::AnyRef(Some(name_ref.to_anyref())),
let entity_ref = alloc_entity_via_export(caller, "alloc_commodity_entity", &args).await?;
anyrefs.push(entity_ref.to_anyref());
alloc_pair_chain(caller, anyrefs).await
fn parse_account_commodities_id(id_arg: Option<String>) -> wasmtime::Result<Uuid> {
wasmtime::Error::msg("get-account-commodities: missing or empty :account-id arg")
"get-account-commodities: invalid uuid '{raw}': {err}"
fn format_commodity_info_list(items: &[CommodityInfo]) -> String {
let mut out = String::from("(:account-commodities (");
for (idx, info) in items.iter().enumerate() {
out.push_str(&format!(
"(:commodity-id \"{}\" :symbol {} :name {})",
info.commodity_id,
quote_string(&info.symbol),
quote_string(&info.name),
));
/// Runs `GetBalance` against `account_id`. Returns the single-currency
/// rational balance (numer, denom). Multi-currency accounts trap with a
/// structured error — `account-balance` is the commodity-bearing variant;
/// a future `get-balances` native will return `pair<commodity-balance>`
/// for the multi case once that entity shape lands.
async fn run_get_balance_single(
) -> wasmtime::Result<(i64, i64)> {
.ok_or_else(|| wasmtime::Error::msg("get-balance: missing or empty :account-id arg"))?;
let account_id = Uuid::parse_str(&raw)
.map_err(|err| wasmtime::Error::msg(format!("get-balance: invalid uuid '{raw}': {err}")))?;
"get-balance: multi-currency account — use get-balances for the typed pair return",
"get-balance: expected Rational, got {other:?}"
Err(err) => Err(wasmtime::Error::msg(format!("get-balance: {err}"))),
/// Renders a `Rational64` as `num` when denom is 1 else `num/denom`.
/// Matches nomiscript's Fraction printer so client-side `(read)`'s
/// number type recovers a native fraction.
fn format_rational(r: &Rational64) -> String {
if *r.denom() == 1 {
r.numer().to_string()
} else {
format!("{}/{}", r.numer(), r.denom())
/// Resolves the lookup key against either `account_id` (UUID-shaped string)
/// or `account_name` (anything else). Mirrors how `cli-core` and the TUI
/// pick between the two columns from a single user-typed string — the
/// emacs client likewise hands us `(get-account "abc")` without committing
/// to a column.
async fn run_get_account(
key_arg: Option<String>,
let key = validate_lookup_key("get-account", key_arg)?;
let mut runner = GetAccount::new().user_id(user_id);
let result = match Uuid::parse_str(&key) {
Ok(id) => runner.account_id(id).run().await,
Err(_) => {
runner = runner.account_name(key);
runner.run().await
let entries = list_account_entries("get-account", result)?;
/// Validates the lookup-key arg. Extracted from `run_get_account` so the
/// validation contract is reachable from unit tests that can't construct
/// a wasmtime `Caller`.
fn validate_lookup_key(name: &str, key_arg: Option<String>) -> wasmtime::Result<String> {
key_arg
.ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty lookup key")))
/// (id, name-tag, parent-uuid) tuple flattened out of `TaggedEntities` so
/// the wasm marshalling site walks a single typed row per account.
type AccountEntry = (String, Option<String>, Option<String>);
/// Pulls (id, name-tag, parent-uuid) per account from the TaggedEntities
/// result. Account rows lacking the standard `name` tag surface as `None`
/// for the field — the typed wasm struct rides nullable string refs so
/// downstream `(account-name e)` returns null without trapping.
fn list_account_entries(
name: &str,
result: Result<Option<CmdResult>, CmdError>,
) -> wasmtime::Result<Vec<AccountEntry>> {
match result {
Ok(Some(CmdResult::TaggedEntities { entities, .. })) => Ok(entities
.into_iter()
.filter_map(|(entity, tags)| match entity {
FinanceEntity::Account(a) => Some((
a.id.to_string(),
tag_value_str(&tags, "name"),
a.parent.map(|u| u.to_string()),
.collect()),
"{name}: expected TaggedEntities, got {other:?}"
Ok(None) => Ok(Vec::new()),
Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
fn tag_value_str(
tags: &std::collections::HashMap<String, FinanceEntity>,
key: &str,
) -> Option<String> {
tags.get(key).and_then(|t| match t {
FinanceEntity::Tag(Tag { tag_value, .. }) => Some(tag_value.clone()),
async fn alloc_account_entity(
id: &str,
name: Option<&str>,
parent: Option<&str>,
) -> wasmtime::Result<Rooted<StructRef>> {
let id_ref = alloc_string_ref(caller, id.as_bytes())?;
let name_ref = match name {
Some(s) => Some(alloc_string_ref(caller, s.as_bytes())?),
None => None,
let parent_ref = match parent {
Val::AnyRef(name_ref.map(|r| r.to_anyref())),
Val::AnyRef(parent_ref.map(|r| r.to_anyref())),
alloc_entity_via_export(caller, "alloc_account", &args).await
async fn alloc_account_chain(
entries: Vec<(String, Option<String>, Option<String>)>,
let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
for (id, name, parent) in entries {
let entity_ref =
alloc_account_entity(caller, &id, name.as_deref(), parent.as_deref()).await?;
/// Test-only: legacy textual envelope kept for the existing format
/// assertions until A6 collapses the streaming-string capture protocol.
/// Production paths now ship typed `pair<account>` / `EntityRef(Account)`
/// values via `alloc_account_chain` and `run_get_account`.
fn format_tagged_entities(
let mut out = String::from("(:accounts (");
let id = match entity {
FinanceEntity::Account(a) => a.id,
continue;
let name = tags.get("name").and_then(|t| match t {
});
out.push_str(&format!("(:id \"{id}\""));
if let Some(name) = name {
fn quote_string(s: &str) -> String {
let mut q = String::with_capacity(s.len() + 2);
q.push('"');
for ch in s.chars() {
match ch {
'"' => q.push_str("\\\""),
'\\' => q.push_str("\\\\"),
other => q.push(other),
q
mod tests {
use super::*;
use finance::account::Account;
use std::collections::HashMap;
fn account_entity(id: Uuid) -> FinanceEntity {
FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"))
#[test]
fn format_empty_list() {
assert_eq!(format_tagged_entities(&[]), "(:accounts ())");
fn format_single_account_no_tags() {
let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let out = format_tagged_entities(&[(account_entity(id), HashMap::new())]);
assert_eq!(
out,
"(:accounts ((:id \"550e8400-e29b-41d4-a716-446655440000\")))"
);
fn format_account_with_name_tag() {
let mut tags = HashMap::new();
tags.insert(
"name".to_string(),
FinanceEntity::Tag(Tag {
id: Uuid::nil(),
tag_name: "name".to_string(),
tag_value: "Checking".to_string(),
description: None,
}),
let out = format_tagged_entities(&[(account_entity(id), tags)]);
assert!(out.contains(":name \"Checking\""));
assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
fn format_escapes_quotes_in_name() {
let id = Uuid::nil();
tag_value: "He said \"hi\"".to_string(),
assert!(out.contains("\"He said \\\"hi\\\"\""));
fn quote_string_round_trip_safe() {
assert_eq!(quote_string("simple"), "\"simple\"");
assert_eq!(quote_string("a\"b"), "\"a\\\"b\"");
assert_eq!(quote_string("a\\b"), "\"a\\\\b\"");
fn validate_lookup_key_rejects_missing_arg() {
let err = validate_lookup_key("get-account", None).unwrap_err();
assert!(err.to_string().contains("missing or empty"), "got: {err}");
fn validate_lookup_key_rejects_empty_string() {
let err = validate_lookup_key("get-account", Some(String::new())).unwrap_err();
fn format_rational_integer_drops_denom() {
assert_eq!(format_rational(&Rational64::new(42, 1)), "42");
assert_eq!(format_rational(&Rational64::new(0, 1)), "0");
fn format_rational_fraction_preserves_denom() {
assert_eq!(format_rational(&Rational64::new(5000, 100)), "50");
assert_eq!(format_rational(&Rational64::new(1, 3)), "1/3");
assert_eq!(format_rational(&Rational64::new(-7, 2)), "-7/2");
#[tokio::test]
async fn run_get_balance_single_with_no_arg_emits_error() {
let err = run_get_balance_single(Uuid::nil(), None).await.unwrap_err();
async fn run_get_balance_single_with_invalid_uuid_emits_error() {
let err = run_get_balance_single(Uuid::nil(), Some("not-uuid".into()))
.unwrap_err();
assert!(err.to_string().contains("invalid uuid"), "got: {err}");
fn format_account_commodities_empty() {
assert_eq!(format_commodity_info_list(&[]), "(:account-commodities ())");
fn format_account_commodities_multi() {
let id1 = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let id2 = Uuid::parse_str("71ddfbdb-1f00-4403-9548-dc973b43e443").unwrap();
let items = vec![
CommodityInfo {
commodity_id: id1,
symbol: "USD".into(),
name: "US Dollar".into(),
commodity_id: id2,
symbol: "JPY".into(),
name: "Japanese Yen".into(),
let out = format_commodity_info_list(&items);
assert!(out.contains(":commodity-id \"550e8400-e29b-41d4-a716-446655440000\""));
assert!(out.contains(":symbol \"USD\""));
assert!(out.contains(":name \"US Dollar\""));
assert!(out.contains(":commodity-id \"71ddfbdb-1f00-4403-9548-dc973b43e443\""));
assert!(out.contains(":symbol \"JPY\""));
fn parse_account_commodities_rejects_missing() {
let err = parse_account_commodities_id(None).unwrap_err();
fn parse_account_commodities_rejects_invalid_uuid() {
let err = parse_account_commodities_id(Some("nope".into())).unwrap_err();
async fn run_set_account_tag_missing_id_emits_error() {
let err = run_set_account_tag(Uuid::nil(), None, Some("k".into()), Some("v".into()))
assert!(err.to_string().contains(":account-id"), "got: {err}");
async fn run_set_account_tag_invalid_uuid_emits_error() {
let err = run_set_account_tag(
Uuid::nil(),
Some("not-uuid".into()),
Some("k".into()),
Some("v".into()),
)
async fn run_set_account_tag_missing_name_emits_error() {
let id = "11111111-1111-1111-1111-111111111111";
let err = run_set_account_tag(Uuid::nil(), Some(id.into()), None, Some("v".into()))
assert!(err.to_string().contains(":tag-name"), "got: {err}");
async fn run_set_account_tag_missing_value_emits_error() {
let err = run_set_account_tag(Uuid::nil(), Some(id.into()), Some("k".into()), None)
assert!(err.to_string().contains(":tag-value"), "got: {err}");
async fn run_create_account_missing_name_emits_error() {
let err = run_create_account(Uuid::nil(), None).await.unwrap_err();
assert!(err.to_string().contains(":name"), "got: {err}");
async fn run_create_account_empty_name_emits_error() {
let err = run_create_account(Uuid::nil(), Some(String::new()))
fn format_manage_tree_empty() {
assert_eq!(format_manage_tree(&[]), "(:accounts-tree ())");
fn format_manage_tree_root_emits_nil_parent() {
let id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
let root =
FinanceEntity::Account(Account::builder().id(id).build().expect("account builder"));
let out = format_manage_tree(&[(root, HashMap::new())]);
assert!(out.contains(":id \"11111111-1111-1111-1111-111111111111\""));
assert!(out.contains(":parent-id nil"));
fn parse_get_account_for_manage_id_rejects_missing() {
let err = parse_get_account_for_manage_id(None).unwrap_err();
fn parse_get_account_for_manage_id_rejects_invalid_uuid() {
let err = parse_get_account_for_manage_id(Some("not-uuid".into())).unwrap_err();
fn format_manage_tree_child_surfaces_parent_uuid_and_name() {
let id = Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap();
let parent = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
let child = FinanceEntity::Account(
Account::builder()
.id(id)
.parent(parent)
.build()
.expect("account builder"),
tag_value: "Sub".to_string(),
let out = format_manage_tree(&[(child, tags)]);
assert!(out.contains(":parent-id \"11111111-1111-1111-1111-111111111111\""));
assert!(out.contains(":name \"Sub\""));