Skip to main content

rpc/natives/
report.rs

1//! Report-domain natives. Wraps `server::command::{BalanceReport, ActivityReport,
2//! CategoryBreakdown}`.
3
4use 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(&quote_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(&quote_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(&quote_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}