1use crate::content_renderer::{ContentRenderer, RendererCapabilities};
11use shape_value::content::{ChartSpec, ContentNode, ContentTable};
12use std::fmt::Write;
13
14pub struct MarkdownRenderer;
16
17impl ContentRenderer for MarkdownRenderer {
18 fn capabilities(&self) -> RendererCapabilities {
19 RendererCapabilities::markdown()
20 }
21
22 fn render(&self, content: &ContentNode) -> String {
23 render_node(content)
24 }
25}
26
27fn render_node(node: &ContentNode) -> String {
28 match node {
29 ContentNode::Text(st) => {
30 let mut out = String::new();
31 for span in &st.spans {
32 let mut text = span.text.clone();
33 if span.style.bold {
34 text = format!("**{}**", text);
35 }
36 if span.style.italic {
37 text = format!("*{}*", text);
38 }
39 out.push_str(&text);
40 }
41 out
42 }
43 ContentNode::Table(table) => render_table(table),
44 ContentNode::Code { language, source } => render_code(language.as_deref(), source),
45 ContentNode::Chart(spec) => render_chart(spec),
46 ContentNode::KeyValue(pairs) => render_key_value(pairs),
47 ContentNode::Fragment(parts) => parts.iter().map(render_node).collect(),
48 }
49}
50
51fn render_table(table: &ContentTable) -> String {
52 let col_count = table.headers.len();
53 if col_count == 0 {
54 return String::new();
55 }
56
57 let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len().max(3)).collect();
58
59 let limit = table.max_rows.unwrap_or(table.rows.len());
60 let display_rows = &table.rows[..limit.min(table.rows.len())];
61 let truncated = table.rows.len().saturating_sub(limit);
62
63 for row in display_rows {
64 for (i, cell) in row.iter().enumerate() {
65 if i < col_count {
66 let cell_text = cell.to_string();
67 widths[i] = widths[i].max(cell_text.len());
68 }
69 }
70 }
71
72 let mut out = String::new();
73
74 out.push('|');
76 for (i, header) in table.headers.iter().enumerate() {
77 let _ = write!(out, " {:width$} |", header, width = widths[i]);
78 }
79 let _ = writeln!(out);
80
81 out.push('|');
83 for w in &widths {
84 out.push(' ');
85 for _ in 0..*w {
86 out.push('-');
87 }
88 out.push_str(" |");
89 }
90 let _ = writeln!(out);
91
92 for row in display_rows {
94 out.push('|');
95 for i in 0..col_count {
96 let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
97 let _ = write!(out, " {:width$} |", cell_text, width = widths[i]);
98 }
99 let _ = writeln!(out);
100 }
101
102 if truncated > 0 {
103 let _ = writeln!(out, "\n*... {} more rows*", truncated);
104 }
105
106 out
107}
108
109fn render_code(language: Option<&str>, source: &str) -> String {
110 let lang = language.unwrap_or("");
111 format!("```{}\n{}\n```\n", lang, source)
112}
113
114fn render_chart(spec: &ChartSpec) -> String {
115 let title = spec.title.as_deref().unwrap_or("untitled");
116 let type_name = chart_type_display_name(spec.chart_type);
117 let y_count = spec.channels_by_name("y").len();
118 format!(
119 "*[{} Chart: {} ({} series)]*\n",
120 type_name, title, y_count
121 )
122}
123
124fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
125 use shape_value::content::ChartType;
126 match ct {
127 ChartType::Line => "Line",
128 ChartType::Bar => "Bar",
129 ChartType::Scatter => "Scatter",
130 ChartType::Area => "Area",
131 ChartType::Candlestick => "Candlestick",
132 ChartType::Histogram => "Histogram",
133 ChartType::BoxPlot => "BoxPlot",
134 ChartType::Heatmap => "Heatmap",
135 ChartType::Bubble => "Bubble",
136 }
137}
138
139fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
140 let mut out = String::new();
141 for (key, value) in pairs {
142 let _ = writeln!(out, "**{}**: {}", key, render_node(value));
143 }
144 out
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
151
152 fn renderer() -> MarkdownRenderer {
153 MarkdownRenderer
154 }
155
156 #[test]
157 fn test_plain_text_md() {
158 let node = ContentNode::plain("hello");
159 assert_eq!(renderer().render(&node), "hello");
160 }
161
162 #[test]
163 fn test_bold_text_md() {
164 let node = ContentNode::plain("bold").with_bold();
165 let output = renderer().render(&node);
166 assert_eq!(output, "**bold**");
167 }
168
169 #[test]
170 fn test_italic_text_md() {
171 let node = ContentNode::plain("italic").with_italic();
172 let output = renderer().render(&node);
173 assert_eq!(output, "*italic*");
174 }
175
176 #[test]
177 fn test_bold_italic_md() {
178 let node = ContentNode::plain("both").with_bold().with_italic();
179 let output = renderer().render(&node);
180 assert_eq!(output, "***both***");
181 }
182
183 #[test]
184 fn test_gfm_table() {
185 let table = ContentNode::Table(ContentTable {
186 headers: vec!["Name".into(), "Age".into()],
187 rows: vec![vec![ContentNode::plain("Alice"), ContentNode::plain("30")]],
188 border: BorderStyle::default(),
189 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("| Name"));
196 assert!(output.contains("| ---"));
197 assert!(output.contains("| Alice"));
198 }
199
200 #[test]
201 fn test_gfm_table_truncation() {
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("*... 2 more rows*"));
217 }
218
219 #[test]
220 fn test_fenced_code_block() {
221 let code = ContentNode::Code {
222 language: Some("rust".into()),
223 source: "fn main() {}".into(),
224 };
225 let output = renderer().render(&code);
226 assert!(output.starts_with("```rust\n"));
227 assert!(output.contains("fn main() {}"));
228 assert!(output.contains("```"));
229 }
230
231 #[test]
232 fn test_code_block_no_language() {
233 let code = ContentNode::Code {
234 language: None,
235 source: "hello".into(),
236 };
237 let output = renderer().render(&code);
238 assert!(output.starts_with("```\n"));
239 }
240
241 #[test]
242 fn test_kv_md() {
243 let kv = ContentNode::KeyValue(vec![("name".into(), ContentNode::plain("Alice"))]);
244 let output = renderer().render(&kv);
245 assert!(output.contains("**name**: Alice"));
246 }
247
248 #[test]
249 fn test_chart_placeholder_md() {
250 let chart = ContentNode::Chart(shape_value::content::ChartSpec {
251 chart_type: shape_value::content::ChartType::Line,
252 channels: vec![],
253 x_categories: None,
254 title: Some("Revenue".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!(output.contains("Line Chart: Revenue"));
264 }
265
266 #[test]
267 fn test_fragment_md() {
268 let frag = ContentNode::Fragment(vec![
269 ContentNode::plain("hello "),
270 ContentNode::plain("world"),
271 ]);
272 assert_eq!(renderer().render(&frag), "hello world");
273 }
274
275 #[test]
276 fn test_no_ansi_in_md() {
277 let node = ContentNode::plain("colored").with_fg(Color::Named(NamedColor::Red));
278 let output = renderer().render(&node);
279 assert!(!output.contains("\x1b["));
280 }
281}