1
//! Report-domain natives. Wraps `server::command::{BalanceReport, ActivityReport,
2
//! CategoryBreakdown}`.
3

            
4
use chrono::{DateTime, Utc};
5
use num_rational::Rational64;
6
use scripting::runtime::{alloc_string_ref, read_string_arg};
7
use server::command::report::{ActivityReport, BalanceReport, CategoryBreakdown};
8
use server::command::{
9
    ActivityData, ActivityPeriod, BreakdownData, BreakdownPeriod, BreakdownRow, CmdResult,
10
    ReportData, ReportNode,
11
};
12
use wasmtime::{ArrayRef, Caller, Linker, Rooted};
13

            
14
use crate::session::SessionData;
15

            
16
pub const REGISTERED_COMMANDS: &[&str] =
17
    &["balance-report", "activity-report", "category-breakdown"];
18

            
19
6045
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
20
6045
    linker.func_wrap_async(
21
6045
        "nomi",
22
6045
        "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
6045
    linker.func_wrap_async(
36
6045
        "nomi",
37
6045
        "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
25
        > {
43
25
            Box::new(async move {
44
25
                let user_id = caller.data().ctx().user_id;
45
25
                let from = read_string_arg(&mut caller, from_arg)?;
46
25
                let to = read_string_arg(&mut caller, to_arg)?;
47
25
                let payload = run_activity_report(user_id, from, to).await?;
48
25
                Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
49
25
            })
50
25
        },
51
    )?;
52
6045
    linker.func_wrap_async(
53
6045
        "nomi",
54
6045
        "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
25
        > {
60
25
            Box::new(async move {
61
25
                let user_id = caller.data().ctx().user_id;
62
25
                let from = read_string_arg(&mut caller, from_arg)?;
63
25
                let to = read_string_arg(&mut caller, to_arg)?;
64
25
                let payload = run_category_breakdown(user_id, from, to).await?;
65
25
                Ok(Some(alloc_string_ref(&mut caller, payload.as_bytes())?))
66
25
            })
67
25
        },
68
    )?;
69
6045
    Ok(())
70
6045
}
71

            
72
54
fn parse_date_args(
73
54
    name: &str,
74
54
    from_arg: Option<String>,
75
54
    to_arg: Option<String>,
76
54
) -> wasmtime::Result<(DateTime<Utc>, DateTime<Utc>)> {
77
54
    let from_raw = from_arg
78
54
        .filter(|s| !s.is_empty())
79
54
        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-from arg")))?;
80
52
    let to_raw = to_arg
81
52
        .filter(|s| !s.is_empty())
82
52
        .ok_or_else(|| wasmtime::Error::msg(format!("{name}: missing or empty :date-to arg")))?;
83
52
    let from = DateTime::parse_from_rfc3339(&from_raw)
84
52
        .map(|d| d.with_timezone(&Utc))
85
52
        .map_err(|err| {
86
2
            wasmtime::Error::msg(format!("{name}: invalid :date-from '{from_raw}': {err}"))
87
2
        })?;
88
50
    let to = DateTime::parse_from_rfc3339(&to_raw)
89
50
        .map(|d| d.with_timezone(&Utc))
90
50
        .map_err(|err| {
91
            wasmtime::Error::msg(format!("{name}: invalid :date-to '{to_raw}': {err}"))
92
        })?;
93
50
    Ok((from, to))
94
54
}
95

            
96
27
async fn run_activity_report(
97
27
    user_id: uuid::Uuid,
98
27
    from_arg: Option<String>,
99
27
    to_arg: Option<String>,
100
27
) -> wasmtime::Result<String> {
101
27
    let (from, to) = parse_date_args("activity-report", from_arg, to_arg)?;
102
25
    match ActivityReport::new()
103
25
        .user_id(user_id)
104
25
        .date_from(from)
105
25
        .date_to(to)
106
25
        .run()
107
25
        .await
108
    {
109
25
        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
27
}
117

            
118
27
async fn run_category_breakdown(
119
27
    user_id: uuid::Uuid,
120
27
    from_arg: Option<String>,
121
27
    to_arg: Option<String>,
122
27
) -> wasmtime::Result<String> {
123
27
    let (from, to) = parse_date_args("category-breakdown", from_arg, to_arg)?;
124
25
    match CategoryBreakdown::new()
125
25
        .user_id(user_id)
126
25
        .date_from(from)
127
25
        .date_to(to)
128
25
        .run()
129
25
        .await
130
    {
131
25
        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
27
}
139

            
140
25
fn format_activity(data: &ActivityData) -> String {
141
25
    let mut out = String::from("(:activity-report :meta ");
142
25
    out.push_str(&format_meta(
143
25
        data.meta.date_from.as_ref(),
144
25
        data.meta.date_to.as_ref(),
145
25
        data.meta.target_commodity_id.as_ref(),
146
25
    ));
147
25
    out.push_str(" :periods (");
148
25
    for (idx, period) in data.periods.iter().enumerate() {
149
25
        if idx > 0 {
150
            out.push(' ');
151
25
        }
152
25
        format_activity_period_into(&mut out, period);
153
    }
154
25
    out.push_str("))");
155
25
    out
156
25
}
157

            
158
25
fn format_activity_period_into(out: &mut String, period: &ActivityPeriod) {
159
25
    out.push_str("(:label ");
160
25
    match period.label.as_deref() {
161
        Some(label) => out.push_str(&quote_string(label)),
162
25
        None => out.push_str("nil"),
163
    }
164
25
    out.push_str(" :groups (");
165
50
    for (idx, group) in period.groups.iter().enumerate() {
166
50
        if idx > 0 {
167
25
            out.push(' ');
168
25
        }
169
50
        out.push_str(&format!(
170
            "(:label {} :flip-sign {} :roots (",
171
50
            quote_string(&group.label),
172
50
            if group.flip_sign { "t" } else { "nil" },
173
        ));
174
50
        for (n, node) in group.roots.iter().enumerate() {
175
            if n > 0 {
176
                out.push(' ');
177
            }
178
            format_node_into(out, node);
179
        }
180
50
        out.push_str("))");
181
    }
182
25
    out.push_str("))");
183
25
}
184

            
185
25
fn format_breakdown(data: &BreakdownData) -> String {
186
25
    let mut out = String::from("(:category-breakdown :meta ");
187
25
    out.push_str(&format_meta(
188
25
        data.meta.date_from.as_ref(),
189
25
        data.meta.date_to.as_ref(),
190
25
        data.meta.target_commodity_id.as_ref(),
191
25
    ));
192
25
    out.push_str(&format!(
193
25
        " :tag-name {} :periods (",
194
25
        quote_string(&data.tag_name)
195
25
    ));
196
25
    for (idx, period) in data.periods.iter().enumerate() {
197
25
        if idx > 0 {
198
            out.push(' ');
199
25
        }
200
25
        format_breakdown_period_into(&mut out, period);
201
    }
202
25
    out.push_str("))");
203
25
    out
204
25
}
205

            
206
25
fn format_breakdown_period_into(out: &mut String, period: &BreakdownPeriod) {
207
25
    out.push_str("(:label ");
208
25
    match period.label.as_deref() {
209
        Some(label) => out.push_str(&quote_string(label)),
210
25
        None => out.push_str("nil"),
211
    }
212
25
    out.push_str(" :rows (");
213
25
    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
25
    out.push_str("))");
220
25
}
221

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

            
242
50
fn format_meta(
243
50
    date_from: Option<&DateTime<Utc>>,
244
50
    date_to: Option<&DateTime<Utc>>,
245
50
    target: Option<&uuid::Uuid>,
246
50
) -> String {
247
50
    format!(
248
        "(:date-from {} :date-to {} :target-commodity-id {})",
249
50
        format_optional_rfc3339(date_from),
250
50
        format_optional_rfc3339(date_to),
251
50
        format_optional_uuid(target),
252
    )
253
50
}
254

            
255
async 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

            
266
1
fn format_balance(data: &ReportData) -> String {
267
1
    let mut out = String::from("(:balance-report :periods (");
268
1
    for (idx, period) in data.periods.iter().enumerate() {
269
1
        if idx > 0 {
270
            out.push(' ');
271
1
        }
272
1
        out.push_str("(:label ");
273
1
        match period.label.as_deref() {
274
            Some(label) => out.push_str(&quote_string(label)),
275
1
            None => out.push_str("nil"),
276
        }
277
1
        out.push_str(" :roots (");
278
1
        for (n, node) in period.roots.iter().enumerate() {
279
1
            if n > 0 {
280
                out.push(' ');
281
1
            }
282
1
            format_node_into(&mut out, node);
283
        }
284
1
        out.push_str("))");
285
    }
286
1
    out.push_str("))");
287
1
    out
288
1
}
289

            
290
1
fn format_node_into(out: &mut String, node: &ReportNode) {
291
1
    out.push_str(&format!(
292
        "(:account-id \"{}\" :account-name {} :account-path {} :depth {} :account-type {} :amounts (",
293
        node.account_id,
294
1
        quote_string(&node.account_name),
295
1
        quote_string(&node.account_path),
296
        node.depth,
297
1
        match node.account_type.as_deref() {
298
1
            Some(t) => quote_string(t),
299
            None => "nil".to_string(),
300
        },
301
    ));
302
2
    for (idx, amount) in node.amounts.iter().enumerate() {
303
2
        if idx > 0 {
304
1
            out.push(' ');
305
1
        }
306
2
        out.push_str(&format!(
307
2
            "(:commodity-id \"{}\" :symbol {} :amount {})",
308
2
            amount.commodity_id,
309
2
            quote_string(&amount.commodity_symbol),
310
2
            format_rational(&amount.amount),
311
2
        ));
312
    }
313
1
    out.push_str(") :children (");
314
1
    for (idx, child) in node.children.iter().enumerate() {
315
        if idx > 0 {
316
            out.push(' ');
317
        }
318
        format_node_into(out, child);
319
    }
320
1
    out.push_str("))");
321
1
}
322

            
323
100
fn format_optional_rfc3339(ts: Option<&chrono::DateTime<chrono::Utc>>) -> String {
324
100
    match ts {
325
100
        Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
326
        None => "nil".to_string(),
327
    }
328
100
}
329

            
330
50
fn format_optional_uuid(id: Option<&uuid::Uuid>) -> String {
331
50
    match id {
332
        Some(id) => format!("\"{id}\""),
333
50
        None => "nil".to_string(),
334
    }
335
50
}
336

            
337
2
fn format_rational(r: &Rational64) -> String {
338
2
    if *r.denom() == 1 {
339
2
        r.numer().to_string()
340
    } else {
341
        format!("{}/{}", r.numer(), r.denom())
342
    }
343
2
}
344

            
345
80
fn quote_string(s: &str) -> String {
346
80
    let mut q = String::with_capacity(s.len() + 2);
347
80
    q.push('"');
348
548
    for ch in s.chars() {
349
548
        match ch {
350
            '"' => q.push_str("\\\""),
351
            '\\' => q.push_str("\\\\"),
352
548
            other => q.push(other),
353
        }
354
    }
355
80
    q.push('"');
356
80
    q
357
80
}
358

            
359
#[cfg(test)]
360
mod tests {
361
    use super::*;
362
    use server::command::{CommodityAmount, PeriodData, ReportMeta};
363
    use uuid::Uuid;
364

            
365
    #[tokio::test]
366
1
    async fn run_activity_report_missing_from_emits_error() {
367
1
        let err = run_activity_report(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
368
1
            .await
369
1
            .unwrap_err();
370
1
        assert!(err.to_string().contains(":date-from"), "got: {err}");
371
1
    }
372

            
373
    #[tokio::test]
374
1
    async fn run_activity_report_invalid_date_emits_error() {
375
1
        let err = run_activity_report(Uuid::nil(), Some("nope".into()), Some("nope".into()))
376
1
            .await
377
1
            .unwrap_err();
378
1
        assert!(err.to_string().contains("invalid"), "got: {err}");
379
1
    }
380

            
381
    #[tokio::test]
382
1
    async fn run_category_breakdown_missing_from_emits_error() {
383
1
        let err = run_category_breakdown(Uuid::nil(), None, Some("2026-01-01T00:00:00Z".into()))
384
1
            .await
385
1
            .unwrap_err();
386
1
        assert!(err.to_string().contains(":date-from"), "got: {err}");
387
1
    }
388

            
389
    #[tokio::test]
390
1
    async fn run_category_breakdown_invalid_date_emits_error() {
391
1
        let err = run_category_breakdown(Uuid::nil(), Some("not-rfc3339".into()), Some("x".into()))
392
1
            .await
393
1
            .unwrap_err();
394
1
        assert!(err.to_string().contains("invalid"), "got: {err}");
395
1
    }
396

            
397
    #[test]
398
1
    fn format_balance_includes_all_commodity_amounts() {
399
1
        let node = ReportNode {
400
1
            account_id: Uuid::nil(),
401
1
            account_name: "Assets".to_string(),
402
1
            account_path: "Assets".to_string(),
403
1
            depth: 0,
404
1
            account_type: Some("asset".to_string()),
405
1
            amounts: vec![
406
1
                CommodityAmount {
407
1
                    commodity_id: Uuid::nil(),
408
1
                    commodity_symbol: "USD".to_string(),
409
1
                    amount: Rational64::new(100, 1),
410
1
                },
411
1
                CommodityAmount {
412
1
                    commodity_id: Uuid::nil(),
413
1
                    commodity_symbol: "EUR".to_string(),
414
1
                    amount: Rational64::new(50, 1),
415
1
                },
416
1
            ],
417
1
            children: vec![],
418
1
        };
419
1
        let data = ReportData {
420
1
            meta: ReportMeta {
421
1
                date_from: None,
422
1
                date_to: None,
423
1
                target_commodity_id: None,
424
1
            },
425
1
            periods: vec![PeriodData {
426
1
                label: None,
427
1
                roots: vec![node],
428
1
            }],
429
1
        };
430
1
        let s = format_balance(&data);
431
1
        assert!(s.contains(":symbol \"USD\""), "USD must appear: {s}");
432
1
        assert!(s.contains(":symbol \"EUR\""), "EUR must appear: {s}");
433
1
        assert!(
434
1
            s.contains(":balance-report :periods"),
435
            "top-level shape: {s}"
436
        );
437
1
        assert!(s.contains(":roots"), "must have :roots key: {s}");
438
1
    }
439
}