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

            
40
63
pub fn parse_chart_shape(s: &str) -> ChartKind {
41
63
    match s.to_ascii_lowercase().as_str() {
42
63
        "line" => ChartKind::Line,
43
62
        "stacked" | "stackedbar" => ChartKind::StackedBar,
44
60
        _ => ChartKind::Bar,
45
    }
46
63
}
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
62
fn reparse(raw: &str, ctx: &str) -> Result<Value, CommandError> {
113
62
    reparse_list(raw).map_err(|e| CommandError::Command(format!("{ctx}: reparse failed: {e}")))
114
62
}
115

            
116
62
fn extract_string(value: &Value, ctx: &str) -> Result<String, CommandError> {
117
62
    match value {
118
62
        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
62
}
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
28
    plist_field_raw(plist, key).is_some_and(|value| !value.is_nil())
140
28
}
141

            
142
131
fn field_list(plist: &Value, key: &str, ctx: &str) -> Result<Vec<Value>, CommandError> {
143
131
    match plist_field_raw(plist, key) {
144
7
        Some(Value::Nil) | None => Ok(vec![]),
145
124
        Some(v) => list_to_vec(&v)
146
124
            .ok_or_else(|| CommandError::Command(format!("{ctx}: {key} is not a list"))),
147
    }
148
131
}
149

            
150
// ---------- amounts ----------
151

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

            
172
// ---------- ReportNode / balance ----------
173

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

            
186
41
    let amounts_val = plist_field_raw(node, ":amounts").unwrap_or(Value::Nil);
187
41
    let amounts = parse_amounts(&amounts_val, ctx)?;
188

            
189
41
    let children_val = plist_field_raw(node, ":children").unwrap_or(Value::Nil);
190
41
    let children = match &children_val {
191
41
        Value::Nil => vec![],
192
        v => list_to_vec(v)
193
            .ok_or_else(|| CommandError::Command(format!("{ctx}: :children not a list")))?,
194
    };
195

            
196
41
    out.push(ReportRowView {
197
41
        account_id,
198
41
        parent_id,
199
41
        account_name,
200
41
        depth,
201
41
        has_children: !children.is_empty(),
202
41
        amounts,
203
41
    });
204

            
205
41
    for child in &children {
206
        parse_report_node(child, Some(account_id), out)?;
207
    }
208
41
    Ok(())
209
41
}
210

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

            
226
// ---------- activity ----------
227

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

            
244
14
fn top_level_totals(rows: &[ReportRowView]) -> Vec<AmountView> {
245
14
    let mut out = Vec::new();
246
14
    for row in rows.iter().filter(|r| r.depth == 0) {
247
14
        sum_into(&mut out, &row.amounts, false);
248
14
    }
249
14
    out
250
14
}
251

            
252
/// Map the `activity-report` wire string into period views for the chart adapter.
253
16
pub fn value_to_activity_periods(value: &Value) -> Result<Vec<PeriodActivityView>, CommandError> {
254
16
    let raw = extract_string(value, "activity-report")?;
255
16
    let plist = reparse(&raw, "activity-report")?;
256
16
    let period_vals = field_list(&plist, ":periods", "activity-report")?;
257

            
258
16
    period_vals
259
16
        .iter()
260
16
        .map(|period| {
261
14
            let label = plist_field(period, ":label").unwrap_or_default();
262
14
            let group_vals = field_list(period, ":groups", "activity period")?;
263

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

            
292
14
            let mut net: Vec<AmountView> = Vec::new();
293
14
            for g in &groups {
294
14
                sum_into(&mut net, &g.total, !g.flip_sign);
295
14
            }
296
14
            Ok(PeriodActivityView { label, groups, net })
297
14
        })
298
16
        .collect()
299
16
}
300

            
301
// ---------- breakdown ----------
302

            
303
/// Map the `category-breakdown` wire string into period views for the chart adapter.
304
16
pub fn value_to_breakdown_periods(value: &Value) -> Result<Vec<BreakdownPeriodView>, CommandError> {
305
16
    let raw = extract_string(value, "category-breakdown")?;
306
16
    let plist = reparse(&raw, "category-breakdown")?;
307
16
    let period_vals = field_list(&plist, ":periods", "category-breakdown")?;
308

            
309
16
    period_vals
310
16
        .iter()
311
16
        .map(|period| {
312
14
            let label = plist_field(period, ":label").unwrap_or_default();
313
14
            let row_vals = field_list(period, ":rows", "breakdown period")?;
314

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

            
330
14
            Ok(BreakdownPeriodView { label, rows })
331
14
        })
332
16
        .collect()
333
16
}
334

            
335
#[cfg(test)]
336
mod tests {
337
    use super::*;
338

            
339
4
    fn parse(s: &str) -> Value {
340
4
        Value::String(s.to_string())
341
4
    }
342

            
343
    #[test]
344
1
    fn coerce_date_arg_yyyy_mm_dd_start_of_day() {
345
1
        let s = coerce_date_arg("2024-03-15", false).expect("valid date");
346
1
        assert_eq!(s, "2024-03-15T00:00:00+00:00");
347
1
    }
348

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

            
355
    #[test]
356
1
    fn coerce_date_arg_rfc3339_passes_through() {
357
1
        let rfc = "2024-03-15T12:34:56+00:00";
358
1
        assert_eq!(coerce_date_arg(rfc, false).expect("valid"), rfc);
359
1
        assert_eq!(coerce_date_arg(rfc, true).expect("valid"), rfc);
360
1
    }
361

            
362
    #[test]
363
1
    fn coerce_date_arg_invalid_is_error() {
364
1
        assert!(coerce_date_arg("not-a-date", false).is_err());
365
1
        assert!(coerce_date_arg("2024-99-01", false).is_err());
366
1
    }
367

            
368
    #[test]
369
1
    fn parse_rational_integer() {
370
1
        assert_eq!(parse_rational("42").unwrap(), Rational64::new(42, 1));
371
1
    }
372

            
373
    #[test]
374
1
    fn parse_rational_fraction() {
375
1
        assert_eq!(parse_rational("3/4").unwrap(), Rational64::new(3, 4));
376
1
    }
377

            
378
    #[test]
379
1
    fn parse_rational_negative() {
380
1
        assert_eq!(parse_rational("-5/2").unwrap(), Rational64::new(-5, 2));
381
1
    }
382

            
383
    #[test]
384
1
    fn parse_rational_zero_denom_is_error() {
385
1
        assert!(parse_rational("1/0").is_err());
386
1
    }
387

            
388
    #[test]
389
1
    fn parse_chart_shape_defaults_to_bar() {
390
1
        assert_eq!(parse_chart_shape("anything"), ChartKind::Bar);
391
1
        assert_eq!(parse_chart_shape("bar"), ChartKind::Bar);
392
1
    }
393

            
394
    #[test]
395
1
    fn parse_chart_shape_line() {
396
1
        assert_eq!(parse_chart_shape("line"), ChartKind::Line);
397
1
    }
398

            
399
    #[test]
400
1
    fn parse_chart_shape_stacked() {
401
1
        assert_eq!(parse_chart_shape("stacked"), ChartKind::StackedBar);
402
1
        assert_eq!(parse_chart_shape("stackedbar"), ChartKind::StackedBar);
403
1
    }
404

            
405
    #[test]
406
1
    fn parse_renderer_hint_text() {
407
1
        assert!(matches!(parse_renderer_hint("text"), RendererHint::Text));
408
1
        assert!(matches!(parse_renderer_hint("ascii"), RendererHint::Text));
409
1
    }
410

            
411
    #[test]
412
1
    fn parse_renderer_hint_kitty() {
413
1
        assert!(matches!(parse_renderer_hint("kitty"), RendererHint::Kitty));
414
1
        assert!(matches!(
415
1
            parse_renderer_hint("graphic"),
416
            RendererHint::Kitty
417
        ));
418
1
    }
419

            
420
    #[test]
421
1
    fn parse_renderer_hint_auto() {
422
1
        assert!(matches!(parse_renderer_hint("bar"), RendererHint::Auto));
423
1
        assert!(matches!(parse_renderer_hint("line"), RendererHint::Auto));
424
1
    }
425

            
426
    #[test]
427
1
    fn probe_terminal_kitty_returns_false_in_tests() {
428
        // In CI / test environment stdout is not a tty → always false.
429
1
        if !std::io::stdout().is_terminal() {
430
1
            assert!(!probe_terminal_kitty());
431
        }
432
1
    }
433

            
434
    #[test]
435
1
    fn value_to_balance_rows_single_node_two_amounts() {
436
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 ())))))"#;
437
1
        let rows = value_to_balance_rows(&parse(wire)).expect("parse ok");
438
1
        assert_eq!(rows.len(), 1);
439
1
        assert_eq!(rows[0].account_name, "Assets");
440
1
        assert_eq!(rows[0].amounts.len(), 2, "both commodities must be present");
441
1
        assert!(rows[0].amounts.iter().any(|a| a.commodity_symbol == "USD"));
442
2
        assert!(rows[0].amounts.iter().any(|a| a.commodity_symbol == "EUR"));
443
1
    }
444

            
445
    #[test]
446
1
    fn value_to_balance_rows_empty_periods() {
447
1
        let wire = "(:balance-report :periods ())";
448
1
        let rows = value_to_balance_rows(&parse(wire)).expect("empty ok");
449
1
        assert!(rows.is_empty());
450
1
    }
451

            
452
    #[test]
453
1
    fn value_to_activity_periods_parses_groups() {
454
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 ())))))))"#;
455
1
        let periods = value_to_activity_periods(&parse(wire)).expect("parse ok");
456
1
        assert_eq!(periods.len(), 1);
457
1
        assert_eq!(periods[0].label, "2026-Q1");
458
1
        assert_eq!(periods[0].groups.len(), 1);
459
1
        assert_eq!(periods[0].groups[0].label, "Income");
460
        // flip_sign=t means amounts get negated in the rows
461
1
        assert_eq!(
462
1
            periods[0].groups[0].rows[0].amounts[0].amount,
463
1
            Rational64::new(-1000, 1)
464
        );
465
1
    }
466

            
467
    #[test]
468
1
    fn value_to_breakdown_periods_parses_rows() {
469
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)))))))"#;
470
1
        let periods = value_to_breakdown_periods(&parse(wire)).expect("parse ok");
471
1
        assert_eq!(periods.len(), 1);
472
1
        assert_eq!(periods[0].rows.len(), 1);
473
1
        assert_eq!(periods[0].rows[0].tag_value, "Food");
474
1
        assert_eq!(
475
1
            periods[0].rows[0].amounts[0].amount,
476
1
            Rational64::new(200, 1)
477
        );
478
1
    }
479
}