1
//! Plain-text chart renderer. Draws `ChartSpec` as a block of text
2
//! suitable for the CLI's log region, terminal stdout, or any
3
//! context where SVG/canvas/ratatui widgets aren't available.
4
//!
5
//! Bar and stacked-bar specs render as horizontal bars sized to the
6
//! biggest magnitude. Line specs render as a per-series sparkline.
7
//! The output is purely monospace text — no colour — so it survives
8
//! pasting into logs and scrolling.
9

            
10
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
11

            
12
const DEFAULT_WIDTH: usize = 48;
13

            
14
/// Render `spec` to a multi-line string. `width` controls how wide
15
/// each bar gets (min 10, max ~200 is sensible).
16
#[must_use]
17
3
pub fn render_text(spec: &ChartSpec, width: usize) -> String {
18
3
    let width = width.clamp(10, 200);
19
3
    let mut out = String::new();
20
3
    out.push_str(&spec.title);
21
3
    out.push('\n');
22
3
    if !spec.y_label.is_empty() {
23
2
        out.push_str(&format!("  ({})\n", spec.y_label));
24
2
    }
25

            
26
3
    match spec.kind {
27
2
        ChartKind::Bar | ChartKind::StackedBar => draw_bars(spec, width, &mut out),
28
1
        ChartKind::Line => draw_lines(spec, width, &mut out),
29
    }
30

            
31
3
    if !spec.notes.is_empty() {
32
1
        out.push('\n');
33
1
        for note in &spec.notes {
34
1
            out.push_str("  · ");
35
1
            out.push_str(note);
36
1
            out.push('\n');
37
1
        }
38
2
    }
39

            
40
3
    out
41
3
}
42

            
43
/// Default-width convenience wrapper: 48 columns, readable in the
44
/// typical terminal log pane.
45
#[must_use]
46
pub fn render_text_default(spec: &ChartSpec) -> String {
47
    render_text(spec, DEFAULT_WIDTH)
48
}
49

            
50
2
fn draw_bars(spec: &ChartSpec, width: usize, out: &mut String) {
51
    // Flatten every (series label, point) into (label, value) rows so
52
    // each bar gets an independent entry. Stacked vs grouped collapse
53
    // to the same visual in text — one bar per (series, x).
54
2
    let mut rows: Vec<(String, f64)> = Vec::new();
55
2
    for series in &spec.series {
56
2
        let prefix = if spec.series.len() > 1 {
57
2
            format!("{}:", series.label)
58
        } else {
59
            String::new()
60
        };
61
2
        for point in &series.points {
62
2
            let label = if prefix.is_empty() {
63
                point.x.clone()
64
2
            } else if point.x.is_empty() {
65
                series.label.clone()
66
            } else {
67
2
                format!("{prefix}{}", point.x)
68
            };
69
2
            rows.push((label, point.y_f64()));
70
        }
71
    }
72

            
73
2
    if rows.is_empty() {
74
1
        out.push_str("  (no data)\n");
75
1
        return;
76
1
    }
77

            
78
1
    let label_width = rows
79
1
        .iter()
80
2
        .map(|(l, _)| l.chars().count())
81
1
        .max()
82
1
        .unwrap_or(1)
83
1
        .min(width / 2);
84
1
    let max_abs = rows
85
1
        .iter()
86
2
        .map(|(_, v)| v.abs())
87
1
        .fold(0.0_f64, f64::max)
88
1
        .max(1.0);
89
1
    let bar_width = width.saturating_sub(label_width + 3).max(4);
90

            
91
2
    for (label, value) in rows {
92
2
        let filled = ((value.abs() / max_abs) * bar_width as f64).round() as usize;
93
2
        let bar: String = "█".repeat(filled.min(bar_width));
94
2
        let label_trunc: String = truncate_left(&label, label_width);
95
2
        let sign = if value < 0.0 { "-" } else { " " };
96
2
        out.push_str(&format!(
97
2
            "  {label_trunc:>label_width$} {sign}{bar} {value:.2}\n",
98
2
        ));
99
    }
100
2
}
101

            
102
1
fn draw_lines(spec: &ChartSpec, width: usize, out: &mut String) {
103
    // For each series, draw a one-line sparkline using Unicode block
104
    // characters. Align them by a common min/max so multiple series
105
    // on the same chart read as comparable.
106
1
    if spec.series.is_empty() || spec.series.iter().all(|s| s.points.is_empty()) {
107
        out.push_str("  (no data)\n");
108
        return;
109
1
    }
110

            
111
1
    let (min, max) = spec
112
1
        .series
113
1
        .iter()
114
1
        .flat_map(|s| s.points.iter().map(SeriesPoint::y_f64))
115
3
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), y| {
116
3
            (mn.min(y), mx.max(y))
117
3
        });
118
1
    let span = (max - min).abs().max(1.0);
119
1
    let label_width = spec
120
1
        .series
121
1
        .iter()
122
1
        .map(|s| s.label.chars().count())
123
1
        .max()
124
1
        .unwrap_or(1)
125
1
        .min(width / 2);
126
1
    let line_width = width.saturating_sub(label_width + 3).max(4);
127
    const BLOCKS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
128

            
129
1
    for series in &spec.series {
130
1
        let label = truncate_left(&series.label, label_width);
131
1
        let sparkline: String = (0..series.points.len().min(line_width))
132
3
            .map(|idx| {
133
3
                let y = series.points[idx].y_f64();
134
3
                let pos = ((y - min) / span * (BLOCKS.len() - 1) as f64)
135
3
                    .round()
136
3
                    .clamp(0.0, (BLOCKS.len() - 1) as f64);
137
3
                BLOCKS[pos as usize]
138
3
            })
139
1
            .collect();
140
1
        out.push_str(&format!("  {label:>label_width$} {sparkline}\n"));
141
    }
142
1
}
143

            
144
5
fn truncate_left(s: &str, max: usize) -> String {
145
5
    let chars: Vec<char> = s.chars().collect();
146
5
    if chars.len() <= max {
147
4
        s.to_string()
148
    } else {
149
        // Keep the trailing characters — account names often share a
150
        // common prefix (e.g. "Expenses:Food") and the tail is the
151
        // informative bit.
152
1
        chars[chars.len() - max..].iter().collect()
153
    }
154
5
}
155

            
156
#[cfg(test)]
157
mod tests {
158
    use super::*;
159
    use crate::spec::{ChartKind, Series, SeriesPoint};
160

            
161
1
    fn sample_bar() -> ChartSpec {
162
1
        ChartSpec {
163
1
            title: "Balance".to_string(),
164
1
            kind: ChartKind::Bar,
165
1
            x_label: "Account".to_string(),
166
1
            y_label: "USD".to_string(),
167
1
            series: vec![
168
1
                Series {
169
1
                    label: "Assets".to_string(),
170
1
                    commodity_symbol: "USD".to_string(),
171
1
                    points: vec![SeriesPoint {
172
1
                        x: "Assets".to_string(),
173
1
                        y_num: 10_000,
174
1
                        y_denom: 1,
175
1
                    }],
176
1
                },
177
1
                Series {
178
1
                    label: "Income".to_string(),
179
1
                    commodity_symbol: "USD".to_string(),
180
1
                    points: vec![SeriesPoint {
181
1
                        x: "Income".to_string(),
182
1
                        y_num: -3_000,
183
1
                        y_denom: 1,
184
1
                    }],
185
1
                },
186
1
            ],
187
1
            notes: vec!["Top 2 accounts.".to_string()],
188
1
        }
189
1
    }
190

            
191
    #[test]
192
1
    fn render_text_contains_title_and_labels() {
193
1
        let out = render_text(&sample_bar(), 40);
194
1
        assert!(out.contains("Balance"));
195
1
        assert!(out.contains("Assets"));
196
1
        assert!(out.contains("Income"));
197
1
        assert!(out.contains("Top 2 accounts."));
198
1
    }
199

            
200
    #[test]
201
1
    fn render_text_empty_spec_is_safe() {
202
1
        let spec = ChartSpec {
203
1
            title: "Empty".to_string(),
204
1
            kind: ChartKind::Bar,
205
1
            x_label: String::new(),
206
1
            y_label: String::new(),
207
1
            series: vec![],
208
1
            notes: vec![],
209
1
        };
210
1
        let out = render_text(&spec, 20);
211
1
        assert!(out.contains("no data"));
212
1
    }
213

            
214
    #[test]
215
1
    fn render_text_line_uses_sparkline_blocks() {
216
1
        let spec = ChartSpec {
217
1
            title: "Line".to_string(),
218
1
            kind: ChartKind::Line,
219
1
            x_label: "Month".to_string(),
220
1
            y_label: "USD".to_string(),
221
1
            series: vec![Series {
222
1
                label: "Income".to_string(),
223
1
                commodity_symbol: "USD".to_string(),
224
1
                points: vec![
225
1
                    SeriesPoint {
226
1
                        x: "Jan".to_string(),
227
1
                        y_num: 100,
228
1
                        y_denom: 1,
229
1
                    },
230
1
                    SeriesPoint {
231
1
                        x: "Feb".to_string(),
232
1
                        y_num: 200,
233
1
                        y_denom: 1,
234
1
                    },
235
1
                    SeriesPoint {
236
1
                        x: "Mar".to_string(),
237
1
                        y_num: 50,
238
1
                        y_denom: 1,
239
1
                    },
240
1
                ],
241
1
            }],
242
1
            notes: vec![],
243
1
        };
244
1
        let out = render_text(&spec, 30);
245
        // At least one sparkline block char appears in the output.
246
23
        assert!(out.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)));
247
1
    }
248

            
249
    #[test]
250
1
    fn truncate_left_keeps_tail() {
251
1
        assert_eq!(truncate_left("Expenses:Food", 5), ":Food");
252
1
        assert_eq!(truncate_left("Food", 10), "Food");
253
1
    }
254
}