1use colored::Colorize;
7
8use crate::types::{Block, CalloutType, DecisionStatus, SurfDoc, Trend};
9
10pub 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); 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 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)) .collect::<Vec<_>>()
72 .join("\u{253C}"); 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()); 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()); 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}"; 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()); 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()); 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()); 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 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}" }; 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); 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 §ion.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 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
744fn 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 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 if trimmed.is_empty() {
778 lines.push(String::new());
779 continue;
780 }
781
782 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 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 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 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 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
838 lines.push(format!("{}", "────────────────────────────────────────".dimmed()));
839 continue;
840 }
841
842 lines.push(style_inline(trimmed).to_string());
844 }
845
846 lines.join("\n")
847}
848
849fn 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
863fn 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 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 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 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 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
923fn 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
935fn 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 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 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"); assert!(output.contains("\u{2610}"), "Should contain empty checkbox"); }
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"); assert!(output.contains("\u{2193}"), "Should contain down arrow"); }
1065}