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

            
13
use chrono::{DateTime, Utc};
14
use num_rational::Rational64;
15
use scripting::runtime::{
16
    alloc_entity_via_export, alloc_pair_chain, alloc_ratio_ref, alloc_string_ref, read_string_arg,
17
};
18
use server::command::report::{ActivityReport, BalanceReport, CategoryBreakdown};
19
use server::command::{
20
    ActivityData, ActivityPeriod, BreakdownData, BreakdownPeriod, BreakdownRow, CmdError,
21
    CmdResult, ReportNode,
22
};
23
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
24

            
25
use crate::session::SessionData;
26

            
27
pub const REGISTERED_COMMANDS: &[&str] =
28
    &["balance-report", "activity-report", "category-breakdown"];
29

            
30
2660
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
31
2660
    linker.func_wrap_async(
32
2660
        "nomi",
33
2660
        "report_balance_report",
34
        |mut caller: Caller<'_, SessionData>,
35
         ()|
36
         -> Box<
37
            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
38
36
        > {
39
36
            Box::new(async move {
40
36
                let user_id = caller.data().ctx().user_id;
41
36
                let result = BalanceReport::new().user_id(user_id).run().await;
42
36
                balance_report_to_entity(&mut caller, result).await
43
36
            })
44
36
        },
45
    )?;
46
2660
    linker.func_wrap_async(
47
2660
        "nomi",
48
2660
        "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
18
        > {
54
18
            Box::new(async move {
55
18
                let user_id = caller.data().ctx().user_id;
56
18
                let from = read_string_arg(&mut caller, from_arg)?;
57
18
                let to = read_string_arg(&mut caller, to_arg)?;
58
18
                let payload = run_activity_report(user_id, from, to).await?;
59
18
                Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
60
18
            })
61
18
        },
62
    )?;
63
2660
    linker.func_wrap_async(
64
2660
        "nomi",
65
2660
        "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
18
        > {
71
18
            Box::new(async move {
72
18
                let user_id = caller.data().ctx().user_id;
73
18
                let from = read_string_arg(&mut caller, from_arg)?;
74
18
                let to = read_string_arg(&mut caller, to_arg)?;
75
18
                let payload = run_category_breakdown(user_id, from, to).await?;
76
18
                Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
77
18
            })
78
18
        },
79
    )?;
80
2660
    Ok(())
81
2660
}
82

            
83
40
fn parse_date_args(
84
40
    name: &str,
85
40
    from_arg: Option<String>,
86
40
    to_arg: Option<String>,
87
40
) -> wasmtime::Result<(DateTime<Utc>, DateTime<Utc>)> {
88
40
    let from_raw = from_arg
89
40
        .filter(|s| !s.is_empty())
90
40
        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-from arg")))?;
91
38
    let to_raw = to_arg
92
38
        .filter(|s| !s.is_empty())
93
38
        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-to arg")))?;
94
38
    let from = DateTime::parse_from_rfc3339(&from_raw)
95
38
        .map(|d| d.with_timezone(&Utc))
96
38
        .map_err(|err| {
97
2
            wasmtime::Error::msg(format!("{name}: invalid :date-from '{from_raw}': {err}"))
98
2
        })?;
99
36
    let to = DateTime::parse_from_rfc3339(&to_raw)
100
36
        .map(|d| d.with_timezone(&Utc))
101
36
        .map_err(|err| {
102
            wasmtime::Error::msg(format!("{name}: invalid :date-to '{to_raw}': {err}"))
103
        })?;
104
36
    Ok((from, to))
105
40
}
106

            
107
20
async fn run_activity_report(
108
20
    user_id: uuid::Uuid,
109
20
    from_arg: Option<String>,
110
20
    to_arg: Option<String>,
111
20
) -> wasmtime::Result<String> {
112
20
    let (from, to) = parse_date_args("activity-report", from_arg, to_arg)?;
113
18
    match ActivityReport::new()
114
18
        .user_id(user_id)
115
18
        .date_from(from)
116
18
        .date_to(to)
117
18
        .run()
118
18
        .await
119
    {
120
18
        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
20
}
128

            
129
20
async fn run_category_breakdown(
130
20
    user_id: uuid::Uuid,
131
20
    from_arg: Option<String>,
132
20
    to_arg: Option<String>,
133
20
) -> wasmtime::Result<String> {
134
20
    let (from, to) = parse_date_args("category-breakdown", from_arg, to_arg)?;
135
18
    match CategoryBreakdown::new()
136
18
        .user_id(user_id)
137
18
        .date_from(from)
138
18
        .date_to(to)
139
18
        .run()
140
18
        .await
141
    {
142
18
        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
20
}
150

            
151
18
fn format_activity(data: &ActivityData) -> String {
152
18
    let mut out = String::from("(:activity-report :meta ");
153
18
    out.push_str(&format_meta(
154
18
        data.meta.date_from.as_ref(),
155
18
        data.meta.date_to.as_ref(),
156
18
        data.meta.target_commodity_id.as_ref(),
157
18
    ));
158
18
    out.push_str(" :periods (");
159
18
    for (idx, period) in data.periods.iter().enumerate() {
160
18
        if idx > 0 {
161
            out.push(' ');
162
18
        }
163
18
        format_activity_period_into(&mut out, period);
164
    }
165
18
    out.push_str("))");
166
18
    out
167
18
}
168

            
169
18
fn format_activity_period_into(out: &mut String, period: &ActivityPeriod) {
170
18
    out.push_str("(:label ");
171
18
    match period.label.as_deref() {
172
        Some(label) => out.push_str(&quote_string(label)),
173
18
        None => out.push_str("nil"),
174
    }
175
18
    out.push_str(" :groups (");
176
36
    for (idx, group) in period.groups.iter().enumerate() {
177
36
        if idx > 0 {
178
18
            out.push(' ');
179
18
        }
180
36
        out.push_str(&format!(
181
            "(:label {} :flip-sign {} :roots (",
182
36
            quote_string(&group.label),
183
36
            if group.flip_sign { "t" } else { "nil" },
184
        ));
185
36
        for (n, node) in group.roots.iter().enumerate() {
186
            if n > 0 {
187
                out.push(' ');
188
            }
189
            format_node_into(out, node);
190
        }
191
36
        out.push_str("))");
192
    }
193
18
    out.push_str("))");
194
18
}
195

            
196
18
fn format_breakdown(data: &BreakdownData) -> String {
197
18
    let mut out = String::from("(:category-breakdown :meta ");
198
18
    out.push_str(&format_meta(
199
18
        data.meta.date_from.as_ref(),
200
18
        data.meta.date_to.as_ref(),
201
18
        data.meta.target_commodity_id.as_ref(),
202
18
    ));
203
18
    out.push_str(&format!(
204
18
        " :tag-name {} :periods (",
205
18
        quote_string(&data.tag_name)
206
18
    ));
207
18
    for (idx, period) in data.periods.iter().enumerate() {
208
18
        if idx > 0 {
209
            out.push(' ');
210
18
        }
211
18
        format_breakdown_period_into(&mut out, period);
212
    }
213
18
    out.push_str("))");
214
18
    out
215
18
}
216

            
217
18
fn format_breakdown_period_into(out: &mut String, period: &BreakdownPeriod) {
218
18
    out.push_str("(:label ");
219
18
    match period.label.as_deref() {
220
        Some(label) => out.push_str(&quote_string(label)),
221
18
        None => out.push_str("nil"),
222
    }
223
18
    out.push_str(" :rows (");
224
18
    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
18
    out.push_str("))");
231
18
}
232

            
233
fn 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

            
253
36
fn format_meta(
254
36
    date_from: Option<&DateTime<Utc>>,
255
36
    date_to: Option<&DateTime<Utc>>,
256
36
    target: Option<&uuid::Uuid>,
257
36
) -> String {
258
36
    format!(
259
        "(:date-from {} :date-to {} :target-commodity-id {})",
260
36
        format_optional_rfc3339(date_from),
261
36
        format_optional_rfc3339(date_to),
262
36
        format_optional_uuid(target),
263
    )
264
36
}
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>`.
273
36
async fn balance_report_to_entity(
274
36
    caller: &mut Caller<'_, SessionData>,
275
36
    result: Result<Option<CmdResult>, CmdError>,
276
36
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
277
36
    let data = match result {
278
36
        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
36
    let mut child_refs: Vec<Rooted<AnyRef>> = Vec::new();
288
36
    for period in &data.periods {
289
36
        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
36
    let children = alloc_pair_chain(caller, child_refs).await?;
295
36
    let id = alloc_string_ref(caller, b"")?;
296
36
    let label = alloc_string_ref(caller, b"balance-report")?;
297
36
    let amount = alloc_ratio_ref(caller, 0, 1)?;
298
36
    let root = alloc_entity_via_export(
299
36
        caller,
300
36
        "alloc_report_node",
301
        &[
302
36
            Val::AnyRef(Some(id.to_anyref())),
303
36
            Val::AnyRef(Some(label.to_anyref())),
304
36
            Val::I32(0),
305
36
            Val::AnyRef(Some(amount.to_anyref())),
306
36
            Val::AnyRef(children.map(|p| p.to_anyref())),
307
        ],
308
    )
309
36
    .await?;
310
36
    Ok(Some(root))
311
36
}
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.
316
fn 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.
353
fn 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

            
389
72
fn format_optional_rfc3339(ts: Option<&chrono::DateTime<chrono::Utc>>) -> String {
390
72
    match ts {
391
72
        Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
392
        None => "nil".to_string(),
393
    }
394
72
}
395

            
396
36
fn format_optional_uuid(id: Option<&uuid::Uuid>) -> String {
397
36
    match id {
398
        Some(id) => format!("\"{id}\""),
399
36
        None => "nil".to_string(),
400
    }
401
36
}
402

            
403
fn 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

            
411
54
fn quote_string(s: &str) -> String {
412
54
    let mut q = String::with_capacity(s.len() + 2);
413
54
    q.push('"');
414
378
    for ch in s.chars() {
415
378
        match ch {
416
            '"' => q.push_str("\\\""),
417
            '\\' => q.push_str("\\\\"),
418
378
            other => q.push(other),
419
        }
420
    }
421
54
    q.push('"');
422
54
    q
423
54
}
424

            
425
#[cfg(test)]
426
mod tests {
427
    use super::*;
428
    use uuid::Uuid;
429

            
430
    #[tokio::test]
431
1
    async fn run_activity_report_missing_from_emits_error() {
432
1
        let err = run_activity_report(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
433
1
            .await
434
1
            .unwrap_err();
435
1
        assert!(err.to_string().contains(":date-from"), "got: {err}");
436
1
    }
437

            
438
    #[tokio::test]
439
1
    async fn run_activity_report_invalid_date_emits_error() {
440
1
        let err = run_activity_report(Uuid::nil(), Some("nope".into()), Some("nope".into()))
441
1
            .await
442
1
            .unwrap_err();
443
1
        assert!(err.to_string().contains("invalid"), "got: {err}");
444
1
    }
445

            
446
    #[tokio::test]
447
1
    async fn run_category_breakdown_missing_from_emits_error() {
448
1
        let err = run_category_breakdown(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
449
1
            .await
450
1
            .unwrap_err();
451
1
        assert!(err.to_string().contains(":date-from"), "got: {err}");
452
1
    }
453

            
454
    #[tokio::test]
455
1
    async fn run_category_breakdown_invalid_date_emits_error() {
456
1
        let err = run_category_breakdown(Uuid::nil(), Some("not-rfc3339".into()), Some("x".into()))
457
1
            .await
458
1
            .unwrap_err();
459
1
        assert!(err.to_string().contains("invalid"), "got: {err}");
460
1
    }
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
}