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