1use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
11
12const DEFAULT_WIDTH: usize = 48;
13
14#[must_use]
17pub fn render_text(spec: &ChartSpec, width: usize) -> String {
18 let width = width.clamp(10, 200);
19 let mut out = String::new();
20 out.push_str(&spec.title);
21 out.push('\n');
22 if !spec.y_label.is_empty() {
23 out.push_str(&format!(" ({})\n", spec.y_label));
24 }
25
26 match spec.kind {
27 ChartKind::Bar | ChartKind::StackedBar => draw_bars(spec, width, &mut out),
28 ChartKind::Line => draw_lines(spec, width, &mut out),
29 }
30
31 if !spec.notes.is_empty() {
32 out.push('\n');
33 for note in &spec.notes {
34 out.push_str(" · ");
35 out.push_str(note);
36 out.push('\n');
37 }
38 }
39
40 out
41}
42
43#[must_use]
46pub fn render_text_default(spec: &ChartSpec) -> String {
47 render_text(spec, DEFAULT_WIDTH)
48}
49
50fn draw_bars(spec: &ChartSpec, width: usize, out: &mut String) {
51 let mut rows: Vec<(String, f64)> = Vec::new();
55 for series in &spec.series {
56 let prefix = if spec.series.len() > 1 {
57 format!("{}:", series.label)
58 } else {
59 String::new()
60 };
61 for point in &series.points {
62 let label = if prefix.is_empty() {
63 point.x.clone()
64 } else if point.x.is_empty() {
65 series.label.clone()
66 } else {
67 format!("{prefix}{}", point.x)
68 };
69 rows.push((label, point.y_f64()));
70 }
71 }
72
73 if rows.is_empty() {
74 out.push_str(" (no data)\n");
75 return;
76 }
77
78 let label_width = rows
79 .iter()
80 .map(|(l, _)| l.chars().count())
81 .max()
82 .unwrap_or(1)
83 .min(width / 2);
84 let max_abs = rows
85 .iter()
86 .map(|(_, v)| v.abs())
87 .fold(0.0_f64, f64::max)
88 .max(1.0);
89 let bar_width = width.saturating_sub(label_width + 3).max(4);
90
91 for (label, value) in rows {
92 let filled = ((value.abs() / max_abs) * bar_width as f64).round() as usize;
93 let bar: String = "█".repeat(filled.min(bar_width));
94 let label_trunc: String = truncate_left(&label, label_width);
95 let sign = if value < 0.0 { "-" } else { " " };
96 out.push_str(&format!(
97 " {label_trunc:>label_width$} {sign}{bar} {value:.2}\n",
98 ));
99 }
100}
101
102fn draw_lines(spec: &ChartSpec, width: usize, out: &mut String) {
103 if spec.series.is_empty() || spec.series.iter().all(|s| s.points.is_empty()) {
107 out.push_str(" (no data)\n");
108 return;
109 }
110
111 let (min, max) = spec
112 .series
113 .iter()
114 .flat_map(|s| s.points.iter().map(SeriesPoint::y_f64))
115 .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), y| {
116 (mn.min(y), mx.max(y))
117 });
118 let span = (max - min).abs().max(1.0);
119 let label_width = spec
120 .series
121 .iter()
122 .map(|s| s.label.chars().count())
123 .max()
124 .unwrap_or(1)
125 .min(width / 2);
126 let line_width = width.saturating_sub(label_width + 3).max(4);
127 const BLOCKS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
128
129 for series in &spec.series {
130 let label = truncate_left(&series.label, label_width);
131 let sparkline: String = (0..series.points.len().min(line_width))
132 .map(|idx| {
133 let y = series.points[idx].y_f64();
134 let pos = ((y - min) / span * (BLOCKS.len() - 1) as f64)
135 .round()
136 .clamp(0.0, (BLOCKS.len() - 1) as f64);
137 BLOCKS[pos as usize]
138 })
139 .collect();
140 out.push_str(&format!(" {label:>label_width$} {sparkline}\n"));
141 }
142}
143
144fn truncate_left(s: &str, max: usize) -> String {
145 let chars: Vec<char> = s.chars().collect();
146 if chars.len() <= max {
147 s.to_string()
148 } else {
149 chars[chars.len() - max..].iter().collect()
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::spec::{ChartKind, Series, SeriesPoint};
160
161 fn sample_bar() -> ChartSpec {
162 ChartSpec {
163 title: "Balance".to_string(),
164 kind: ChartKind::Bar,
165 x_label: "Account".to_string(),
166 y_label: "USD".to_string(),
167 series: vec![
168 Series {
169 label: "Assets".to_string(),
170 commodity_symbol: "USD".to_string(),
171 points: vec![SeriesPoint {
172 x: "Assets".to_string(),
173 y_num: 10_000,
174 y_denom: 1,
175 }],
176 },
177 Series {
178 label: "Income".to_string(),
179 commodity_symbol: "USD".to_string(),
180 points: vec![SeriesPoint {
181 x: "Income".to_string(),
182 y_num: -3_000,
183 y_denom: 1,
184 }],
185 },
186 ],
187 notes: vec!["Top 2 accounts.".to_string()],
188 }
189 }
190
191 #[test]
192 fn render_text_contains_title_and_labels() {
193 let out = render_text(&sample_bar(), 40);
194 assert!(out.contains("Balance"));
195 assert!(out.contains("Assets"));
196 assert!(out.contains("Income"));
197 assert!(out.contains("Top 2 accounts."));
198 }
199
200 #[test]
201 fn render_text_empty_spec_is_safe() {
202 let spec = ChartSpec {
203 title: "Empty".to_string(),
204 kind: ChartKind::Bar,
205 x_label: String::new(),
206 y_label: String::new(),
207 series: vec![],
208 notes: vec![],
209 };
210 let out = render_text(&spec, 20);
211 assert!(out.contains("no data"));
212 }
213
214 #[test]
215 fn render_text_line_uses_sparkline_blocks() {
216 let spec = ChartSpec {
217 title: "Line".to_string(),
218 kind: ChartKind::Line,
219 x_label: "Month".to_string(),
220 y_label: "USD".to_string(),
221 series: vec![Series {
222 label: "Income".to_string(),
223 commodity_symbol: "USD".to_string(),
224 points: vec![
225 SeriesPoint {
226 x: "Jan".to_string(),
227 y_num: 100,
228 y_denom: 1,
229 },
230 SeriesPoint {
231 x: "Feb".to_string(),
232 y_num: 200,
233 y_denom: 1,
234 },
235 SeriesPoint {
236 x: "Mar".to_string(),
237 y_num: 50,
238 y_denom: 1,
239 },
240 ],
241 }],
242 notes: vec![],
243 };
244 let out = render_text(&spec, 30);
245 assert!(out.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)));
247 }
248
249 #[test]
250 fn truncate_left_keeps_tail() {
251 assert_eq!(truncate_left("Expenses:Food", 5), ":Food");
252 assert_eq!(truncate_left("Food", 10), "Food");
253 }
254}