1use crate::content_renderer::{ContentRenderer, RendererCapabilities};
8use shape_value::content::{ChartSpec, ContentNode, ContentTable};
9use std::fmt::Write;
10
11pub struct PlainRenderer;
13
14impl ContentRenderer for PlainRenderer {
15 fn capabilities(&self) -> RendererCapabilities {
16 RendererCapabilities::plain()
17 }
18
19 fn render(&self, content: &ContentNode) -> String {
20 render_node(content)
21 }
22}
23
24fn render_node(node: &ContentNode) -> String {
25 match node {
26 ContentNode::Text(st) => {
27 let mut out = String::new();
28 for span in &st.spans {
29 out.push_str(&span.text);
30 }
31 out
32 }
33 ContentNode::Table(table) => render_table(table),
34 ContentNode::Code { language, source } => render_code(language.as_deref(), source),
35 ContentNode::Chart(spec) => render_chart(spec),
36 ContentNode::KeyValue(pairs) => render_key_value(pairs),
37 ContentNode::Fragment(parts) => parts.iter().map(render_node).collect(),
38 }
39}
40
41fn render_table(table: &ContentTable) -> String {
42 let col_count = table.headers.len();
43 let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len()).collect();
44
45 let limit = table.max_rows.unwrap_or(table.rows.len());
46 let display_rows = &table.rows[..limit.min(table.rows.len())];
47 let truncated = table.rows.len().saturating_sub(limit);
48
49 for row in display_rows {
50 for (i, cell) in row.iter().enumerate() {
51 if i < col_count {
52 let cell_text = cell.to_string();
53 if cell_text.len() > widths[i] {
54 widths[i] = cell_text.len();
55 }
56 }
57 }
58 }
59
60 let mut out = String::new();
61
62 write_ascii_border(&mut out, &widths);
64
65 let _ = write!(out, "|");
67 for (i, header) in table.headers.iter().enumerate() {
68 let _ = write!(out, " {:width$} |", header, width = widths[i]);
69 }
70 let _ = writeln!(out);
71
72 write_ascii_border(&mut out, &widths);
74
75 for row in display_rows {
77 let _ = write!(out, "|");
78 for i in 0..col_count {
79 let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
80 let _ = write!(out, " {:width$} |", cell_text, width = widths[i]);
81 }
82 let _ = writeln!(out);
83 }
84
85 if truncated > 0 {
87 let _ = write!(out, "|");
88 let msg = format!("... {} more rows", truncated);
89 let total_width: usize = widths.iter().sum::<usize>() + (col_count - 1) * 3 + 2;
90 let _ = write!(out, " {:width$} |", msg, width = total_width);
91 let _ = writeln!(out);
92 }
93
94 write_ascii_border(&mut out, &widths);
96
97 out
98}
99
100fn write_ascii_border(out: &mut String, widths: &[usize]) {
101 let _ = write!(out, "+");
102 for w in widths {
103 for _ in 0..(w + 2) {
104 out.push('-');
105 }
106 out.push('+');
107 }
108 let _ = writeln!(out);
109}
110
111fn render_code(language: Option<&str>, source: &str) -> String {
112 let mut out = String::new();
113 if let Some(lang) = language {
114 let _ = writeln!(out, "[{}]", lang);
115 }
116 for line in source.lines() {
117 let _ = writeln!(out, " {}", line);
118 }
119 out
120}
121
122fn render_chart(spec: &ChartSpec) -> String {
123 let title = spec.title.as_deref().unwrap_or("untitled");
124 let type_name = chart_type_display_name(spec.chart_type);
125 let y_count = spec.channels_by_name("y").len();
126 format!(
127 "[{} Chart: {} ({} series)]\n",
128 type_name, title, y_count
129 )
130}
131
132fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
133 use shape_value::content::ChartType;
134 match ct {
135 ChartType::Line => "Line",
136 ChartType::Bar => "Bar",
137 ChartType::Scatter => "Scatter",
138 ChartType::Area => "Area",
139 ChartType::Candlestick => "Candlestick",
140 ChartType::Histogram => "Histogram",
141 ChartType::BoxPlot => "BoxPlot",
142 ChartType::Heatmap => "Heatmap",
143 ChartType::Bubble => "Bubble",
144 }
145}
146
147fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
148 if pairs.is_empty() {
149 return String::new();
150 }
151 let max_key_len = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
152 let mut out = String::new();
153 for (key, value) in pairs {
154 let value_str = render_node(value);
155 let _ = writeln!(out, "{:width$} {}", key, value_str, width = max_key_len);
156 }
157 out
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
164
165 fn renderer() -> PlainRenderer {
166 PlainRenderer
167 }
168
169 #[test]
170 fn test_plain_text() {
171 let node = ContentNode::plain("hello world");
172 let output = renderer().render(&node);
173 assert_eq!(output, "hello world");
174 }
175
176 #[test]
177 fn test_styled_text_strips_styles() {
178 let node = ContentNode::plain("styled")
179 .with_bold()
180 .with_fg(Color::Named(NamedColor::Red));
181 let output = renderer().render(&node);
182 assert!(!output.contains("\x1b["));
184 assert_eq!(output, "styled");
185 }
186
187 #[test]
188 fn test_ascii_table() {
189 let table = ContentNode::Table(ContentTable {
190 headers: vec!["Name".into(), "Age".into()],
191 rows: vec![
192 vec![ContentNode::plain("Alice"), ContentNode::plain("30")],
193 vec![ContentNode::plain("Bob"), ContentNode::plain("25")],
194 ],
195 border: BorderStyle::Rounded, max_rows: None,
197 column_types: None,
198 total_rows: None,
199 sortable: false,
200 });
201 let output = renderer().render(&table);
202 assert!(output.contains("+-------+-----+"));
203 assert!(output.contains("| Alice | 30 |"));
204 assert!(output.contains("| Bob | 25 |"));
205 }
206
207 #[test]
208 fn test_ascii_table_max_rows() {
209 let table = ContentNode::Table(ContentTable {
210 headers: vec!["X".into()],
211 rows: vec![
212 vec![ContentNode::plain("1")],
213 vec![ContentNode::plain("2")],
214 vec![ContentNode::plain("3")],
215 ],
216 border: BorderStyle::default(),
217 max_rows: Some(1),
218 column_types: None,
219 total_rows: None,
220 sortable: false,
221 });
222 let output = renderer().render(&table);
223 assert!(output.contains("| 1 |"));
224 assert!(output.contains("... 2 more rows"));
225 assert!(!output.contains("| 3 |"));
226 }
227
228 #[test]
229 fn test_code_block_with_language() {
230 let code = ContentNode::Code {
231 language: Some("python".into()),
232 source: "print(\"hi\")".into(),
233 };
234 let output = renderer().render(&code);
235 assert!(output.contains("[python]"));
236 assert!(output.contains(" print(\"hi\")"));
237 assert!(!output.contains("\x1b["));
238 }
239
240 #[test]
241 fn test_code_block_no_language() {
242 let code = ContentNode::Code {
243 language: None,
244 source: "hello".into(),
245 };
246 let output = renderer().render(&code);
247 assert!(!output.contains("["));
248 assert!(output.contains(" hello"));
249 }
250
251 #[test]
252 fn test_chart_placeholder() {
253 let chart = ContentNode::Chart(shape_value::content::ChartSpec {
254 chart_type: shape_value::content::ChartType::Bar,
255 channels: vec![],
256 x_categories: None,
257 title: Some("Sales".into()),
258 x_label: None,
259 y_label: None,
260 width: None,
261 height: None,
262 echarts_options: None,
263 interactive: true,
264 });
265 let output = renderer().render(&chart);
266 assert_eq!(output, "[Bar Chart: Sales (0 series)]\n");
267 }
268
269 #[test]
270 fn test_key_value() {
271 let kv = ContentNode::KeyValue(vec![
272 ("name".into(), ContentNode::plain("Alice")),
273 ("age".into(), ContentNode::plain("30")),
274 ]);
275 let output = renderer().render(&kv);
276 assert!(output.contains("name"));
277 assert!(output.contains("Alice"));
278 assert!(output.contains("age"));
279 assert!(output.contains("30"));
280 assert!(!output.contains("\x1b["));
281 }
282
283 #[test]
284 fn test_fragment() {
285 let frag = ContentNode::Fragment(vec![
286 ContentNode::plain("hello "),
287 ContentNode::plain("world"),
288 ]);
289 let output = renderer().render(&frag);
290 assert_eq!(output, "hello world");
291 }
292
293 #[test]
294 fn test_no_ansi_in_any_output() {
295 let complex = ContentNode::Fragment(vec![
297 ContentNode::plain("text")
298 .with_bold()
299 .with_fg(Color::Named(NamedColor::Red)),
300 ContentNode::Table(ContentTable {
301 headers: vec!["H".into()],
302 rows: vec![vec![ContentNode::plain("v")]],
303 border: BorderStyle::default(),
304 max_rows: None,
305 column_types: None,
306 total_rows: None,
307 sortable: false,
308 }),
309 ContentNode::Code {
310 language: Some("js".into()),
311 source: "1+1".into(),
312 },
313 ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]),
314 ]);
315 let output = renderer().render(&complex);
316 assert!(
317 !output.contains("\x1b["),
318 "Plain renderer must not emit ANSI codes"
319 );
320 }
321}