Skip to main content

rpc/natives/
report.rs

1//! Report-domain natives. Wraps `server::command::{BalanceReport, ActivityReport,
2//! CategoryBreakdown}`.
3//!
4//! v1 binds the no-arg snapshot form of `balance-report`: no commodity
5//! filter, no date range, no `ReportFilter`. Surfaces the resulting
6//! `ReportData` tree as a nested plist so emacs `(read)` walks
7//! ((:account-id ... :children (...)) ...) recursively. Date-range and
8//! commodity-filter variants ride follow-up slices once the
9//! keyword-pair extension to the capture queue lands; activity-report
10//! and category-breakdown have similar shapes and wait on the same
11//! groundwork.
12
13use 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(&quote_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(&quote_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
266/// Surfaces a `ReportData` tree as a typed `$report_node` entity. Wraps
267/// all period roots under a single synthetic root (depth 0, empty
268/// label, zero amount) so the consumer walks one entity ref — periods
269/// flatten because the typed-entity shape doesn't (yet) model periods
270/// directly. Multi-currency amounts collapse to the first row's value;
271/// truly multi-currency reports still need a follow-up sub-slice to
272/// surface `(node-amounts ...)` as a `pair<commodity>`.
273async 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
313/// Stringification of a `ReportNode` for the not-yet-typed report
314/// natives (activity, category-breakdown). Once those migrate to
315/// typed `$report_node` returns, this fallback retires.
316fn 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
349/// Recursively allocates a `$report_node` wasm struct for `node` and
350/// every descendant. Async recursion needs an explicit `Box::pin`
351/// because `async fn` desugars to an opaque future whose size can't be
352/// known at compile time when the body references itself.
353fn 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    // The string-flattener test that lived here covered the old
463    // `format_report` path; balance-report now returns a typed
464    // `$report_node` entity, so equivalent coverage lives in
465    // `tests-integration` where a live `rpc::Session` can produce a
466    // real `Rooted<StructRef>` and consumers compose it through
467    // `(node-children ...)` / `(node-label ...)`.
468}