1use chrono::{DateTime, Utc};
5use num_rational::Rational64;
6use scripting::runtime::{alloc_string_ref, read_string_arg};
7use server::command::report::{ActivityReport, BalanceReport, CategoryBreakdown};
8use server::command::{
9 ActivityData, ActivityPeriod, BreakdownData, BreakdownPeriod, BreakdownRow, CmdResult,
10 ReportData, ReportNode,
11};
12use wasmtime::{ArrayRef, Caller, Linker, Rooted};
13
14use crate::session::SessionData;
15
16pub const REGISTERED_COMMANDS: &[&str] =
17 &["balance-report", "activity-report", "category-breakdown"];
18
19pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
20 linker.func_wrap_async(
21 "nomi",
22 "report_balance_report",
23 |mut caller: Caller<'_, SessionData>,
24 ()|
25 -> Box<
26 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
27 > {
28 Box::new(async move {
29 let user_id = caller.data().ctx().user_id;
30 let payload = run_balance_report(user_id).await?;
31 Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
32 })
33 },
34 )?;
35 linker.func_wrap_async(
36 "nomi",
37 "report_activity_report",
38 |mut caller: Caller<'_, SessionData>,
39 (from_arg, to_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
40 -> Box<
41 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
42 > {
43 Box::new(async move {
44 let user_id = caller.data().ctx().user_id;
45 let from = read_string_arg(&mut caller, from_arg)?;
46 let to = read_string_arg(&mut caller, to_arg)?;
47 let payload = run_activity_report(user_id, from, to).await?;
48 Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
49 })
50 },
51 )?;
52 linker.func_wrap_async(
53 "nomi",
54 "report_category_breakdown",
55 |mut caller: Caller<'_, SessionData>,
56 (from_arg, to_arg): (Option<Rooted<ArrayRef>>, Option<Rooted<ArrayRef>>)|
57 -> Box<
58 dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
59 > {
60 Box::new(async move {
61 let user_id = caller.data().ctx().user_id;
62 let from = read_string_arg(&mut caller, from_arg)?;
63 let to = read_string_arg(&mut caller, to_arg)?;
64 let payload = run_category_breakdown(user_id, from, to).await?;
65 Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
66 })
67 },
68 )?;
69 Ok(())
70}
71
72fn parse_date_args(
73 name: &str,
74 from_arg: Option<String>,
75 to_arg: Option<String>,
76) -> wasmtime::Result<(DateTime<Utc>, DateTime<Utc>)> {
77 let from_raw = from_arg
78 .filter(|s| !s.is_empty())
79 .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-from arg")))?;
80 let to_raw = to_arg
81 .filter(|s| !s.is_empty())
82 .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-to arg")))?;
83 let from = DateTime::parse_from_rfc3339(&from_raw)
84 .map(|d| d.with_timezone(&Utc))
85 .map_err(|err| {
86 wasmtime::Error::msg(format!("{name}: invalid :date-from '{from_raw}': {err}"))
87 })?;
88 let to = DateTime::parse_from_rfc3339(&to_raw)
89 .map(|d| d.with_timezone(&Utc))
90 .map_err(|err| {
91 wasmtime::Error::msg(format!("{name}: invalid :date-to '{to_raw}': {err}"))
92 })?;
93 Ok((from, to))
94}
95
96async fn run_activity_report(
97 user_id: uuid::Uuid,
98 from_arg: Option<String>,
99 to_arg: Option<String>,
100) -> wasmtime::Result<String> {
101 let (from, to) = parse_date_args("activity-report", from_arg, to_arg)?;
102 match ActivityReport::new()
103 .user_id(user_id)
104 .date_from(from)
105 .date_to(to)
106 .run()
107 .await
108 {
109 Ok(Some(CmdResult::Activity(data))) => Ok(format_activity(&data)),
110 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
111 "activity-report: expected Activity, got {other:?}"
112 ))),
113 Ok(None) => Ok("(:activity-report :periods ())".to_string()),
114 Err(err) => Err(wasmtime::Error::msg(format!("activity-report: {err}"))),
115 }
116}
117
118async fn run_category_breakdown(
119 user_id: uuid::Uuid,
120 from_arg: Option<String>,
121 to_arg: Option<String>,
122) -> wasmtime::Result<String> {
123 let (from, to) = parse_date_args("category-breakdown", from_arg, to_arg)?;
124 match CategoryBreakdown::new()
125 .user_id(user_id)
126 .date_from(from)
127 .date_to(to)
128 .run()
129 .await
130 {
131 Ok(Some(CmdResult::Breakdown(data))) => Ok(format_breakdown(&data)),
132 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
133 "category-breakdown: expected Breakdown, got {other:?}"
134 ))),
135 Ok(None) => Ok("(:category-breakdown :periods ())".to_string()),
136 Err(err) => Err(wasmtime::Error::msg(format!("category-breakdown: {err}"))),
137 }
138}
139
140fn format_activity(data: &ActivityData) -> String {
141 let mut out = String::from("(:activity-report :meta ");
142 out.push_str(&format_meta(
143 data.meta.date_from.as_ref(),
144 data.meta.date_to.as_ref(),
145 data.meta.target_commodity_id.as_ref(),
146 ));
147 out.push_str(" :periods (");
148 for (idx, period) in data.periods.iter().enumerate() {
149 if idx > 0 {
150 out.push(' ');
151 }
152 format_activity_period_into(&mut out, period);
153 }
154 out.push_str("))");
155 out
156}
157
158fn format_activity_period_into(out: &mut String, period: &ActivityPeriod) {
159 out.push_str("(:label ");
160 match period.label.as_deref() {
161 Some(label) => out.push_str("e_string(label)),
162 None => out.push_str("nil"),
163 }
164 out.push_str(" :groups (");
165 for (idx, group) in period.groups.iter().enumerate() {
166 if idx > 0 {
167 out.push(' ');
168 }
169 out.push_str(&format!(
170 "(:label {} :flip-sign {} :roots (",
171 quote_string(&group.label),
172 if group.flip_sign { "t" } else { "nil" },
173 ));
174 for (n, node) in group.roots.iter().enumerate() {
175 if n > 0 {
176 out.push(' ');
177 }
178 format_node_into(out, node);
179 }
180 out.push_str("))");
181 }
182 out.push_str("))");
183}
184
185fn format_breakdown(data: &BreakdownData) -> String {
186 let mut out = String::from("(:category-breakdown :meta ");
187 out.push_str(&format_meta(
188 data.meta.date_from.as_ref(),
189 data.meta.date_to.as_ref(),
190 data.meta.target_commodity_id.as_ref(),
191 ));
192 out.push_str(&format!(
193 " :tag-name {} :periods (",
194 quote_string(&data.tag_name)
195 ));
196 for (idx, period) in data.periods.iter().enumerate() {
197 if idx > 0 {
198 out.push(' ');
199 }
200 format_breakdown_period_into(&mut out, period);
201 }
202 out.push_str("))");
203 out
204}
205
206fn format_breakdown_period_into(out: &mut String, period: &BreakdownPeriod) {
207 out.push_str("(:label ");
208 match period.label.as_deref() {
209 Some(label) => out.push_str("e_string(label)),
210 None => out.push_str("nil"),
211 }
212 out.push_str(" :rows (");
213 for (idx, row) in period.rows.iter().enumerate() {
214 if idx > 0 {
215 out.push(' ');
216 }
217 format_breakdown_row_into(out, row);
218 }
219 out.push_str("))");
220}
221
222fn format_breakdown_row_into(out: &mut String, row: &BreakdownRow) {
223 out.push_str(&format!(
224 "(:tag-value {} :uncategorized {} :amounts (",
225 quote_string(&row.tag_value),
226 if row.is_uncategorized { "t" } else { "nil" },
227 ));
228 for (idx, amount) in row.amounts.iter().enumerate() {
229 if idx > 0 {
230 out.push(' ');
231 }
232 out.push_str(&format!(
233 "(:commodity-id \"{}\" :symbol {} :amount {})",
234 amount.commodity_id,
235 quote_string(&amount.commodity_symbol),
236 format_rational(&amount.amount),
237 ));
238 }
239 out.push_str("))");
240}
241
242fn format_meta(
243 date_from: Option<&DateTime<Utc>>,
244 date_to: Option<&DateTime<Utc>>,
245 target: Option<&uuid::Uuid>,
246) -> String {
247 format!(
248 "(:date-from {} :date-to {} :target-commodity-id {})",
249 format_optional_rfc3339(date_from),
250 format_optional_rfc3339(date_to),
251 format_optional_uuid(target),
252 )
253}
254
255async fn run_balance_report(user_id: uuid::Uuid) -> wasmtime::Result<String> {
256 match BalanceReport::new().user_id(user_id).run().await {
257 Ok(Some(CmdResult::Report(data))) => Ok(format_balance(&data)),
258 Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
259 "balance-report: expected Report, got {other:?}"
260 ))),
261 Ok(None) => Ok("(:balance-report :periods ())".to_string()),
262 Err(err) => Err(wasmtime::Error::msg(format!("balance-report: {err}"))),
263 }
264}
265
266fn format_balance(data: &ReportData) -> String {
267 let mut out = String::from("(:balance-report :periods (");
268 for (idx, period) in data.periods.iter().enumerate() {
269 if idx > 0 {
270 out.push(' ');
271 }
272 out.push_str("(:label ");
273 match period.label.as_deref() {
274 Some(label) => out.push_str("e_string(label)),
275 None => out.push_str("nil"),
276 }
277 out.push_str(" :roots (");
278 for (n, node) in period.roots.iter().enumerate() {
279 if n > 0 {
280 out.push(' ');
281 }
282 format_node_into(&mut out, node);
283 }
284 out.push_str("))");
285 }
286 out.push_str("))");
287 out
288}
289
290fn format_node_into(out: &mut String, node: &ReportNode) {
291 out.push_str(&format!(
292 "(:account-id \"{}\" :account-name {} :account-path {} :depth {} :account-type {} :amounts (",
293 node.account_id,
294 quote_string(&node.account_name),
295 quote_string(&node.account_path),
296 node.depth,
297 match node.account_type.as_deref() {
298 Some(t) => quote_string(t),
299 None => "nil".to_string(),
300 },
301 ));
302 for (idx, amount) in node.amounts.iter().enumerate() {
303 if idx > 0 {
304 out.push(' ');
305 }
306 out.push_str(&format!(
307 "(:commodity-id \"{}\" :symbol {} :amount {})",
308 amount.commodity_id,
309 quote_string(&amount.commodity_symbol),
310 format_rational(&amount.amount),
311 ));
312 }
313 out.push_str(") :children (");
314 for (idx, child) in node.children.iter().enumerate() {
315 if idx > 0 {
316 out.push(' ');
317 }
318 format_node_into(out, child);
319 }
320 out.push_str("))");
321}
322
323fn format_optional_rfc3339(ts: Option<&chrono::DateTime<chrono::Utc>>) -> String {
324 match ts {
325 Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
326 None => "nil".to_string(),
327 }
328}
329
330fn format_optional_uuid(id: Option<&uuid::Uuid>) -> String {
331 match id {
332 Some(id) => format!("\"{id}\""),
333 None => "nil".to_string(),
334 }
335}
336
337fn format_rational(r: &Rational64) -> String {
338 if *r.denom() == 1 {
339 r.numer().to_string()
340 } else {
341 format!("{}/{}", r.numer(), r.denom())
342 }
343}
344
345fn quote_string(s: &str) -> String {
346 let mut q = String::with_capacity(s.len() + 2);
347 q.push('"');
348 for ch in s.chars() {
349 match ch {
350 '"' => q.push_str("\\\""),
351 '\\' => q.push_str("\\\\"),
352 other => q.push(other),
353 }
354 }
355 q.push('"');
356 q
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use server::command::{CommodityAmount, PeriodData, ReportMeta};
363 use uuid::Uuid;
364
365 #[tokio::test]
366 async fn run_activity_report_missing_from_emits_error() {
367 let err = run_activity_report(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
368 .await
369 .unwrap_err();
370 assert!(err.to_string().contains(":date-from"), "got: {err}");
371 }
372
373 #[tokio::test]
374 async fn run_activity_report_invalid_date_emits_error() {
375 let err = run_activity_report(Uuid::nil(), Some("nope".into()), Some("nope".into()))
376 .await
377 .unwrap_err();
378 assert!(err.to_string().contains("invalid"), "got: {err}");
379 }
380
381 #[tokio::test]
382 async fn run_category_breakdown_missing_from_emits_error() {
383 let err = run_category_breakdown(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
384 .await
385 .unwrap_err();
386 assert!(err.to_string().contains(":date-from"), "got: {err}");
387 }
388
389 #[tokio::test]
390 async fn run_category_breakdown_invalid_date_emits_error() {
391 let err = run_category_breakdown(Uuid::nil(), Some("not-rfc3339".into()), Some("x".into()))
392 .await
393 .unwrap_err();
394 assert!(err.to_string().contains("invalid"), "got: {err}");
395 }
396
397 #[test]
398 fn format_balance_includes_all_commodity_amounts() {
399 let node = ReportNode {
400 account_id: Uuid::nil(),
401 account_name: "Assets".to_string(),
402 account_path: "Assets".to_string(),
403 depth: 0,
404 account_type: Some("asset".to_string()),
405 amounts: vec![
406 CommodityAmount {
407 commodity_id: Uuid::nil(),
408 commodity_symbol: "USD".to_string(),
409 amount: Rational64::new(100, 1),
410 },
411 CommodityAmount {
412 commodity_id: Uuid::nil(),
413 commodity_symbol: "EUR".to_string(),
414 amount: Rational64::new(50, 1),
415 },
416 ],
417 children: vec![],
418 };
419 let data = ReportData {
420 meta: ReportMeta {
421 date_from: None,
422 date_to: None,
423 target_commodity_id: None,
424 },
425 periods: vec![PeriodData {
426 label: None,
427 roots: vec![node],
428 }],
429 };
430 let s = format_balance(&data);
431 assert!(s.contains(":symbol \"USD\""), "USD must appear: {s}");
432 assert!(s.contains(":symbol \"EUR\""), "EUR must appear: {s}");
433 assert!(
434 s.contains(":balance-report :periods"),
435 "top-level shape: {s}"
436 );
437 assert!(s.contains(":roots"), "must have :roots key: {s}");
438 }
439}