1use 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
210async 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
282async 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
294async 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#[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
367async 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
393async 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
496async 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#[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
540async 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
568fn 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
577type AccountEntry = (String, Option<String>, Option<String>);
580
581fn 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#[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}