1use colored::Colorize;
7
8use crate::types::{Block, CalloutType, DecisionStatus, SurfDoc, Trend};
9
10pub 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); 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 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)) .collect::<Vec<_>>()
66 .join("\u{253C}"); 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()); 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()); 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}"; 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()); 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()); 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()); 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 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 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 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"); assert!(output.contains("\u{2610}"), "Should contain empty checkbox"); }
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"); assert!(output.contains("\u{2193}"), "Should contain down arrow"); }
516}