1
//! Wire → view mapping for the three report natives.
2
//!
3
//! All functions are pure and DB-free: they accept a [`nomiscript::Value`]
4
//! decoded from the wire string and project it into the server-side view
5
//! structs consumed by the plotting adapters.
6

            
7
use std::io::IsTerminal;
8
use std::str::FromStr;
9

            
10
use chrono::{NaiveDate, TimeZone, Utc};
11
use nomiscript::{Value, list_to_vec};
12
use num_rational::Rational64;
13
use plotting::ChartKind;
14
use server::command::report::view::{
15
    AmountView, BreakdownPeriodView, BreakdownRowView, GroupView, PeriodActivityView, ReportRowView,
16
};
17
use sqlx::types::Uuid;
18

            
19
use crate::CommandError;
20
use crate::eval::{plist_field, plist_field_raw};
21
use crate::render::reparse_list;
22

            
23
// ---------- renderer hint ----------
24

            
25
/// Caller's renderer preference derived from the `--chart` flag.
26
pub(crate) enum RendererHint {
27
    Auto,
28
    Text,
29
    Kitty,
30
}
31

            
32
7
pub(crate) fn parse_renderer_hint(s: &str) -> RendererHint {
33
7
    match s.to_ascii_lowercase().as_str() {
34
7
        "text" | "ascii" => RendererHint::Text,
35
5
        "kitty" | "graphic" => RendererHint::Kitty,
36
2
        _ => RendererHint::Auto,
37
    }
38
7
}
39

            
40
57
pub fn parse_chart_shape(s: &str) -> ChartKind {
41
57
    match s.to_ascii_lowercase().as_str() {
42
57
        "line" => ChartKind::Line,
43
56
        "stacked" | "stackedbar" => ChartKind::StackedBar,
44
54
        _ => ChartKind::Bar,
45
    }
46
57
}
47

            
48
/// Returns `true` when the environment signals a kitty-compatible terminal
49
/// AND stdout is an interactive tty.
50
1
pub(crate) fn probe_terminal_kitty() -> bool {
51
1
    if !std::io::stdout().is_terminal() {
52
1
        return false;
53
    }
54
    std::env::var("KITTY_WINDOW_ID").is_ok()
55
        || std::env::var("TERM")
56
            .map(|t| t.contains("kitty"))
57
            .unwrap_or(false)
58
        || std::env::var("TERM_PROGRAM")
59
            .map(|p| p.eq_ignore_ascii_case("kitty"))
60
            .unwrap_or(false)
61
1
}
62

            
63
// ---------- date coercion ----------
64

            
65
/// Expand a `YYYY-MM-DD` string to RFC3339 with start-of-day (`00:00:00Z`)
66
/// or end-of-day (`23:59:59Z`) semantics matching the old `parse_date_arg`
67
/// logic. An already-valid RFC3339 string is returned as-is.
68
///
69
/// # Errors
70
///
71
/// Returns an error when the string is neither a valid `YYYY-MM-DD` date
72
/// nor a valid RFC3339 timestamp.
73
188
pub fn coerce_date_arg(s: &str, end_of_day: bool) -> Result<String, CommandError> {
74
188
    if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
75
28
        return Ok(s.to_string());
76
160
    }
77
160
    let date = NaiveDate::parse_from_str(s, "%Y-%m-%d")
78
160
        .map_err(|e| CommandError::Argument(format!("invalid date '{s}': {e}")))?;
79
119
    let (h, m, sec) = if end_of_day { (23, 59, 59) } else { (0, 0, 0) };
80
119
    let naive = date
81
119
        .and_hms_opt(h, m, sec)
82
119
        .ok_or_else(|| CommandError::Argument(format!("invalid time for date '{s}'")))?;
83
119
    Ok(Utc.from_utc_datetime(&naive).to_rfc3339())
84
188
}
85

            
86
// ---------- rational ----------
87

            
88
60
fn parse_rational(s: &str) -> Result<Rational64, CommandError> {
89
60
    if let Some((num, denom)) = s.split_once('/') {
90
3
        let n: i64 = num
91
3
            .parse()
92
3
            .map_err(|e| CommandError::Command(format!("rational numerator parse '{s}': {e}")))?;
93
3
        let d: i64 = denom
94
3
            .parse()
95
3
            .map_err(|e| CommandError::Command(format!("rational denominator parse '{s}': {e}")))?;
96
3
        if d == 0 {
97
1
            return Err(CommandError::Command(format!(
98
1
                "rational zero denominator: {s}"
99
1
            )));
100
2
        }
101
2
        Ok(Rational64::new(n, d))
102
    } else {
103
57
        let n: i64 = s
104
57
            .parse()
105
57
            .map_err(|e| CommandError::Command(format!("rational parse '{s}': {e}")))?;
106
57
        Ok(Rational64::new(n, 1))
107
    }
108
60
}
109

            
110
// ---------- shared plist helpers ----------
111

            
112
56
fn reparse(raw: &str, ctx: &str) -> Result<Value, CommandError> {
113
56
    reparse_list(raw).map_err(|e| CommandError::Command(format!("{ctx}: reparse failed: {e}")))
114
56
}
115

            
116
56
fn extract_string(value: &Value, ctx: &str) -> Result<String, CommandError> {
117
56
    match value {
118
56
        Value::String(s) => Ok(s.clone()),
119
        Value::Nil => Err(CommandError::Command(format!(
120
            "{ctx}: got nil, expected string"
121
        ))),
122
        other => Err(CommandError::Command(format!(
123
            "{ctx}: unexpected value: {}",
124
            nomiscript::format_value(other)
125
        ))),
126
    }
127
56
}
128

            
129
263
fn field_str(plist: &Value, key: &str, ctx: &str) -> Result<String, CommandError> {
130
263
    plist_field(plist, key).ok_or_else(|| CommandError::Command(format!("{ctx}: missing {key}")))
131
263
}
132

            
133
41
fn field_usize(plist: &Value, key: &str, ctx: &str) -> Result<usize, CommandError> {
134
41
    let s = field_str(plist, key, ctx)?;
135
41
    usize::from_str(&s).map_err(|e| CommandError::Command(format!("{ctx}: {key} parse error: {e}")))
136
41
}
137

            
138
28
fn field_bool_sym(plist: &Value, key: &str) -> bool {
139
    // Lisp convention: `t` is truthy, `nil` is falsy.
140
28
    match plist_field_raw(plist, key) {
141
14
        Some(Value::Bool(b)) => b,
142
        Some(Value::Symbol(s)) => s == "t",
143
14
        Some(Value::Nil) | None => false,
144
        _ => false,
145
    }
146
28
}
147

            
148
125
fn field_list(plist: &Value, key: &str, ctx: &str) -> Result<Vec<Value>, CommandError> {
149
125
    match plist_field_raw(plist, key) {
150
1
        Some(Value::Nil) | None => Ok(vec![]),
151
124
        Some(v) => list_to_vec(&v)
152
124
            .ok_or_else(|| CommandError::Command(format!("{ctx}: {key} is not a list"))),
153
    }
154
125
}
155

            
156
// ---------- amounts ----------
157

            
158
55
fn parse_amounts(amounts_val: &Value, ctx: &str) -> Result<Vec<AmountView>, CommandError> {
159
55
    let items = match amounts_val {
160
        Value::Nil => return Ok(vec![]),
161
55
        v => list_to_vec(v)
162
55
            .ok_or_else(|| CommandError::Command(format!("{ctx}: amounts not a list")))?,
163
    };
164
55
    items
165
55
        .iter()
166
56
        .map(|item| {
167
56
            let symbol = field_str(item, ":symbol", ctx)?;
168
56
            let amount_s = field_str(item, ":amount", ctx)?;
169
56
            let amount = parse_rational(&amount_s)?;
170
56
            Ok(AmountView {
171
56
                commodity_symbol: symbol,
172
56
                amount,
173
56
            })
174
56
        })
175
55
        .collect()
176
55
}
177

            
178
// ---------- ReportNode / balance ----------
179

            
180
41
fn parse_report_node(
181
41
    node: &Value,
182
41
    parent_id: Option<Uuid>,
183
41
    out: &mut Vec<ReportRowView>,
184
41
) -> Result<(), CommandError> {
185
41
    let ctx = "balance-report node";
186
41
    let account_id_s = field_str(node, ":account-id", ctx)?;
187
41
    let account_id = Uuid::parse_str(&account_id_s)
188
41
        .map_err(|e| CommandError::Command(format!("{ctx}: account-id parse: {e}")))?;
189
41
    let account_name = field_str(node, ":account-name", ctx)?;
190
41
    let depth = field_usize(node, ":depth", ctx)?;
191

            
192
41
    let amounts_val = plist_field_raw(node, ":amounts").unwrap_or(Value::Nil);
193
41
    let amounts = parse_amounts(&amounts_val, ctx)?;
194

            
195
41
    let children_val = plist_field_raw(node, ":children").unwrap_or(Value::Nil);
196
41
    let children = match &children_val {
197
41
        Value::Nil => vec![],
198
        v => list_to_vec(v)
199
            .ok_or_else(|| CommandError::Command(format!("{ctx}: :children not a list")))?,
200
    };
201

            
202
41
    out.push(ReportRowView {
203
41
        account_id,
204
41
        parent_id,
205
41
        account_name,
206
41
        depth,
207
41
        has_children: !children.is_empty(),
208
41
        amounts,
209
41
    });
210

            
211
41
    for child in &children {
212
        parse_report_node(child, Some(account_id), out)?;
213
    }
214
41
    Ok(())
215
41
}
216

            
217
/// Map the `balance-report` wire string into a flat row list for the chart adapter.
218
28
pub fn value_to_balance_rows(value: &Value) -> Result<Vec<ReportRowView>, CommandError> {
219
28
    let raw = extract_string(value, "balance-report")?;
220
28
    let plist = reparse(&raw, "balance-report")?;
221
28
    let periods = field_list(&plist, ":periods", "balance-report")?;
222
28
    let mut rows = Vec::new();
223
28
    for period in &periods {
224
27
        let roots = field_list(period, ":roots", "balance-report period")?;
225
27
        for root in &roots {
226
27
            parse_report_node(root, None, &mut rows)?;
227
        }
228
    }
229
28
    Ok(rows)
230
28
}
231

            
232
// ---------- activity ----------
233

            
234
28
fn sum_into(dest: &mut Vec<AmountView>, src: &[AmountView], negate: bool) {
235
28
    for a in src {
236
28
        let contribution = if negate { -a.amount } else { a.amount };
237
28
        match dest
238
28
            .iter_mut()
239
28
            .find(|d| d.commodity_symbol == a.commodity_symbol)
240
        {
241
            Some(existing) => existing.amount += contribution,
242
28
            None => dest.push(AmountView {
243
28
                commodity_symbol: a.commodity_symbol.clone(),
244
28
                amount: contribution,
245
28
            }),
246
        }
247
    }
248
28
}
249

            
250
14
fn top_level_totals(rows: &[ReportRowView]) -> Vec<AmountView> {
251
14
    let mut out = Vec::new();
252
14
    for row in rows.iter().filter(|r| r.depth == 0) {
253
14
        sum_into(&mut out, &row.amounts, false);
254
14
    }
255
14
    out
256
14
}
257

            
258
/// Map the `activity-report` wire string into period views for the chart adapter.
259
14
pub fn value_to_activity_periods(value: &Value) -> Result<Vec<PeriodActivityView>, CommandError> {
260
14
    let raw = extract_string(value, "activity-report")?;
261
14
    let plist = reparse(&raw, "activity-report")?;
262
14
    let period_vals = field_list(&plist, ":periods", "activity-report")?;
263

            
264
14
    period_vals
265
14
        .iter()
266
14
        .map(|period| {
267
14
            let label = plist_field(period, ":label").unwrap_or_default();
268
14
            let group_vals = field_list(period, ":groups", "activity period")?;
269

            
270
14
            let groups: Vec<GroupView> = group_vals
271
14
                .iter()
272
14
                .map(|g| {
273
14
                    let g_label = field_str(g, ":label", "activity group")?;
274
14
                    let flip_sign = field_bool_sym(g, ":flip-sign");
275
14
                    let root_vals = field_list(g, ":roots", "activity group")?;
276
14
                    let mut rows = Vec::new();
277
14
                    for root in &root_vals {
278
14
                        parse_report_node(root, None, &mut rows)?;
279
                    }
280
                    // Apply flip to amounts matching the server-side convention.
281
14
                    if flip_sign {
282
14
                        for row in &mut rows {
283
14
                            for a in &mut row.amounts {
284
14
                                a.amount = -a.amount;
285
14
                            }
286
                        }
287
                    }
288
14
                    let total = top_level_totals(&rows);
289
14
                    Ok(GroupView {
290
14
                        label: g_label,
291
14
                        flip_sign,
292
14
                        rows,
293
14
                        total,
294
14
                    })
295
14
                })
296
14
                .collect::<Result<_, CommandError>>()?;
297

            
298
14
            let mut net: Vec<AmountView> = Vec::new();
299
14
            for g in &groups {
300
14
                sum_into(&mut net, &g.total, !g.flip_sign);
301
14
            }
302
14
            Ok(PeriodActivityView { label, groups, net })
303
14
        })
304
14
        .collect()
305
14
}
306

            
307
// ---------- breakdown ----------
308

            
309
/// Map the `category-breakdown` wire string into period views for the chart adapter.
310
14
pub fn value_to_breakdown_periods(value: &Value) -> Result<Vec<BreakdownPeriodView>, CommandError> {
311
14
    let raw = extract_string(value, "category-breakdown")?;
312
14
    let plist = reparse(&raw, "category-breakdown")?;
313
14
    let period_vals = field_list(&plist, ":periods", "category-breakdown")?;
314

            
315
14
    period_vals
316
14
        .iter()
317
14
        .map(|period| {
318
14
            let label = plist_field(period, ":label").unwrap_or_default();
319
14
            let row_vals = field_list(period, ":rows", "breakdown period")?;
320

            
321
14
            let rows: Vec<BreakdownRowView> = row_vals
322
14
                .iter()
323
14
                .map(|row| {
324
14
                    let tag_value = field_str(row, ":tag-value", "breakdown row")?;
325
14
                    let is_uncategorized = field_bool_sym(row, ":uncategorized");
326
14
                    let amounts_val = plist_field_raw(row, ":amounts").unwrap_or(Value::Nil);
327
14
                    let amounts = parse_amounts(&amounts_val, "breakdown row")?;
328
14
                    Ok(BreakdownRowView {
329
14
                        tag_value,
330
14
                        is_uncategorized,
331
14
                        amounts,
332
14
                    })
333
14
                })
334
14
                .collect::<Result<_, CommandError>>()?;
335

            
336
14
            Ok(BreakdownPeriodView { label, rows })
337
14
        })
338
14
        .collect()
339
14
}
340

            
341
#[cfg(test)]
342
mod tests {
343
    use super::*;
344

            
345
4
    fn parse(s: &str) -> Value {
346
4
        Value::String(s.to_string())
347
4
    }
348

            
349
    #[test]
350
1
    fn coerce_date_arg_yyyy_mm_dd_start_of_day() {
351
1
        let s = coerce_date_arg("2024-03-15", false).expect("valid date");
352
1
        assert_eq!(s, "2024-03-15T00:00:00+00:00");
353
1
    }
354

            
355
    #[test]
356
1
    fn coerce_date_arg_yyyy_mm_dd_end_of_day() {
357
1
        let s = coerce_date_arg("2024-03-15", true).expect("valid date");
358
1
        assert_eq!(s, "2024-03-15T23:59:59+00:00");
359
1
    }
360

            
361
    #[test]
362
1
    fn coerce_date_arg_rfc3339_passes_through() {
363
1
        let rfc = "2024-03-15T12:34:56+00:00";
364
1
        assert_eq!(coerce_date_arg(rfc, false).expect("valid"), rfc);
365
1
        assert_eq!(coerce_date_arg(rfc, true).expect("valid"), rfc);
366
1
    }
367

            
368
    #[test]
369
1
    fn coerce_date_arg_invalid_is_error() {
370
1
        assert!(coerce_date_arg("not-a-date", false).is_err());
371
1
        assert!(coerce_date_arg("2024-99-01", false).is_err());
372
1
    }
373

            
374
    #[test]
375
1
    fn parse_rational_integer() {
376
1
        assert_eq!(parse_rational("42").unwrap(), Rational64::new(42, 1));
377
1
    }
378

            
379
    #[test]
380
1
    fn parse_rational_fraction() {
381
1
        assert_eq!(parse_rational("3/4").unwrap(), Rational64::new(3, 4));
382
1
    }
383

            
384
    #[test]
385
1
    fn parse_rational_negative() {
386
1
        assert_eq!(parse_rational("-5/2").unwrap(), Rational64::new(-5, 2));
387
1
    }
388

            
389
    #[test]
390
1
    fn parse_rational_zero_denom_is_error() {
391
1
        assert!(parse_rational("1/0").is_err());
392
1
    }
393

            
394
    #[test]
395
1
    fn parse_chart_shape_defaults_to_bar() {
396
1
        assert_eq!(parse_chart_shape("anything"), ChartKind::Bar);
397
1
        assert_eq!(parse_chart_shape("bar"), ChartKind::Bar);
398
1
    }
399

            
400
    #[test]
401
1
    fn parse_chart_shape_line() {
402
1
        assert_eq!(parse_chart_shape("line"), ChartKind::Line);
403
1
    }
404

            
405
    #[test]
406
1
    fn parse_chart_shape_stacked() {
407
1
        assert_eq!(parse_chart_shape("stacked"), ChartKind::StackedBar);
408
1
        assert_eq!(parse_chart_shape("stackedbar"), ChartKind::StackedBar);
409
1
    }
410

            
411
    #[test]
412
1
    fn parse_renderer_hint_text() {
413
1
        assert!(matches!(parse_renderer_hint("text"), RendererHint::Text));
414
1
        assert!(matches!(parse_renderer_hint("ascii"), RendererHint::Text));
415
1
    }
416

            
417
    #[test]
418
1
    fn parse_renderer_hint_kitty() {
419
1
        assert!(matches!(parse_renderer_hint("kitty"), RendererHint::Kitty));
420
1
        assert!(matches!(
421
1
            parse_renderer_hint("graphic"),
422
            RendererHint::Kitty
423
        ));
424
1
    }
425

            
426
    #[test]
427
1
    fn parse_renderer_hint_auto() {
428
1
        assert!(matches!(parse_renderer_hint("bar"), RendererHint::Auto));
429
1
        assert!(matches!(parse_renderer_hint("line"), RendererHint::Auto));
430
1
    }
431

            
432
    #[test]
433
1
    fn probe_terminal_kitty_returns_false_in_tests() {
434
        // In CI / test environment stdout is not a tty → always false.
435
1
        if !std::io::stdout().is_terminal() {
436
1
            assert!(!probe_terminal_kitty());
437
        }
438
1
    }
439

            
440
    #[test]
441
1
    fn value_to_balance_rows_single_node_two_amounts() {
442
1
        let wire = r#"(:balance-report :periods ((:label nil :roots ((:account-id "00000000-0000-0000-0000-000000000000" :account-name "Assets" :account-path "Assets" :depth 0 :account-type "asset" :amounts ((:commodity-id "00000000-0000-0000-0000-000000000000" :symbol "USD" :amount 100) (:commodity-id "00000000-0000-0000-0000-000000000000" :symbol "EUR" :amount 50)) :children ())))))"#;
443
1
        let rows = value_to_balance_rows(&parse(wire)).expect("parse ok");
444
1
        assert_eq!(rows.len(), 1);
445
1
        assert_eq!(rows[0].account_name, "Assets");
446
1
        assert_eq!(rows[0].amounts.len(), 2, "both commodities must be present");
447
1
        assert!(rows[0].amounts.iter().any(|a| a.commodity_symbol == "USD"));
448
2
        assert!(rows[0].amounts.iter().any(|a| a.commodity_symbol == "EUR"));
449
1
    }
450

            
451
    #[test]
452
1
    fn value_to_balance_rows_empty_periods() {
453
1
        let wire = "(:balance-report :periods ())";
454
1
        let rows = value_to_balance_rows(&parse(wire)).expect("empty ok");
455
1
        assert!(rows.is_empty());
456
1
    }
457

            
458
    #[test]
459
1
    fn value_to_activity_periods_parses_groups() {
460
1
        let wire = r#"(:activity-report :meta nil :periods ((:label "2026-Q1" :groups ((:label "Income" :flip-sign t :roots ((:account-id "00000000-0000-0000-0000-000000000000" :account-name "Salary" :account-path "Income:Salary" :depth 0 :account-type nil :amounts ((:commodity-id "00000000-0000-0000-0000-000000000000" :symbol "USD" :amount 1000)) :children ())))))))"#;
461
1
        let periods = value_to_activity_periods(&parse(wire)).expect("parse ok");
462
1
        assert_eq!(periods.len(), 1);
463
1
        assert_eq!(periods[0].label, "2026-Q1");
464
1
        assert_eq!(periods[0].groups.len(), 1);
465
1
        assert_eq!(periods[0].groups[0].label, "Income");
466
        // flip_sign=t means amounts get negated in the rows
467
1
        assert_eq!(
468
1
            periods[0].groups[0].rows[0].amounts[0].amount,
469
1
            Rational64::new(-1000, 1)
470
        );
471
1
    }
472

            
473
    #[test]
474
1
    fn value_to_breakdown_periods_parses_rows() {
475
1
        let wire = r#"(:category-breakdown :meta nil :tag-name "category" :periods ((:label nil :rows ((:tag-value "Food" :uncategorized nil :amounts ((:commodity-id "00000000-0000-0000-0000-000000000000" :symbol "USD" :amount 200)))))))"#;
476
1
        let periods = value_to_breakdown_periods(&parse(wire)).expect("parse ok");
477
1
        assert_eq!(periods.len(), 1);
478
1
        assert_eq!(periods[0].rows.len(), 1);
479
1
        assert_eq!(periods[0].rows[0].tag_value, "Food");
480
1
        assert_eq!(
481
1
            periods[0].rows[0].amounts[0].amount,
482
1
            Rational64::new(200, 1)
483
        );
484
1
    }
485
}