Skip to main content

surf_parse/
render_term.rs

1//! ANSI terminal renderer.
2//!
3//! Produces colored terminal output using the `colored` crate. Each block type
4//! gets a distinctive visual treatment suitable for CLI display.
5
6use colored::Colorize;
7
8use crate::types::{Block, CalloutType, DecisionStatus, SurfDoc, Trend};
9
10/// Render a `SurfDoc` as ANSI-colored terminal text.
11pub fn to_terminal(doc: &SurfDoc) -> String {
12    let mut parts: Vec<String> = Vec::new();
13
14    for block in &doc.blocks {
15        parts.push(render_block(block));
16    }
17
18    parts.join("\n\n")
19}
20
21fn render_block(block: &Block) -> String {
22    match block {
23        Block::Markdown { content, .. } => content.clone(),
24
25        Block::Callout {
26            callout_type,
27            title,
28            content,
29            ..
30        } => {
31            let (border_color, type_label) = callout_style(*callout_type);
32            let border = apply_color("\u{2502}", border_color); // │
33            let label = format!("{}", type_label.bold());
34            let title_part = match title {
35                Some(t) => format!(": {t}"),
36                None => String::new(),
37            };
38            let mut lines = vec![format!("{border} {label}{title_part}")];
39            for line in content.lines() {
40                lines.push(format!("{border} {line}"));
41            }
42            lines.join("\n")
43        }
44
45        Block::Data {
46            headers, rows, ..
47        } => {
48            if headers.is_empty() {
49                return String::new();
50            }
51
52            // Calculate column widths
53            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
54            for row in rows {
55                for (i, cell) in row.iter().enumerate() {
56                    if i < widths.len() {
57                        widths[i] = widths[i].max(cell.len());
58                    }
59                }
60            }
61
62            let separator: String = widths
63                .iter()
64                .map(|&w| "\u{2500}".repeat(w + 2)) // ─
65                .collect::<Vec<_>>()
66                .join("\u{253C}"); // ┼
67
68            // Header row (bold)
69            let header_cells: Vec<String> = headers
70                .iter()
71                .enumerate()
72                .map(|(i, h)| format!(" {:width$} ", h, width = widths[i]))
73                .collect();
74            let header_line = format!(
75                "\u{2502}{}\u{2502}",
76                header_cells.join("\u{2502}")
77            );
78
79            let mut lines = vec![
80                format!("{}", header_line.bold()),
81                format!("\u{2502}{separator}\u{2502}"),
82            ];
83
84            for row in rows {
85                let cells: Vec<String> = row
86                    .iter()
87                    .enumerate()
88                    .map(|(i, c)| {
89                        let w = widths.get(i).copied().unwrap_or(c.len());
90                        format!(" {:width$} ", c, width = w)
91                    })
92                    .collect();
93                lines.push(format!(
94                    "\u{2502}{}\u{2502}",
95                    cells.join("\u{2502}")
96                ));
97            }
98            lines.join("\n")
99        }
100
101        Block::Code {
102            lang, content, ..
103        } => {
104            let lang_label = match lang {
105                Some(l) => format!(" {}", l.dimmed()),
106                None => String::new(),
107            };
108            let border = format!("{}", "\u{2500}\u{2500}\u{2500}".dimmed()); // ───
109            let mut lines = vec![format!("{border}{lang_label}")];
110            for line in content.lines() {
111                lines.push(format!("  {line}"));
112            }
113            lines.push(border.clone());
114            lines.join("\n")
115        }
116
117        Block::Tasks { items, .. } => {
118            let lines: Vec<String> = items
119                .iter()
120                .map(|item| {
121                    if item.done {
122                        let check = format!("{}", "\u{2713}".green()); // ✓
123                        let text = format!("{}", item.text.strikethrough().green());
124                        let assignee = match &item.assignee {
125                            Some(a) => format!(" {}", format!("@{a}").dimmed()),
126                            None => String::new(),
127                        };
128                        format!("{check} {text}{assignee}")
129                    } else {
130                        let check = "\u{2610}"; // ☐
131                        let assignee = match &item.assignee {
132                            Some(a) => format!(" {}", format!("@{a}").dimmed()),
133                            None => String::new(),
134                        };
135                        format!("{check} {}{assignee}", item.text)
136                    }
137                })
138                .collect();
139            lines.join("\n")
140        }
141
142        Block::Decision {
143            status,
144            date,
145            content,
146            ..
147        } => {
148            let badge = decision_badge(*status);
149            let label = format!("{}", "Decision".bold());
150            let date_part = match date {
151                Some(d) => format!(" ({d})"),
152                None => String::new(),
153            };
154            format!("{badge} {label}{date_part}\n{content}")
155        }
156
157        Block::Metric {
158            label,
159            value,
160            trend,
161            unit,
162            ..
163        } => {
164            let label_str = format!("{}", label.bold());
165            let value_str = format!("{}", value.bold());
166            let unit_part = match unit {
167                Some(u) => format!(" {u}"),
168                None => String::new(),
169            };
170            let trend_part = match trend {
171                Some(Trend::Up) => format!(" {}", "\u{2191}".green()),
172                Some(Trend::Down) => format!(" {}", "\u{2193}".red()),
173                Some(Trend::Flat) => format!(" {}", "\u{2192}".dimmed()),
174                None => String::new(),
175            };
176            format!("{label_str}: {value_str}{unit_part}{trend_part}")
177        }
178
179        Block::Summary { content, .. } => {
180            let border = format!("{}", "\u{2502}".cyan()); // │
181            let lines: Vec<String> = content
182                .lines()
183                .map(|l| format!("{border} {}", l.italic()))
184                .collect();
185            lines.join("\n")
186        }
187
188        Block::Figure {
189            src, caption, ..
190        } => {
191            let cap = caption.as_deref().unwrap_or("Image");
192            format!("{}", format!("[Figure: {cap}] ({src})").dimmed())
193        }
194
195        Block::Tabs { tabs, .. } => {
196            let mut parts = Vec::new();
197            for (i, tab) in tabs.iter().enumerate() {
198                let label = format!("{}", format!("[Tab {}] {}", i + 1, tab.label).bold());
199                parts.push(format!("{label}\n{}", tab.content));
200            }
201            parts.join("\n\n")
202        }
203
204        Block::Columns { columns, .. } => {
205            let parts: Vec<String> = columns
206                .iter()
207                .enumerate()
208                .map(|(i, col)| {
209                    let label = format!("{}", format!("[Col {}]", i + 1).dimmed());
210                    format!("{label}\n{}", col.content)
211                })
212                .collect();
213            parts.join("\n\n")
214        }
215
216        Block::Quote {
217            content,
218            attribution,
219            ..
220        } => {
221            let border = format!("{}", "\u{2502}".dimmed()); // │
222            let mut lines: Vec<String> = content
223                .lines()
224                .map(|l| format!("{border} {}", l.italic()))
225                .collect();
226            if let Some(attr) = attribution {
227                lines.push(format!("{border} {}", format!("\u{2014} {attr}").dimmed()));
228            }
229            lines.join("\n")
230        }
231
232        Block::Cta {
233            label, href, primary, ..
234        } => {
235            let badge = if *primary {
236                format!("{}", "[CTA]".blue().bold())
237            } else {
238                format!("{}", "[CTA]".dimmed())
239            };
240            format!("{badge} {} ({href})", label.bold())
241        }
242
243        Block::HeroImage { src, alt, .. } => {
244            let desc = alt.as_deref().unwrap_or("Hero image");
245            format!("{}", format!("[Hero: {desc}] ({src})").dimmed())
246        }
247
248        Block::Testimonial {
249            content,
250            author,
251            role,
252            company,
253            ..
254        } => {
255            let border = format!("{}", "\u{2502}".dimmed()); // │
256            let mut lines: Vec<String> = content
257                .lines()
258                .map(|l| format!("{border} {}", l.italic()))
259                .collect();
260            let details: Vec<&str> = [author.as_deref(), role.as_deref(), company.as_deref()]
261                .iter()
262                .filter_map(|v| *v)
263                .collect();
264            if !details.is_empty() {
265                lines.push(format!("{border} {}", format!("\u{2014} {}", details.join(", ")).dimmed()));
266            }
267            lines.join("\n")
268        }
269
270        Block::Style { properties, .. } => {
271            if properties.is_empty() {
272                format!("{}", "[Style: empty]".dimmed())
273            } else {
274                let pairs: Vec<String> = properties
275                    .iter()
276                    .map(|p| format!("  {}: {}", p.key.bold(), p.value))
277                    .collect();
278                format!("{}\n{}", "[Style]".dimmed(), pairs.join("\n"))
279            }
280        }
281
282        Block::Faq { items, .. } => {
283            let mut parts = Vec::new();
284            for (i, item) in items.iter().enumerate() {
285                let q = format!("{}", format!("Q{}: {}", i + 1, item.question).bold());
286                parts.push(format!("{q}\n  {}", item.answer));
287            }
288            parts.join("\n\n")
289        }
290
291        Block::PricingTable {
292            headers, rows, ..
293        } => {
294            // Reuse the same table rendering as Data blocks
295            if headers.is_empty() {
296                return String::new();
297            }
298
299            let label = format!("{}", "[Pricing]".bold().cyan());
300            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
301            for row in rows {
302                for (i, cell) in row.iter().enumerate() {
303                    if i < widths.len() {
304                        widths[i] = widths[i].max(cell.len());
305                    }
306                }
307            }
308
309            let separator: String = widths
310                .iter()
311                .map(|&w| "\u{2500}".repeat(w + 2))
312                .collect::<Vec<_>>()
313                .join("\u{253C}");
314
315            let header_cells: Vec<String> = headers
316                .iter()
317                .enumerate()
318                .map(|(i, h)| format!(" {:width$} ", h, width = widths[i]))
319                .collect();
320            let header_line = format!(
321                "\u{2502}{}\u{2502}",
322                header_cells.join("\u{2502}")
323            );
324
325            let mut lines = vec![
326                label,
327                format!("{}", header_line.bold()),
328                format!("\u{2502}{separator}\u{2502}"),
329            ];
330
331            for row in rows {
332                let cells: Vec<String> = row
333                    .iter()
334                    .enumerate()
335                    .map(|(i, c)| {
336                        let w = widths.get(i).copied().unwrap_or(c.len());
337                        format!(" {:width$} ", c, width = w)
338                    })
339                    .collect();
340                lines.push(format!(
341                    "\u{2502}{}\u{2502}",
342                    cells.join("\u{2502}")
343                ));
344            }
345            lines.join("\n")
346        }
347
348        Block::Site { domain, properties, .. } => {
349            let label = format!("{}", "[Site Config]".bold().cyan());
350            let mut lines = vec![label];
351            if let Some(d) = domain {
352                lines.push(format!("  {}: {}", "domain".bold(), d));
353            }
354            for p in properties {
355                lines.push(format!("  {}: {}", p.key.bold(), p.value));
356            }
357            lines.join("\n")
358        }
359
360        Block::Page {
361            route,
362            layout,
363            children,
364            content,
365            ..
366        } => {
367            let layout_part = match layout {
368                Some(l) => format!(" layout={l}"),
369                None => String::new(),
370            };
371            let label = format!("{}", format!("[Page {route}{layout_part}]").bold().cyan());
372            if children.is_empty() {
373                if content.is_empty() {
374                    label
375                } else {
376                    format!("{label}\n{content}")
377                }
378            } else {
379                let child_output: Vec<String> = children.iter().map(render_block).collect();
380                format!("{label}\n{}", child_output.join("\n\n"))
381            }
382        }
383
384        Block::Unknown {
385            name, content, ..
386        } => {
387            let label = format!("{}", format!("[{name}]").dimmed());
388            if content.is_empty() {
389                label
390            } else {
391                format!("{label}\n{content}")
392            }
393        }
394    }
395}
396
397fn callout_style(ct: CalloutType) -> (&'static str, &'static str) {
398    match ct {
399        CalloutType::Warning => ("yellow", "Warning"),
400        CalloutType::Danger => ("red", "Danger"),
401        CalloutType::Info => ("blue", "Info"),
402        CalloutType::Tip => ("green", "Tip"),
403        CalloutType::Note => ("cyan", "Note"),
404        CalloutType::Success => ("green", "Success"),
405    }
406}
407
408fn apply_color(text: &str, color: &str) -> String {
409    match color {
410        "yellow" => format!("{}", text.yellow()),
411        "red" => format!("{}", text.red()),
412        "blue" => format!("{}", text.blue()),
413        "green" => format!("{}", text.green()),
414        "cyan" => format!("{}", text.cyan()),
415        _ => text.to_string(),
416    }
417}
418
419fn decision_badge(status: DecisionStatus) -> String {
420    match status {
421        DecisionStatus::Accepted => format!("{}", "[ACCEPTED]".green()),
422        DecisionStatus::Rejected => format!("{}", "[REJECTED]".red()),
423        DecisionStatus::Proposed => format!("{}", "[PROPOSED]".yellow()),
424        DecisionStatus::Superseded => format!("{}", "[SUPERSEDED]".dimmed()),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::types::*;
432
433    fn span() -> Span {
434        Span {
435            start_line: 1,
436            end_line: 1,
437            start_offset: 0,
438            end_offset: 0,
439        }
440    }
441
442    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
443        SurfDoc {
444            front_matter: None,
445            blocks,
446            source: String::new(),
447        }
448    }
449
450    #[test]
451    fn term_callout_has_color() {
452        // Force colors on — the colored crate disables them when stdout is not a tty.
453        colored::control::set_override(true);
454
455        let doc = doc_with(vec![Block::Callout {
456            callout_type: CalloutType::Warning,
457            title: None,
458            content: "Watch out!".into(),
459            span: span(),
460        }]);
461        let output = to_terminal(&doc);
462        // ANSI escape codes start with \x1b[
463        assert!(
464            output.contains("\x1b["),
465            "Terminal output should contain ANSI escape codes, got: {output:?}"
466        );
467        assert!(output.contains("Watch out!"));
468
469        colored::control::unset_override();
470    }
471
472    #[test]
473    fn term_tasks_symbols() {
474        let doc = doc_with(vec![Block::Tasks {
475            items: vec![
476                TaskItem {
477                    done: true,
478                    text: "Done".into(),
479                    assignee: None,
480                },
481                TaskItem {
482                    done: false,
483                    text: "Pending".into(),
484                    assignee: None,
485                },
486            ],
487            span: span(),
488        }]);
489        let output = to_terminal(&doc);
490        assert!(output.contains("\u{2713}"), "Should contain checkmark"); // ✓
491        assert!(output.contains("\u{2610}"), "Should contain empty checkbox"); // ☐
492    }
493
494    #[test]
495    fn term_metric_trend() {
496        let doc = doc_with(vec![
497            Block::Metric {
498                label: "MRR".into(),
499                value: "$2K".into(),
500                trend: Some(Trend::Up),
501                unit: None,
502                span: span(),
503            },
504            Block::Metric {
505                label: "Churn".into(),
506                value: "5%".into(),
507                trend: Some(Trend::Down),
508                unit: None,
509                span: span(),
510            },
511        ]);
512        let output = to_terminal(&doc);
513        assert!(output.contains("\u{2191}"), "Should contain up arrow"); // ↑
514        assert!(output.contains("\u{2193}"), "Should contain down arrow"); // ↓
515    }
516}