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!("[{} Chart: {} ({} series)]\n", type_name, title, y_count)
127}
128
129fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
130 use shape_value::content::ChartType;
131 match ct {
132 ChartType::Line => "Line",
133 ChartType::Bar => "Bar",
134 ChartType::Scatter => "Scatter",
135 ChartType::Area => "Area",
136 ChartType::Candlestick => "Candlestick",
137 ChartType::Histogram => "Histogram",
138 ChartType::BoxPlot => "BoxPlot",
139 ChartType::Heatmap => "Heatmap",
140 ChartType::Bubble => "Bubble",
141 }
142}
143
144fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
145 if pairs.is_empty() {
146 return String::new();
147 }
148 let max_key_len = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
149 let mut out = String::new();
150 for (key, value) in pairs {
151 let value_str = render_node(value);
152 let _ = writeln!(out, "{:width$} {}", key, value_str, width = max_key_len);
153 }
154 out
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
161
162 fn renderer() -> PlainRenderer {
163 PlainRenderer
164 }
165
166 #[test]
167 fn test_plain_text() {
168 let node = ContentNode::plain("hello world");
169 let output = renderer().render(&node);
170 assert_eq!(output, "hello world");
171 }
172
173 #[test]
174 fn test_styled_text_strips_styles() {
175 let node = ContentNode::plain("styled")
176 .with_bold()
177 .with_fg(Color::Named(NamedColor::Red));
178 let output = renderer().render(&node);
179 assert!(!output.contains("\x1b["));
181 assert_eq!(output, "styled");
182 }
183
184 #[test]
185 fn test_ascii_table() {
186 let table = ContentNode::Table(ContentTable {
187 headers: vec!["Name".into(), "Age".into()],
188 rows: vec![
189 vec![ContentNode::plain("Alice"), ContentNode::plain("30")],
190 vec![ContentNode::plain("Bob"), ContentNode::plain("25")],
191 ],
192 border: BorderStyle::Rounded, max_rows: None,
194 column_types: None,
195 total_rows: None,
196 sortable: false,
197 });
198 let output = renderer().render(&table);
199 assert!(output.contains("+-------+-----+"));
200 assert!(output.contains("| Alice | 30 |"));
201 assert!(output.contains("| Bob | 25 |"));
202 }
203
204 #[test]
205 fn test_ascii_table_max_rows() {
206 let table = ContentNode::Table(ContentTable {
207 headers: vec!["X".into()],
208 rows: vec![
209 vec![ContentNode::plain("1")],
210 vec![ContentNode::plain("2")],
211 vec![ContentNode::plain("3")],
212 ],
213 border: BorderStyle::default(),
214 max_rows: Some(1),
215 column_types: None,
216 total_rows: None,
217 sortable: false,
218 });
219 let output = renderer().render(&table);
220 assert!(output.contains("| 1 |"));
221 assert!(output.contains("... 2 more rows"));
222 assert!(!output.contains("| 3 |"));
223 }
224
225 #[test]
226 fn test_code_block_with_language() {
227 let code = ContentNode::Code {
228 language: Some("python".into()),
229 source: "print(\"hi\")".into(),
230 };
231 let output = renderer().render(&code);
232 assert!(output.contains("[python]"));
233 assert!(output.contains(" print(\"hi\")"));
234 assert!(!output.contains("\x1b["));
235 }
236
237 #[test]
238 fn test_code_block_no_language() {
239 let code = ContentNode::Code {
240 language: None,
241 source: "hello".into(),
242 };
243 let output = renderer().render(&code);
244 assert!(!output.contains("["));
245 assert!(output.contains(" hello"));
246 }
247
248 #[test]
249 fn test_chart_placeholder() {
250 let chart = ContentNode::Chart(shape_value::content::ChartSpec {
251 chart_type: shape_value::content::ChartType::Bar,
252 channels: vec![],
253 x_categories: None,
254 title: Some("Sales".into()),
255 x_label: None,
256 y_label: None,
257 width: None,
258 height: None,
259 echarts_options: None,
260 interactive: true,
261 });
262 let output = renderer().render(&chart);
263 assert_eq!(output, "[Bar Chart: Sales (0 series)]\n");
264 }
265
266 #[test]
267 fn test_key_value() {
268 let kv = ContentNode::KeyValue(vec![
269 ("name".into(), ContentNode::plain("Alice")),
270 ("age".into(), ContentNode::plain("30")),
271 ]);
272 let output = renderer().render(&kv);
273 assert!(output.contains("name"));
274 assert!(output.contains("Alice"));
275 assert!(output.contains("age"));
276 assert!(output.contains("30"));
277 assert!(!output.contains("\x1b["));
278 }
279
280 #[test]
281 fn test_fragment() {
282 let frag = ContentNode::Fragment(vec![
283 ContentNode::plain("hello "),
284 ContentNode::plain("world"),
285 ]);
286 let output = renderer().render(&frag);
287 assert_eq!(output, "hello world");
288 }
289
290 #[test]
291 fn test_no_ansi_in_any_output() {
292 let complex = ContentNode::Fragment(vec![
294 ContentNode::plain("text")
295 .with_bold()
296 .with_fg(Color::Named(NamedColor::Red)),
297 ContentNode::Table(ContentTable {
298 headers: vec!["H".into()],
299 rows: vec![vec![ContentNode::plain("v")]],
300 border: BorderStyle::default(),
301 max_rows: None,
302 column_types: None,
303 total_rows: None,
304 sortable: false,
305 }),
306 ContentNode::Code {
307 language: Some("js".into()),
308 source: "1+1".into(),
309 },
310 ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]),
311 ]);
312 let output = renderer().render(&complex);
313 assert!(
314 !output.contains("\x1b["),
315 "Plain renderer must not emit ANSI codes"
316 );
317 }
318}