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