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 _cite_scope = crate::citation::install_context(crate::citation::build_context(
13        &doc.blocks,
14        doc.front_matter.as_ref().and_then(|fm| fm.format),
15    ));
16    let mut parts: Vec<String> = Vec::new();
17
18    for block in &doc.blocks {
19        parts.push(render_block(block));
20    }
21
22    parts.join("\n\n")
23}
24
25fn render_block(block: &Block) -> String {
26    match block {
27        Block::Markdown { content, .. } => {
28            render_markdown_content(&crate::citation::substitute_text_cites(content))
29        }
30
31        Block::Callout {
32            callout_type,
33            title,
34            content,
35            ..
36        } => {
37            let (border_color, type_label) = callout_style(*callout_type);
38            let border = apply_color("\u{2502}", border_color); // │
39            let label = format!("{}", type_label.bold());
40            let title_part = match title {
41                Some(t) => format!(": {t}"),
42                None => String::new(),
43            };
44            let mut lines = vec![format!("{border} {label}{title_part}")];
45            for line in content.lines() {
46                lines.push(format!("{border} {line}"));
47            }
48            lines.join("\n")
49        }
50
51        Block::Data {
52            headers, rows, ..
53        } => {
54            if headers.is_empty() {
55                return String::new();
56            }
57
58            // Calculate column widths
59            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
60            for row in rows {
61                for (i, cell) in row.iter().enumerate() {
62                    if i < widths.len() {
63                        widths[i] = widths[i].max(cell.len());
64                    }
65                }
66            }
67
68            let separator: String = widths
69                .iter()
70                .map(|&w| "\u{2500}".repeat(w + 2)) // ─
71                .collect::<Vec<_>>()
72                .join("\u{253C}"); // ┼
73
74            // Header row (bold)
75            let header_cells: Vec<String> = headers
76                .iter()
77                .enumerate()
78                .map(|(i, h)| format!(" {:width$} ", h, width = widths[i]))
79                .collect();
80            let header_line = format!(
81                "\u{2502}{}\u{2502}",
82                header_cells.join("\u{2502}")
83            );
84
85            let mut lines = vec![
86                format!("{}", header_line.bold()),
87                format!("\u{2502}{separator}\u{2502}"),
88            ];
89
90            for row in rows {
91                let cells: Vec<String> = row
92                    .iter()
93                    .enumerate()
94                    .map(|(i, c)| {
95                        let w = widths.get(i).copied().unwrap_or(c.len());
96                        format!(" {:width$} ", c, width = w)
97                    })
98                    .collect();
99                lines.push(format!(
100                    "\u{2502}{}\u{2502}",
101                    cells.join("\u{2502}")
102                ));
103            }
104            lines.join("\n")
105        }
106
107        Block::Code {
108            lang, content, ..
109        } => {
110            let lang_label = match lang {
111                Some(l) => format!(" {}", l.dimmed()),
112                None => String::new(),
113            };
114            let border = format!("{}", "\u{2500}\u{2500}\u{2500}".dimmed()); // ───
115            let mut lines = vec![format!("{border}{lang_label}")];
116            for line in content.lines() {
117                lines.push(format!("  {line}"));
118            }
119            lines.push(border.clone());
120            lines.join("\n")
121        }
122
123        Block::Tasks { items, .. } => {
124            let lines: Vec<String> = items
125                .iter()
126                .map(|item| {
127                    if item.done {
128                        let check = format!("{}", "\u{2713}".green()); // ✓
129                        let text = format!("{}", item.text.strikethrough().green());
130                        let assignee = match &item.assignee {
131                            Some(a) => format!(" {}", format!("@{a}").dimmed()),
132                            None => String::new(),
133                        };
134                        format!("{check} {text}{assignee}")
135                    } else {
136                        let check = "\u{2610}"; // ☐
137                        let assignee = match &item.assignee {
138                            Some(a) => format!(" {}", format!("@{a}").dimmed()),
139                            None => String::new(),
140                        };
141                        format!("{check} {}{assignee}", item.text)
142                    }
143                })
144                .collect();
145            lines.join("\n")
146        }
147
148        Block::Decision {
149            status,
150            date,
151            content,
152            ..
153        } => {
154            let badge = decision_badge(*status);
155            let label = format!("{}", "Decision".bold());
156            let date_part = match date {
157                Some(d) => format!(" ({d})"),
158                None => String::new(),
159            };
160            format!("{badge} {label}{date_part}\n{content}")
161        }
162
163        Block::Metric {
164            label,
165            value,
166            trend,
167            unit,
168            ..
169        } => {
170            let label_str = format!("{}", label.bold());
171            let value_str = format!("{}", value.bold());
172            let unit_part = match unit {
173                Some(u) => format!(" {u}"),
174                None => String::new(),
175            };
176            let trend_part = match trend {
177                Some(Trend::Up) => format!(" {}", "\u{2191}".green()),
178                Some(Trend::Down) => format!(" {}", "\u{2193}".red()),
179                Some(Trend::Flat) => format!(" {}", "\u{2192}".dimmed()),
180                None => String::new(),
181            };
182            format!("{label_str}: {value_str}{unit_part}{trend_part}")
183        }
184
185        Block::Summary { content, .. } => {
186            let border = format!("{}", "\u{2502}".cyan()); // │
187            let lines: Vec<String> = content
188                .lines()
189                .map(|l| format!("{border} {}", l.italic()))
190                .collect();
191            lines.join("\n")
192        }
193
194        Block::Figure {
195            src, caption, ..
196        } => {
197            let cap = caption.as_deref().unwrap_or("Image");
198            format!("{}", format!("[Figure: {cap}] ({src})").dimmed())
199        }
200
201        Block::Tabs { tabs, .. } => {
202            let mut parts = Vec::new();
203            for (i, tab) in tabs.iter().enumerate() {
204                let label = format!("{}", format!("[Tab {}] {}", i + 1, tab.label).bold());
205                parts.push(format!("{label}\n{}", tab.content));
206            }
207            parts.join("\n\n")
208        }
209
210        Block::Columns { columns, .. } => {
211            let parts: Vec<String> = columns
212                .iter()
213                .enumerate()
214                .map(|(i, col)| {
215                    let label = format!("{}", format!("[Col {}]", i + 1).dimmed());
216                    format!("{label}\n{}", col.content)
217                })
218                .collect();
219            parts.join("\n\n")
220        }
221
222        Block::Quote {
223            content,
224            attribution,
225            ..
226        } => {
227            let border = format!("{}", "\u{2502}".dimmed()); // │
228            let mut lines: Vec<String> = content
229                .lines()
230                .map(|l| format!("{border} {}", l.italic()))
231                .collect();
232            if let Some(attr) = attribution {
233                lines.push(format!("{border} {}", format!("\u{2014} {attr}").dimmed()));
234            }
235            lines.join("\n")
236        }
237
238        Block::Cta {
239            label, href, primary, ..
240        } => {
241            let badge = if *primary {
242                format!("{}", "[CTA]".blue().bold())
243            } else {
244                format!("{}", "[CTA]".dimmed())
245            };
246            format!("{badge} {} ({href})", label.bold())
247        }
248
249        Block::HeroImage { src, alt, .. } => {
250            let desc = alt.as_deref().unwrap_or("Hero image");
251            format!("{}", format!("[Hero: {desc}] ({src})").dimmed())
252        }
253
254        Block::Testimonial {
255            content,
256            author,
257            role,
258            company,
259            ..
260        } => {
261            let border = format!("{}", "\u{2502}".dimmed()); // │
262            let mut lines: Vec<String> = content
263                .lines()
264                .map(|l| format!("{border} {}", l.italic()))
265                .collect();
266            let details: Vec<&str> = [author.as_deref(), role.as_deref(), company.as_deref()]
267                .iter()
268                .filter_map(|v| *v)
269                .collect();
270            if !details.is_empty() {
271                lines.push(format!("{border} {}", format!("\u{2014} {}", details.join(", ")).dimmed()));
272            }
273            lines.join("\n")
274        }
275
276        Block::Style { properties, .. } => {
277            if properties.is_empty() {
278                format!("{}", "[Style: empty]".dimmed())
279            } else {
280                let pairs: Vec<String> = properties
281                    .iter()
282                    .map(|p| format!("  {}: {}", p.key.bold(), p.value))
283                    .collect();
284                format!("{}\n{}", "[Style]".dimmed(), pairs.join("\n"))
285            }
286        }
287
288        Block::Faq { items, .. } => {
289            let mut parts = Vec::new();
290            for (i, item) in items.iter().enumerate() {
291                let q = format!("{}", format!("Q{}: {}", i + 1, item.question).bold());
292                parts.push(format!("{q}\n  {}", item.answer));
293            }
294            parts.join("\n\n")
295        }
296
297        Block::PricingTable {
298            headers, rows, ..
299        } => {
300            // Reuse the same table rendering as Data blocks
301            if headers.is_empty() {
302                return String::new();
303            }
304
305            let label = format!("{}", "[Pricing]".bold().cyan());
306            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
307            for row in rows {
308                for (i, cell) in row.iter().enumerate() {
309                    if i < widths.len() {
310                        widths[i] = widths[i].max(cell.len());
311                    }
312                }
313            }
314
315            let separator: String = widths
316                .iter()
317                .map(|&w| "\u{2500}".repeat(w + 2))
318                .collect::<Vec<_>>()
319                .join("\u{253C}");
320
321            let header_cells: Vec<String> = headers
322                .iter()
323                .enumerate()
324                .map(|(i, h)| format!(" {:width$} ", h, width = widths[i]))
325                .collect();
326            let header_line = format!(
327                "\u{2502}{}\u{2502}",
328                header_cells.join("\u{2502}")
329            );
330
331            let mut lines = vec![
332                label,
333                format!("{}", header_line.bold()),
334                format!("\u{2502}{separator}\u{2502}"),
335            ];
336
337            for row in rows {
338                let cells: Vec<String> = row
339                    .iter()
340                    .enumerate()
341                    .map(|(i, c)| {
342                        let w = widths.get(i).copied().unwrap_or(c.len());
343                        format!(" {:width$} ", c, width = w)
344                    })
345                    .collect();
346                lines.push(format!(
347                    "\u{2502}{}\u{2502}",
348                    cells.join("\u{2502}")
349                ));
350            }
351            lines.join("\n")
352        }
353
354        Block::Site { domain, properties, .. } => {
355            let label = format!("{}", "[Site Config]".bold().cyan());
356            let mut lines = vec![label];
357            if let Some(d) = domain {
358                lines.push(format!("  {}: {}", "domain".bold(), d));
359            }
360            for p in properties {
361                lines.push(format!("  {}: {}", p.key.bold(), p.value));
362            }
363            lines.join("\n")
364        }
365
366        Block::Page {
367            route,
368            layout,
369            children,
370            content,
371            ..
372        } => {
373            let layout_part = match layout {
374                Some(l) => format!(" layout={l}"),
375                None => String::new(),
376            };
377            let label = format!("{}", format!("[Page {route}{layout_part}]").bold().cyan());
378            if children.is_empty() {
379                if content.is_empty() {
380                    label
381                } else {
382                    format!("{label}\n{content}")
383                }
384            } else {
385                let child_output: Vec<String> = children.iter().map(render_block).collect();
386                format!("{label}\n{}", child_output.join("\n\n"))
387            }
388        }
389
390        Block::Deck { properties, .. } => {
391            let label = format!("{}", "[Deck Config]".bold().cyan());
392            let mut lines = vec![label];
393            for p in properties {
394                lines.push(format!("  {}: {}", p.key.bold(), p.value));
395            }
396            lines.join("\n")
397        }
398
399        Block::Slide {
400            layout,
401            kicker,
402            children,
403            content,
404            ..
405        } => {
406            let layout_part = match layout {
407                Some(l) => format!(" layout={}", l.css_class()),
408                None => String::new(),
409            };
410            let kick = kicker.as_deref().unwrap_or("");
411            let label = format!("{}", format!("[Slide{layout_part}] {kick}").bold().cyan());
412            if children.is_empty() {
413                if content.is_empty() {
414                    label
415                } else {
416                    format!("{label}\n{content}")
417                }
418            } else {
419                let child_output: Vec<String> = children.iter().map(render_block).collect();
420                format!("{label}\n{}", child_output.join("\n\n"))
421            }
422        }
423
424        Block::Nav { items, logo, groups, brand, .. } => {
425            let header = match (brand, logo) {
426                (Some(b), _) => format!("{} ", b.bold()),
427                (None, Some(l)) => format!("{} ", l.bold()),
428                _ => String::new(),
429            };
430            let mut links: Vec<String> = items
431                .iter()
432                .map(|item| format!("{} ({})", item.label.blue(), item.href.dimmed()))
433                .collect();
434            for g in groups {
435                for item in &g.items {
436                    links.push(format!("{} ({})", item.label.blue(), item.href.dimmed()));
437                }
438            }
439            format!("{header}{}", links.join(" | "))
440        }
441
442        Block::Details {
443            title, content, open, ..
444        } => {
445            let state = if *open { "\u{25bc}" } else { "\u{25b6}" }; // ▼ or ▶
446            let heading = title.as_deref().unwrap_or("Details");
447            format!("{} {}\n{content}", state, heading.bold())
448        }
449
450        Block::Divider { label, .. } => {
451            let rule = "\u{2500}".repeat(40); // ─
452            match label {
453                Some(text) => format!(
454                    "{} {} {}",
455                    "\u{2500}".repeat(3).dimmed(),
456                    text.dimmed(),
457                    "\u{2500}".repeat(36usize.saturating_sub(text.len())).dimmed(),
458                ),
459                None => rule.dimmed().to_string(),
460            }
461        }
462
463        Block::Unknown {
464            name, content, ..
465        } => {
466            let label = format!("{}", format!("[{name}]").dimmed());
467            if content.is_empty() {
468                label
469            } else {
470                format!("{label}\n{content}")
471            }
472        }
473
474        Block::Embed { src, title, .. } => {
475            let label = title.as_deref().unwrap_or("Embed");
476            format!("{} {}", format!("[{label}]").cyan(), src.dimmed())
477        }
478
479        Block::Form { fields, submit_label, .. } => {
480            let mut lines = vec![format!("{}", "Form".bold())];
481            for field in fields {
482                let req = if field.required { " *".red().to_string() } else { String::new() };
483                lines.push(format!("  {} {}{}", "•".dimmed(), field.label, req));
484            }
485            if let Some(label) = submit_label {
486                lines.push(format!("  {}", format!("[{label}]").cyan()));
487            }
488            lines.join("\n")
489        }
490
491        Block::Gallery { items, .. } => {
492            let count = items.len();
493            format!("{} ({count} images)", "Gallery".bold())
494        }
495
496        Block::Footer { sections, copyright, .. } => {
497            let mut lines = vec!["\u{2500}".repeat(40).dimmed().to_string()];
498            for section in sections {
499                lines.push(format!("{}", section.heading.bold()));
500                for link in &section.links {
501                    lines.push(format!("  {}", link.label.dimmed()));
502                }
503            }
504            if let Some(cr) = copyright {
505                lines.push(cr.dimmed().to_string());
506            }
507            lines.join("\n")
508        }
509
510        Block::Hero {
511            headline, subtitle, buttons, ..
512        } => {
513            let mut lines = Vec::new();
514            if let Some(h) = headline {
515                lines.push(format!("{}", h.bold()));
516            }
517            if let Some(s) = subtitle {
518                lines.push(s.dimmed().to_string());
519            }
520            for btn in buttons {
521                let marker = if btn.primary { "\u{25b6}" } else { "\u{25b7}" };
522                lines.push(format!("  {} {} {}", marker, btn.label.cyan(), btn.href.dimmed()));
523            }
524            lines.join("\n")
525        }
526
527        Block::Banner {
528            headline, subtitle, buttons, ..
529        } => {
530            let mut lines = Vec::new();
531            if let Some(h) = headline {
532                lines.push(format!("{}", h.bold()));
533            }
534            if let Some(s) = subtitle {
535                lines.push(s.dimmed().to_string());
536            }
537            for btn in buttons {
538                let marker = if btn.primary { "\u{25b6}" } else { "\u{25b7}" };
539                lines.push(format!("  {} {} {}", marker, btn.label.cyan(), btn.href.dimmed()));
540            }
541            lines.join("\n")
542        }
543
544        Block::ProductGrid { groups, .. } => {
545            let mut lines = Vec::new();
546            for group in groups {
547                if let Some(label) = &group.label {
548                    lines.push(label.bold().to_string());
549                }
550                for item in &group.items {
551                    let tagline = item
552                        .tagline
553                        .as_deref()
554                        .map(|t| format!(" — {}", t.dimmed()))
555                        .unwrap_or_default();
556                    lines.push(format!("  {} {}{}", item.name.cyan(), item.href.dimmed(), tagline));
557                }
558            }
559            lines.join("\n")
560        }
561
562        Block::Features { cards, .. } => {
563            let mut lines = Vec::new();
564            for card in cards {
565                let icon = card.icon.as_deref().unwrap_or("\u{2022}");
566                lines.push(format!("{} {}", icon, card.title.bold()));
567                if !card.body.is_empty() {
568                    lines.push(format!("  {}", card.body.dimmed()));
569                }
570            }
571            lines.join("\n")
572        }
573
574        Block::Steps { steps, .. } => {
575            let mut lines = Vec::new();
576            for (i, step) in steps.iter().enumerate() {
577                let time_str = step.time.as_ref().map(|t| format!(" ({})", t)).unwrap_or_default();
578                lines.push(format!("{}. {}{}", i + 1, step.title.bold(), time_str.dimmed()));
579                if !step.body.is_empty() {
580                    lines.push(format!("   {}", step.body.dimmed()));
581                }
582            }
583            lines.join("\n")
584        }
585
586        Block::Stats { items, .. } => {
587            items
588                .iter()
589                .map(|item| format!("{} {}", item.value.bold(), item.label.dimmed()))
590                .collect::<Vec<_>>()
591                .join("  \u{2502}  ")
592        }
593
594        Block::Comparison { headers, rows, .. } => {
595            let mut lines = Vec::new();
596            lines.push(headers.join(" | ").bold().to_string());
597            for row in rows {
598                lines.push(row.join(" | "));
599            }
600            lines.join("\n")
601        }
602
603        Block::Logo { src, alt, .. } => {
604            let label = alt.as_deref().unwrap_or("Logo");
605            format!("{} {}", format!("[{label}]").cyan(), src.dimmed())
606        }
607
608        Block::Toc { depth, .. } => {
609            format!("{} (depth: {})", "Table of Contents".bold(), depth)
610        }
611
612        Block::BeforeAfter {
613            before_items,
614            after_items,
615            transition,
616            ..
617        } => {
618            let mut lines = Vec::new();
619            lines.push(format!("{}", "Before".bold().red()));
620            for item in before_items {
621                lines.push(format!("  {} {} {}", "\u{25cf}".red(), item.label.bold(), item.detail.dimmed()));
622            }
623            if let Some(t) = transition {
624                lines.push(format!("  {} {} {}", "\u{2193}".cyan(), t.cyan(), "\u{2193}".cyan()));
625            }
626            lines.push(format!("{}", "After".bold().green()));
627            for item in after_items {
628                lines.push(format!("  {} {} {}", "\u{25cf}".green(), item.label.bold(), item.detail.dimmed()));
629            }
630            lines.join("\n")
631        }
632
633        Block::Pipeline { steps, .. } => {
634            steps
635                .iter()
636                .map(|s| {
637                    if let Some(d) = &s.description {
638                        format!("{} ({})", s.label.bold(), d.dimmed())
639                    } else {
640                        format!("{}", s.label.bold())
641                    }
642                })
643                .collect::<Vec<_>>()
644                .join(&format!(" {} ", "\u{2192}".cyan()))
645        }
646
647        Block::Section {
648            headline,
649            subtitle,
650            children,
651            ..
652        } => {
653            let mut lines = Vec::new();
654            if let Some(h) = headline {
655                lines.push(format!("{}", h.bold()));
656            }
657            if let Some(s) = subtitle {
658                lines.push(format!("{}", s.dimmed()));
659            }
660            for child in children {
661                lines.push(render_block(child));
662            }
663            lines.join("\n")
664        }
665
666        Block::ProductCard {
667            title,
668            subtitle,
669            badge,
670            features,
671            cta_label,
672            cta_href,
673            body,
674            ..
675        } => {
676            let mut lines = Vec::new();
677            let badge_str = badge.as_ref().map(|b| format!(" [{}]", b.green())).unwrap_or_default();
678            lines.push(format!("{}{}", title.bold(), badge_str));
679            if let Some(s) = subtitle {
680                lines.push(format!("{}", s.dimmed()));
681            }
682            if !body.is_empty() {
683                lines.push(body.clone());
684            }
685            for f in features {
686                lines.push(format!("  {} {f}", "\u{2713}".green()));
687            }
688            if let (Some(label), Some(href)) = (cta_label, cta_href) {
689                lines.push(format!("{} ({})", label.cyan(), href.dimmed()));
690            }
691            lines.join("\n")
692        }
693
694        // App description blocks — degrade to markdown for terminal
695        Block::Diagram { .. }
696        | Block::List { .. } | Block::Board { .. } | Block::Action { .. }
697        | Block::FilterBar { .. } | Block::Search { .. } | Block::Dashboard { .. }
698        | Block::ChatInput { .. } | Block::Feed { .. } | Block::Booking { .. } | Block::Store { .. } | Block::Editor { .. }
699        | Block::Chart { .. } | Block::SplitPane { .. }
700        | Block::App { .. } | Block::Build { .. } | Block::InfraDatabase { .. }
701        | Block::Deploy { .. } | Block::InfraEnv { .. } | Block::Health { .. }
702        | Block::Concurrency { .. } | Block::Cicd { .. } | Block::Smoke { .. }
703        | Block::Domains { .. } | Block::Crates { .. } | Block::DeployUrls { .. }
704        | Block::Volumes { .. }
705        | Block::Model { .. }
706        | Block::Route { .. }
707        | Block::Auth { .. }
708        | Block::Binding { .. }
709        | Block::Schema { .. }
710        | Block::Use { .. }
711        | Block::AppEnv { .. }
712        | Block::AppDeploy { .. }
713        | Block::Row { .. }
714        | Block::InfoCard { .. }
715        | Block::AppShell { .. }
716        | Block::Sidebar { .. }
717        | Block::Panel { .. }
718        | Block::TabBar { .. }
719        | Block::TabContent { .. }
720        | Block::Toolbar { .. }
721        | Block::Drawer { .. }
722        | Block::Modal { .. }
723        | Block::CommandPalette { .. }
724        | Block::CodeEditor { .. }
725        | Block::BlockEditor { .. }
726        | Block::Terminal { .. }
727        | Block::NavTree { .. }
728        | Block::Badge { .. }
729        | Block::SuggestionChips { .. }
730        | Block::ChatThread { .. }
731        | Block::ChatInputSimple { .. }
732        | Block::Progress { .. }
733        | Block::LogStream { .. }
734        | Block::ProblemList { .. }
735        | Block::PostGrid { .. }
736        | Block::Cite { .. }
737        | Block::Bibliography { .. }
738        | Block::Gate { .. } => {
739            crate::render_md::render_block(block)
740        }
741    }
742}
743
744/// Render markdown content with ANSI terminal styling.
745/// Handles headers, bold, italic, inline code, lists, blockquotes, and links.
746fn render_markdown_content(content: &str) -> String {
747    let mut lines: Vec<String> = Vec::new();
748    let mut in_code_block = false;
749    for line in content.lines() {
750        // Code block toggle
751        if line.trim_start().starts_with("```") {
752            if in_code_block {
753                in_code_block = false;
754                lines.push(format!("{}", "───".dimmed()));
755                continue;
756            } else {
757                in_code_block = true;
758                let code_lang = line.trim_start().trim_start_matches('`').to_string();
759                let lang_label = if code_lang.is_empty() {
760                    String::new()
761                } else {
762                    format!(" {}", code_lang.dimmed())
763                };
764                lines.push(format!("{}{}", "───".dimmed(), lang_label));
765                continue;
766            }
767        }
768
769        if in_code_block {
770            lines.push(format!("  {}", line));
771            continue;
772        }
773
774        let trimmed = line.trim();
775
776        // Empty lines
777        if trimmed.is_empty() {
778            lines.push(String::new());
779            continue;
780        }
781
782        // Headers
783        if trimmed.starts_with("#### ") {
784            let text = &trimmed[5..];
785            lines.push(format!("{}", style_inline(text).bold()));
786            continue;
787        }
788        if trimmed.starts_with("### ") {
789            let text = &trimmed[4..];
790            lines.push(format!("{}", style_inline(text).bold()));
791            continue;
792        }
793        if trimmed.starts_with("## ") {
794            let text = &trimmed[3..];
795            lines.push(format!("\n{}", style_inline(text).bold().cyan()));
796            continue;
797        }
798        if trimmed.starts_with("# ") {
799            let text = &trimmed[2..];
800            lines.push(format!("\n{}", style_inline(text).bold().blue()));
801            continue;
802        }
803
804        // Blockquotes
805        if trimmed.starts_with("> ") {
806            let text = &trimmed[2..];
807            let border = format!("{}", "│".dimmed());
808            lines.push(format!("{} {}", border, style_inline(text).italic()));
809            continue;
810        }
811        if trimmed == ">" {
812            let border = format!("{}", "│".dimmed());
813            lines.push(format!("{}", border));
814            continue;
815        }
816
817        // Unordered list items
818        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
819            let indent = line.len() - line.trim_start().len();
820            let text = &trimmed[2..];
821            let pad = " ".repeat(indent);
822            lines.push(format!("{}  {} {}", pad, "•".dimmed(), style_inline(text)));
823            continue;
824        }
825
826        // Numbered list items
827        if let Some(rest) = parse_numbered_list(trimmed) {
828            let indent = line.len() - line.trim_start().len();
829            let pad = " ".repeat(indent);
830            let num_end = trimmed.find(". ").unwrap_or(0);
831            let num = &trimmed[..num_end + 1];
832            lines.push(format!("{}  {} {}", pad, num.dimmed(), style_inline(rest)));
833            continue;
834        }
835
836        // Horizontal rules
837        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
838            lines.push(format!("{}", "────────────────────────────────────────".dimmed()));
839            continue;
840        }
841
842        // Regular paragraph text — apply inline styling
843        lines.push(style_inline(trimmed).to_string());
844    }
845
846    lines.join("\n")
847}
848
849/// Check if a line starts with a numbered list pattern like "1. ", "2. ", etc.
850fn parse_numbered_list(line: &str) -> Option<&str> {
851    let bytes = line.as_bytes();
852    let mut i = 0;
853    while i < bytes.len() && bytes[i].is_ascii_digit() {
854        i += 1;
855    }
856    if i > 0 && i < bytes.len() - 1 && bytes[i] == b'.' && bytes[i + 1] == b' ' {
857        Some(&line[i + 2..])
858    } else {
859        None
860    }
861}
862
863/// Apply inline markdown styling: **bold**, *italic*, `code`, [links](url)
864fn style_inline(text: &str) -> String {
865    let mut result = String::new();
866    let chars: Vec<char> = text.chars().collect();
867    let len = chars.len();
868    let mut i = 0;
869
870    while i < len {
871        // Bold: **text**
872        if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
873            if let Some(end) = find_closing(&chars, i + 2, &['*', '*']) {
874                let inner: String = chars[i + 2..end].iter().collect();
875                result.push_str(&format!("{}", inner.bold()));
876                i = end + 2;
877                continue;
878            }
879        }
880
881        // Italic: *text*
882        if chars[i] == '*' && (i + 1 >= len || chars[i + 1] != '*') {
883            if let Some(end) = find_closing_single(&chars, i + 1, '*') {
884                let inner: String = chars[i + 1..end].iter().collect();
885                result.push_str(&format!("{}", inner.italic()));
886                i = end + 1;
887                continue;
888            }
889        }
890
891        // Inline code: `text`
892        if chars[i] == '`' {
893            if let Some(end) = find_closing_single(&chars, i + 1, '`') {
894                let inner: String = chars[i + 1..end].iter().collect();
895                result.push_str(&format!("{}", inner.cyan()));
896                i = end + 1;
897                continue;
898            }
899        }
900
901        // Links: [text](url)
902        if chars[i] == '[' {
903            if let Some(bracket_end) = find_closing_single(&chars, i + 1, ']') {
904                if bracket_end + 1 < len && chars[bracket_end + 1] == '(' {
905                    if let Some(paren_end) = find_closing_single(&chars, bracket_end + 2, ')') {
906                        let link_text: String = chars[i + 1..bracket_end].iter().collect();
907                        let url: String = chars[bracket_end + 2..paren_end].iter().collect();
908                        result.push_str(&format!("{} {}", link_text.blue(), format!("({url})").dimmed()));
909                        i = paren_end + 1;
910                        continue;
911                    }
912                }
913            }
914        }
915
916        result.push(chars[i]);
917        i += 1;
918    }
919
920    result
921}
922
923/// Find closing pair of two identical chars (e.g., ** for bold)
924fn find_closing(chars: &[char], start: usize, pattern: &[char; 2]) -> Option<usize> {
925    let mut i = start;
926    while i + 1 < chars.len() {
927        if chars[i] == pattern[0] && chars[i + 1] == pattern[1] {
928            return Some(i);
929        }
930        i += 1;
931    }
932    None
933}
934
935/// Find closing single char (e.g., * for italic, ` for code)
936fn find_closing_single(chars: &[char], start: usize, closing: char) -> Option<usize> {
937    for i in start..chars.len() {
938        if chars[i] == closing {
939            return Some(i);
940        }
941    }
942    None
943}
944
945fn callout_style(ct: CalloutType) -> (&'static str, &'static str) {
946    match ct {
947        CalloutType::Warning => ("yellow", "Warning"),
948        CalloutType::Danger => ("red", "Danger"),
949        CalloutType::Info => ("blue", "Info"),
950        CalloutType::Tip => ("green", "Tip"),
951        CalloutType::Note => ("cyan", "Note"),
952        CalloutType::Success => ("green", "Success"),
953        CalloutType::Context => ("white", "Context"),
954    }
955}
956
957fn apply_color(text: &str, color: &str) -> String {
958    match color {
959        "yellow" => format!("{}", text.yellow()),
960        "red" => format!("{}", text.red()),
961        "blue" => format!("{}", text.blue()),
962        "green" => format!("{}", text.green()),
963        "cyan" => format!("{}", text.cyan()),
964        _ => text.to_string(),
965    }
966}
967
968fn decision_badge(status: DecisionStatus) -> String {
969    match status {
970        DecisionStatus::Accepted => format!("{}", "[ACCEPTED]".green()),
971        DecisionStatus::Rejected => format!("{}", "[REJECTED]".red()),
972        DecisionStatus::Proposed => format!("{}", "[PROPOSED]".yellow()),
973        DecisionStatus::Superseded => format!("{}", "[SUPERSEDED]".dimmed()),
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use crate::types::*;
981
982    fn span() -> Span {
983        Span {
984            start_line: 1,
985            end_line: 1,
986            start_offset: 0,
987            end_offset: 0,
988        }
989    }
990
991    fn doc_with(blocks: Vec<Block>) -> SurfDoc {
992        SurfDoc {
993            front_matter: None,
994            blocks,
995            source: String::new(),
996        }
997    }
998
999    #[test]
1000    fn term_callout_has_color() {
1001        // Force colors on — the colored crate disables them when stdout is not a tty.
1002        colored::control::set_override(true);
1003
1004        let doc = doc_with(vec![Block::Callout {
1005            callout_type: CalloutType::Warning,
1006            title: None,
1007            content: "Watch out!".into(),
1008            span: span(),
1009        }]);
1010        let output = to_terminal(&doc);
1011        // ANSI escape codes start with \x1b[
1012        assert!(
1013            output.contains("\x1b["),
1014            "Terminal output should contain ANSI escape codes, got: {output:?}"
1015        );
1016        assert!(output.contains("Watch out!"));
1017
1018        colored::control::unset_override();
1019    }
1020
1021    #[test]
1022    fn term_tasks_symbols() {
1023        let doc = doc_with(vec![Block::Tasks {
1024            items: vec![
1025                TaskItem {
1026                    done: true,
1027                    text: "Done".into(),
1028                    assignee: None,
1029                },
1030                TaskItem {
1031                    done: false,
1032                    text: "Pending".into(),
1033                    assignee: None,
1034                },
1035            ],
1036            span: span(),
1037        }]);
1038        let output = to_terminal(&doc);
1039        assert!(output.contains("\u{2713}"), "Should contain checkmark"); // ✓
1040        assert!(output.contains("\u{2610}"), "Should contain empty checkbox"); // ☐
1041    }
1042
1043    #[test]
1044    fn term_metric_trend() {
1045        let doc = doc_with(vec![
1046            Block::Metric {
1047                label: "MRR".into(),
1048                value: "$2K".into(),
1049                trend: Some(Trend::Up),
1050                unit: None,
1051                span: span(),
1052            },
1053            Block::Metric {
1054                label: "Churn".into(),
1055                value: "5%".into(),
1056                trend: Some(Trend::Down),
1057                unit: None,
1058                span: span(),
1059            },
1060        ]);
1061        let output = to_terminal(&doc);
1062        assert!(output.contains("\u{2191}"), "Should contain up arrow"); // ↑
1063        assert!(output.contains("\u{2193}"), "Should contain down arrow"); // ↓
1064    }
1065}