1use std::io::{self, IsTerminal, Write};
7
8use rich_rust::prelude::*;
9use rich_rust::renderables::{Markdown, Syntax};
10use rich_rust::segment::Segment;
11
12pub struct PiConsole {
14 console: Console,
15 is_tty: bool,
16}
17
18impl PiConsole {
19 pub fn new() -> Self {
21 Self::new_with_theme(None)
22 }
23
24 pub fn new_with_theme(_theme: Option<crate::theme::Theme>) -> Self {
26 let is_tty = io::stdout().is_terminal();
27 let console = Console::builder().markup(is_tty).emoji(is_tty).build();
28
29 Self { console, is_tty }
30 }
31
32 pub fn with_color() -> Self {
34 Self {
35 console: Console::builder()
36 .markup(true)
37 .emoji(true)
38 .file(Box::new(io::sink()))
39 .build(),
40 is_tty: true,
41 }
42 }
43
44 pub const fn is_terminal(&self) -> bool {
46 self.is_tty
47 }
48
49 pub fn width(&self) -> usize {
51 self.console.width()
52 }
53
54 pub fn print_plain(&self, text: &str) {
60 print!("{text}");
61 let _ = io::stdout().flush();
62 }
63
64 pub fn print_markup(&self, markup: &str) {
66 if self.is_tty {
67 self.console.print(markup);
68 } else {
69 print!("{}", strip_markup(markup));
71 let _ = io::stdout().flush();
72 }
73 }
74
75 pub fn newline(&self) {
77 println!();
78 }
79
80 pub fn render_markdown(&self, markdown: &str) {
82 self.render_markdown_with_indent(markdown, None);
83 }
84
85 pub fn render_markdown_with_indent(&self, markdown: &str, code_block_indent: Option<usize>) {
87 if self.is_tty {
88 let indent = code_block_indent.unwrap_or(0);
89 let mut segments = render_markdown_with_syntax(markdown, self.width(), indent);
90 let mut ends_with_newline = false;
91 for segment in segments.iter().rev() {
92 let text = segment.text.as_ref();
93 if text.is_empty() {
94 continue;
95 }
96 ends_with_newline = text.ends_with('\n');
97 break;
98 }
99 if !ends_with_newline {
100 segments.push(Segment::plain("\n"));
101 }
102 self.console.print_segments(&segments);
103 } else {
104 print!("{markdown}");
105 if !markdown.ends_with('\n') {
106 println!();
107 }
108 let _ = io::stdout().flush();
109 }
110 }
111
112 pub fn render_text_delta(&self, text: &str) {
118 print!("{text}");
119 let _ = io::stdout().flush();
120 }
121
122 pub fn render_thinking_delta(&self, text: &str) {
124 if self.is_tty {
125 print!("\x1b[2m{text}\x1b[0m");
127 } else {
128 print!("{text}");
129 }
130 let _ = io::stdout().flush();
131 }
132
133 pub fn render_thinking_start(&self) {
135 if self.is_tty {
136 self.print_markup("\n[dim italic]Thinking...[/]\n");
137 }
138 }
139
140 pub fn render_thinking_end(&self) {
142 if self.is_tty {
143 self.print_markup("[/dim]\n");
144 }
145 }
146
147 pub fn render_tool_start(&self, name: &str, _input: &str) {
149 if self.is_tty {
150 self.print_markup(&format!("\n[bold yellow][[Running {name}...]][/]\n"));
151 }
152 }
153
154 pub fn render_tool_end(&self, name: &str, is_error: bool) {
156 if self.is_tty {
157 if is_error {
158 self.print_markup(&format!("[bold red][[{name} failed]][/]\n\n"));
159 } else {
160 self.print_markup(&format!("[bold green][[{name} done]][/]\n\n"));
161 }
162 }
163 }
164
165 pub fn render_error(&self, error: &str) {
167 if self.is_tty {
168 self.print_markup(&format!("\n[bold red]Error:[/] {error}\n"));
169 } else {
170 eprintln!("\nError: {error}");
171 }
172 }
173
174 pub fn render_warning(&self, warning: &str) {
176 if self.is_tty {
177 self.print_markup(&format!("[bold yellow]Warning:[/] {warning}\n"));
178 } else {
179 eprintln!("Warning: {warning}");
180 }
181 }
182
183 pub fn render_success(&self, message: &str) {
185 if self.is_tty {
186 self.print_markup(&format!("[bold green]{message}[/]\n"));
187 } else {
188 println!("{message}");
189 }
190 }
191
192 pub fn render_info(&self, message: &str) {
194 if self.is_tty {
195 self.print_markup(&format!("[bold blue]{message}[/]\n"));
196 } else {
197 println!("{message}");
198 }
199 }
200
201 pub fn render_panel(&self, content: &str, title: &str) {
207 if self.is_tty {
208 let panel = Panel::from_text(content)
209 .title(title)
210 .border_style(Style::parse("cyan").unwrap_or_default());
211 self.console.print_renderable(&panel);
212 } else {
213 println!("--- {title} ---");
214 println!("{content}");
215 println!("---");
216 }
217 }
218
219 pub fn render_table(&self, headers: &[&str], rows: &[Vec<&str>]) {
221 if self.is_tty {
222 let mut table = Table::new().header_style(Style::parse("bold").unwrap_or_default());
223 for header in headers {
224 table = table.with_column(Column::new(*header));
225 }
226 for row in rows {
227 table.add_row_cells(row.iter().copied());
228 }
229 self.console.print_renderable(&table);
230 } else {
231 println!("{}", headers.join("\t"));
233 for row in rows {
234 println!("{}", row.join("\t"));
235 }
236 }
237 }
238
239 pub fn render_rule(&self, title: Option<&str>) {
241 if self.is_tty {
242 let rule = title.map_or_else(Rule::new, Rule::with_title);
243 self.console.print_renderable(&rule);
244 } else if let Some(t) = title {
245 println!("--- {t} ---");
246 } else {
247 println!("---");
248 }
249 }
250
251 pub fn render_usage(&self, input_tokens: u32, output_tokens: u32, cost_usd: Option<f64>) {
257 if self.is_tty {
258 let cost_str = cost_usd
259 .map(|c| format!(" [dim](${c:.4})[/]"))
260 .unwrap_or_default();
261 self.print_markup(&format!(
262 "[dim]Tokens: {input_tokens} in / {output_tokens} out{cost_str}[/]\n"
263 ));
264 }
265 }
266
267 pub fn render_session_info(&self, session_path: &str, message_count: usize) {
269 if self.is_tty {
270 self.print_markup(&format!(
271 "[dim]Session: {session_path} ({message_count} messages)[/]\n"
272 ));
273 }
274 }
275
276 pub fn render_model_info(&self, model: &str, thinking_level: Option<&str>) {
278 if self.is_tty {
279 let thinking_str = thinking_level
280 .map(|t| format!(" [dim](thinking: {t})[/]"))
281 .unwrap_or_default();
282 self.print_markup(&format!("[dim]Model: {model}{thinking_str}[/]\n"));
283 }
284 }
285
286 pub fn render_prompt(&self) {
292 if self.is_tty {
293 self.print_markup("[bold cyan]>[/] ");
294 } else {
295 print!("> ");
296 }
297 let _ = io::stdout().flush();
298 }
299
300 pub fn render_user_message(&self, message: &str) {
302 if self.is_tty {
303 self.print_markup(&format!("[bold]You:[/] {message}\n\n"));
304 } else {
305 println!("You: {message}\n");
306 }
307 }
308
309 pub fn render_assistant_start(&self) {
311 if self.is_tty {
312 self.print_markup("[bold]Assistant:[/] ");
313 } else {
314 print!("Assistant: ");
315 }
316 let _ = io::stdout().flush();
317 }
318
319 pub fn clear_line(&self) {
321 if self.is_tty {
322 print!("\r\x1b[K");
323 let _ = io::stdout().flush();
324 }
325 }
326
327 pub fn cursor_up(&self, n: usize) {
329 if self.is_tty && n > 0 {
330 print!("\x1b[{n}A");
331 let _ = io::stdout().flush();
332 }
333 }
334}
335
336impl Default for PiConsole {
337 fn default() -> Self {
338 Self::new()
339 }
340}
341
342impl Clone for PiConsole {
344 fn clone(&self) -> Self {
345 Self {
346 console: Console::builder()
347 .markup(self.is_tty)
348 .emoji(self.is_tty)
349 .build(),
350 is_tty: self.is_tty,
351 }
352 }
353}
354
355#[derive(Debug, Clone)]
356enum MarkdownChunk {
357 Text(String),
358 CodeBlock {
359 language: Option<String>,
360 code: String,
361 },
362}
363
364fn parse_fenced_code_language(info: &str) -> Option<String> {
365 let language_tag = info
366 .split_whitespace()
367 .next()
368 .unwrap_or_default()
369 .split(',')
370 .next()
371 .unwrap_or_default()
372 .trim();
373 if language_tag.is_empty() {
374 None
375 } else {
376 Some(language_tag.to_ascii_lowercase())
377 }
378}
379
380fn split_markdown_fenced_code_blocks(markdown: &str) -> Vec<MarkdownChunk> {
381 let mut chunks = Vec::new();
382
383 let mut text_buf = String::new();
384 let mut code_buf = String::new();
385 let mut in_code_block = false;
386 let mut fence_len = 0usize;
387 let mut fence_char = '\0';
388 let mut code_language: Option<String> = None;
389
390 for line in markdown.split_inclusive('\n') {
391 let trimmed_start = line.trim_start();
392 let trimmed_line = trimmed_start.trim_end_matches(['\r', '\n']);
393
394 let marker = trimmed_line.chars().next().unwrap_or('\0');
395 let is_potential_fence = marker == '`' || marker == '~';
396 let marker_count = if is_potential_fence {
397 trimmed_line.chars().take_while(|ch| *ch == marker).count()
398 } else {
399 0
400 };
401 let is_fence = marker_count >= 3;
402
403 if !in_code_block {
404 if is_fence {
405 fence_len = marker_count;
406 fence_char = marker;
407 let info = trimmed_line.get(fence_len..).unwrap_or_default();
408
409 if marker == '`' && info.contains('`') {
412 text_buf.push_str(line);
413 continue;
414 }
415
416 if !text_buf.is_empty() {
417 chunks.push(MarkdownChunk::Text(text_buf.clone()));
418 text_buf.clear();
419 }
420
421 code_language = parse_fenced_code_language(info);
422 in_code_block = true;
423 code_buf.clear();
424 continue;
425 }
426
427 text_buf.push_str(line);
428 continue;
429 }
430
431 if is_fence
432 && marker == fence_char
433 && marker_count >= fence_len
434 && trimmed_line[marker_count..].trim().is_empty()
435 {
436 chunks.push(MarkdownChunk::CodeBlock {
437 language: code_language.take(),
438 code: code_buf.clone(),
439 });
440 code_buf.clear();
441 in_code_block = false;
442 fence_len = 0;
443 fence_char = '\0';
444 continue;
445 }
446
447 code_buf.push_str(line);
448 }
449
450 if in_code_block {
451 chunks.push(MarkdownChunk::CodeBlock {
455 language: code_language.take(),
456 code: code_buf,
457 });
458 } else if !text_buf.is_empty() {
459 chunks.push(MarkdownChunk::Text(text_buf));
460 }
461
462 chunks
463}
464
465fn has_multiple_non_none_styles(segments: &[Segment<'_>]) -> bool {
466 use std::collections::HashSet;
467
468 let mut seen = HashSet::new();
469 for segment in segments {
470 let Some(style) = &segment.style else {
471 continue;
472 };
473 if segment.text.as_ref().trim().is_empty() {
474 continue;
475 }
476
477 seen.insert(style.clone());
478 if seen.len() > 1 {
479 return true;
480 }
481 }
482
483 false
484}
485
486fn render_syntax_line_by_line(
487 code: &str,
488 language: &str,
489 width: usize,
490) -> Option<Vec<Segment<'static>>> {
491 let mut rendered: Vec<Segment<'static>> = Vec::new();
492 for line in code.split_inclusive('\n') {
493 let syntax = Syntax::new(line, language);
494 let items = syntax.render(Some(width)).ok()?;
495 rendered.extend(items.into_iter().map(Segment::into_owned));
496 }
497 Some(rendered)
498}
499
500fn indent_code_block(code: &str, indent: usize) -> String {
501 if indent == 0 {
502 return code.to_string();
503 }
504 let prefix = " ".repeat(indent);
505 let mut out = String::with_capacity(code.len() + indent * code.lines().count().max(1));
506 for line in code.split_inclusive('\n') {
507 out.push_str(&prefix);
508 out.push_str(line);
509 }
510 out
511}
512
513fn render_markdown_with_syntax(
514 markdown: &str,
515 width: usize,
516 code_block_indent: usize,
517) -> Vec<Segment<'static>> {
518 if !markdown.contains("```") {
519 return Markdown::new(markdown)
520 .render(width)
521 .into_iter()
522 .map(Segment::into_owned)
523 .collect();
524 }
525
526 let chunks = split_markdown_fenced_code_blocks(markdown);
527 let mut segments: Vec<Segment<'static>> = Vec::new();
528
529 for chunk in chunks {
530 match chunk {
531 MarkdownChunk::Text(text) => {
532 if text.is_empty() {
533 continue;
534 }
535 segments.extend(
536 Markdown::new(text)
537 .render(width)
538 .into_iter()
539 .map(Segment::into_owned),
540 );
541 }
542 MarkdownChunk::CodeBlock { language, mut code } => {
543 if !code.ends_with('\n') {
544 code.push('\n');
545 }
546 if code_block_indent > 0 {
547 code = indent_code_block(&code, code_block_indent);
548 }
549
550 let language = language.unwrap_or_else(|| "text".to_string());
551 let require_variation = matches!(language.as_str(), "typescript" | "ts" | "tsx");
552 let mut candidates: Vec<&str> = Vec::new();
553 match language.as_str() {
554 "typescript" | "ts" | "tsx" => candidates.extend(["ts", "javascript"]),
557 _ => candidates.push(language.as_str()),
558 }
559 candidates.push("text");
560
561 let mut rendered_items: Option<Vec<Segment<'static>>> = None;
562 for candidate in candidates {
563 let syntax = Syntax::new(code.as_str(), candidate);
564 if let Ok(items) = syntax.render(Some(width)) {
565 if require_variation
566 && candidate != "text"
567 && !has_multiple_non_none_styles(&items)
568 {
569 if candidate == "javascript" {
570 if let Some(line_items) =
571 render_syntax_line_by_line(code.as_str(), candidate, width)
572 {
573 if has_multiple_non_none_styles(&line_items) {
574 rendered_items = Some(line_items);
575 break;
576 }
577 }
578 }
579 continue;
580 }
581 rendered_items = Some(items.into_iter().map(Segment::into_owned).collect());
582 break;
583 }
584 }
585
586 if let Some(items) = rendered_items {
587 segments.extend(items);
588 } else {
589 segments.extend(
590 Markdown::new(format!("```\n{code}```\n"))
591 .render(width)
592 .into_iter()
593 .map(Segment::into_owned),
594 );
595 }
596 }
597 }
598 }
599
600 segments
601}
602
603fn strip_markup(text: &str) -> String {
605 let mut result = String::with_capacity(text.len());
606 let mut buffer = String::new();
607 let mut in_tag = false;
608
609 for c in text.chars() {
610 if in_tag {
611 if c == ']' {
612 let is_pure_digits =
617 !buffer.is_empty() && buffer.chars().all(|ch| ch.is_ascii_digit());
618 let contains_invalid_chars = buffer.chars().any(|ch| {
619 !ch.is_ascii_alphanumeric()
620 && !matches!(
621 ch,
622 ' ' | '/'
623 | ','
624 | '#'
625 | '='
626 | '.'
627 | ':'
628 | '-'
629 | '_'
630 | '?'
631 | '&'
632 | '%'
633 | '+'
634 | '~'
635 | ';'
636 | '*'
637 | '\''
638 | '('
639 | ')'
640 )
641 });
642
643 if is_pure_digits || contains_invalid_chars || buffer.is_empty() {
644 result.push('[');
646 result.push_str(&buffer);
647 result.push(']');
648 } else {
649 }
651 buffer.clear();
652 in_tag = false;
653 } else if c == '[' {
654 result.push('[');
655 if buffer.is_empty() {
656 in_tag = false;
658 } else {
659 result.push_str(&buffer);
662 buffer.clear();
663 }
665 } else {
666 buffer.push(c);
667 }
668 } else if c == '[' {
669 in_tag = true;
670 } else {
671 result.push(c);
672 }
673 }
674
675 if in_tag {
677 result.push('[');
678 result.push_str(&buffer);
679 }
680
681 result
682}
683
684pub enum SpinnerStyle {
686 Dots,
688 Line,
690 Simple,
692}
693
694impl SpinnerStyle {
695 pub const fn frames(&self) -> &'static [&'static str] {
697 match self {
698 Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
699 Self::Line => &["⎺", "⎻", "⎼", "⎽", "⎼", "⎻"],
700 Self::Simple => &["|", "/", "-", "\\"],
701 }
702 }
703
704 pub const fn interval_ms(&self) -> u64 {
706 match self {
707 Self::Dots => 80,
708 Self::Line | Self::Simple => 100,
709 }
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 use std::collections::HashSet;
718 use std::sync::{Arc, Mutex};
719
720 fn capture_markdown_segments(markdown: &str) -> Vec<Segment<'static>> {
721 capture_markdown_segments_with_indent(markdown, None)
722 }
723
724 fn capture_markdown_segments_with_indent(
725 markdown: &str,
726 code_block_indent: Option<usize>,
727 ) -> Vec<Segment<'static>> {
728 let console = PiConsole::with_color();
729 console.console.begin_capture();
730 console.render_markdown_with_indent(markdown, code_block_indent);
731 console.console.end_capture()
732 }
733
734 fn segments_text(segments: &[Segment<'static>]) -> String {
735 segments.iter().map(|s| s.text.as_ref()).collect()
736 }
737
738 fn unique_style_debug_for_tokens(
739 segments: &[Segment<'static>],
740 tokens: &[&str],
741 ) -> HashSet<String> {
742 segments
743 .iter()
744 .filter(|segment| {
745 let text = segment.text.as_ref();
746 tokens.iter().any(|token| text.contains(token))
747 })
748 .map(|segment| format!("{:?}", segment.style))
749 .collect()
750 }
751
752 #[derive(Clone)]
753 struct SharedBufferWriter {
754 buffer: Arc<Mutex<Vec<u8>>>,
755 }
756
757 impl io::Write for SharedBufferWriter {
758 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
759 self.buffer
760 .lock()
761 .expect("lock buffer")
762 .extend_from_slice(buf);
763 Ok(buf.len())
764 }
765
766 fn flush(&mut self) -> io::Result<()> {
767 Ok(())
768 }
769 }
770
771 #[test]
772 fn test_strip_markup() {
773 assert_eq!(strip_markup("[bold]Hello[/]"), "Hello");
774 assert_eq!(strip_markup("[red]A[/] [blue]B[/]"), "A B");
775 assert_eq!(strip_markup("No markup"), "No markup");
776 assert_eq!(strip_markup("[bold red on blue]Text[/]"), "Text");
777 assert_eq!(strip_markup("array[0]"), "array[0]");
778 assert_eq!(strip_markup("[#ff0000]Hex[/]"), "Hex");
779 assert_eq!(strip_markup("[link=https://example.com]Link[/]"), "Link");
780 }
781
782 #[test]
783 fn render_markdown_emits_ansi_when_tty() {
784 let buffer = Arc::new(Mutex::new(Vec::new()));
785 let writer = SharedBufferWriter {
786 buffer: Arc::clone(&buffer),
787 };
788 let console = Console::builder()
789 .markup(true)
790 .emoji(false)
791 .force_terminal(true)
792 .color_system(ColorSystem::TrueColor)
793 .file(Box::new(writer))
794 .build();
795
796 let pi_console = PiConsole {
797 console,
798 is_tty: true,
799 };
800
801 pi_console.render_markdown("# Title\n\n- Item 1\n- Item 2\n\n**bold**");
802
803 let output = String::from_utf8(
804 buffer
805 .lock()
806 .unwrap_or_else(std::sync::PoisonError::into_inner)
807 .clone(),
808 )
809 .expect("utf-8");
810
811 assert!(
812 output.contains("\u{1b}["),
813 "expected ANSI escape codes, got: {output:?}"
814 );
815 assert!(!output.contains("**bold**"));
816 assert!(output.contains("bold"));
817 }
818
819 #[test]
820 fn test_spinner_frames() {
821 let dots = SpinnerStyle::Dots;
822 assert_eq!(dots.frames().len(), 10);
823 assert_eq!(dots.interval_ms(), 80);
824
825 let simple = SpinnerStyle::Simple;
826 assert_eq!(simple.frames().len(), 4);
827 }
828
829 #[test]
830 fn test_console_creation() {
831 let console = PiConsole::with_color();
832 assert!(console.width() > 0);
833 }
834
835 #[test]
836 fn render_markdown_produces_styled_segments() {
837 let console = PiConsole::with_color();
838
839 console.console.begin_capture();
840 console.render_markdown("# Title\n\nThis is **bold**.\n\n- Item 1\n- Item 2");
841 let segments = console.console.end_capture();
842
843 let captured: String = segments.iter().map(|s| s.text.as_ref()).collect();
844 assert!(captured.contains("Title"));
845 assert!(captured.contains("bold"));
846 assert!(segments.iter().any(|s| s.style.is_some()));
847 }
848
849 #[test]
850 fn render_markdown_code_fence_uses_syntax_highlighting_when_language_present() {
851 let console = PiConsole::with_color();
852
853 console.console.begin_capture();
854 console.render_markdown("```rust\nfn main() {\n println!(\"hi\");\n}\n```");
855 let segments = console.console.end_capture();
856
857 let code_styles = unique_style_debug_for_tokens(&segments, &["fn", "println"]);
858
859 assert!(
860 code_styles.len() > 1,
861 "expected multiple token styles from syntax highlighting, got {code_styles:?}"
862 );
863 }
864
865 #[test]
866 fn render_markdown_code_block_indent_applies_to_code_lines() {
867 let markdown = "Intro\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n";
868 let default_segments = capture_markdown_segments(markdown);
869 let indented_segments = capture_markdown_segments_with_indent(markdown, Some(4));
870
871 let default_text = segments_text(&default_segments);
872 let indented_text = segments_text(&indented_segments);
873
874 let default_line = default_text
875 .lines()
876 .find(|line| line.contains("fn main"))
877 .expect("default code line");
878 let indented_line = indented_text
879 .lines()
880 .find(|line| line.contains("fn main"))
881 .expect("indented code line");
882
883 assert!(
884 indented_line.starts_with(" "),
885 "expected indented code line, got: {indented_line:?}"
886 );
887 assert_eq!(
888 default_line.trim(),
889 indented_line.trim(),
890 "indent should only affect whitespace, not content"
891 );
892 }
893
894 #[test]
895 fn parse_fenced_code_language_extracts_first_tag() {
896 assert_eq!(parse_fenced_code_language("rust"), Some("rust".to_string()));
897 assert_eq!(
898 parse_fenced_code_language(" RuSt "),
899 Some("rust".to_string())
900 );
901 assert_eq!(
902 parse_fenced_code_language("rust,ignore"),
903 Some("rust".to_string())
904 );
905 assert_eq!(parse_fenced_code_language(""), None);
906 assert_eq!(parse_fenced_code_language(" "), None);
907 }
908
909 #[test]
910 fn split_markdown_fenced_code_blocks_splits_text_and_code() {
911 let input = "Intro\n\n```rust\nfn main() {}\n```\n\nTail\n";
912 let chunks = split_markdown_fenced_code_blocks(input);
913
914 assert_eq!(chunks.len(), 3);
915 assert!(matches!(chunks[0], MarkdownChunk::Text(_)));
916 assert!(
917 matches!(
918 &chunks[1],
919 MarkdownChunk::CodeBlock { language, code }
920 if language.as_deref() == Some("rust") && code.contains("fn main")
921 ),
922 "expected rust code block, got {chunks:?}"
923 );
924 assert!(matches!(chunks[2], MarkdownChunk::Text(_)));
925 }
926
927 #[test]
928 fn split_markdown_fenced_code_blocks_unterminated_fence_emits_as_code_block() {
929 let input = "Intro\n\n```rust\nfn main() {}\n";
930 let chunks = split_markdown_fenced_code_blocks(input);
931
932 assert_eq!(
933 chunks.len(),
934 2,
935 "expected a text chunk and an unterminated code block chunk"
936 );
937 assert!(matches!(chunks[0], MarkdownChunk::Text(ref t) if t.contains("Intro")));
938 let MarkdownChunk::CodeBlock { language, code } = &chunks[1] else {
939 unreachable!("expected code block, got {:?}", chunks[1]);
940 };
941 assert_eq!(language.as_deref(), Some("rust"));
942 assert!(code.contains("fn main"));
943 }
944
945 #[test]
946 fn render_markdown_strips_inline_markers_and_renders_headings_lists_links() {
947 let segments = capture_markdown_segments(
948 r"
949# H1
950## H2
951### H3
952#### H4
953##### H5
954###### H6
955
956This is **bold**, *italic*, ~~strike~~, `code`, and [link](https://example.com).
957
958- Bullet 1
9591. Numbered 1
960
961Nested: **bold and *italic*** and ~~**strike bold**~~.
962",
963 );
964
965 let captured = segments_text(&segments);
966 for needle in [
967 "H1",
968 "H2",
969 "H3",
970 "H4",
971 "H5",
972 "H6",
973 "bold",
974 "italic",
975 "strike",
976 "code",
977 "link",
978 "Bullet 1",
979 "Numbered 1",
980 "Nested",
981 ] {
982 assert!(
983 captured.contains(needle),
984 "expected output to contain {needle:?}, got: {captured:?}"
985 );
986 }
987
988 assert!(
989 !captured.contains("**"),
990 "expected bold markers to be stripped, got: {captured:?}"
991 );
992 assert!(
993 !captured.contains("~~"),
994 "expected strikethrough markers to be stripped, got: {captured:?}"
995 );
996 assert!(
997 !captured.contains('`'),
998 "expected inline code markers to be stripped, got: {captured:?}"
999 );
1000 assert!(
1001 !captured.contains("]("),
1002 "expected link markers to be stripped, got: {captured:?}"
1003 );
1004
1005 assert!(
1006 segments.iter().any(|s| s.style.is_some()),
1007 "expected styled segments, got: {segments:?}"
1008 );
1009 }
1010
1011 #[test]
1013 fn strip_markup_nested_tags() {
1014 assert_eq!(strip_markup("[bold][red]text[/][/]"), "text");
1015 }
1016
1017 #[test]
1018 fn strip_markup_empty_tag() {
1019 assert_eq!(strip_markup("before[]after"), "before[]after");
1021 }
1022
1023 #[test]
1024 fn strip_markup_adjacent_tags() {
1025 assert_eq!(strip_markup("[bold]A[/][red]B[/]"), "AB");
1026 }
1027
1028 #[test]
1029 fn strip_markup_only_closing_tag() {
1030 assert_eq!(strip_markup("[/]"), "");
1031 }
1032
1033 #[test]
1034 fn strip_markup_unclosed_bracket_at_end() {
1035 assert_eq!(strip_markup("text[unclosed"), "text[unclosed");
1036 }
1037
1038 #[test]
1039 fn strip_markup_bracket_with_special_chars() {
1040 assert_eq!(strip_markup("[hello!]world"), "[hello!]world");
1042 assert_eq!(strip_markup("[hello@world]text"), "[hello@world]text");
1043 }
1044
1045 #[test]
1046 fn strip_markup_pure_digits_preserved() {
1047 assert_eq!(strip_markup("array[0]"), "array[0]");
1048 assert_eq!(strip_markup("arr[123]"), "arr[123]");
1049 assert_eq!(strip_markup("x[0][1][2]"), "x[0][1][2]");
1050 }
1051
1052 #[test]
1053 fn strip_markup_mixed_digit_alpha_is_tag() {
1054 assert_eq!(strip_markup("[dim]faded[/]"), "faded");
1056 }
1057
1058 #[test]
1059 fn strip_markup_empty_input() {
1060 assert_eq!(strip_markup(""), "");
1061 }
1062
1063 #[test]
1064 fn strip_markup_no_brackets() {
1065 assert_eq!(
1066 strip_markup("plain text without brackets"),
1067 "plain text without brackets"
1068 );
1069 }
1070
1071 #[test]
1072 fn strip_markup_hash_color_tag() {
1073 assert_eq!(strip_markup("[#aabbcc]colored[/]"), "colored");
1074 }
1075
1076 #[test]
1077 fn strip_markup_tag_with_equals() {
1078 assert_eq!(strip_markup("[link=https://example.com]click[/]"), "click");
1079 }
1080
1081 #[test]
1082 fn strip_markup_multiple_lines() {
1083 let input = "[bold]line1[/]\n[red]line2[/]\n";
1084 assert_eq!(strip_markup(input), "line1\nline2\n");
1085 }
1086
1087 #[test]
1089 fn split_markdown_tilde_code_blocks() {
1090 let input = "text1\n~~~rust\ncode1\n~~~\ntext2\n";
1091 let chunks = split_markdown_fenced_code_blocks(input);
1092 assert_eq!(chunks.len(), 3, "expected 3 chunks: {chunks:?}");
1093 assert!(matches!(&chunks[0], MarkdownChunk::Text(_)));
1094 assert!(
1095 matches!(&chunks[1], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("rust"))
1096 );
1097 assert!(matches!(&chunks[2], MarkdownChunk::Text(_)));
1098 }
1099
1100 #[test]
1101 fn split_markdown_multiple_code_blocks() {
1102 let input = "text1\n```rust\ncode1\n```\ntext2\n```python\ncode2\n```\ntext3\n";
1103 let chunks = split_markdown_fenced_code_blocks(input);
1104 assert_eq!(chunks.len(), 5, "expected 5 chunks: {chunks:?}");
1105 assert!(matches!(&chunks[0], MarkdownChunk::Text(_)));
1106 assert!(
1107 matches!(&chunks[1], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("rust"))
1108 );
1109 assert!(matches!(&chunks[2], MarkdownChunk::Text(_)));
1110 assert!(
1111 matches!(&chunks[3], MarkdownChunk::CodeBlock { language, .. } if language.as_deref() == Some("python"))
1112 );
1113 assert!(matches!(&chunks[4], MarkdownChunk::Text(_)));
1114 }
1115
1116 #[test]
1117 fn split_markdown_code_block_no_language() {
1118 let input = "```\nplain code\n```\n";
1119 let chunks = split_markdown_fenced_code_blocks(input);
1120 assert_eq!(chunks.len(), 1);
1121 assert!(matches!(
1122 &chunks[0],
1123 MarkdownChunk::CodeBlock { language, code }
1124 if language.is_none() && code.contains("plain code")
1125 ));
1126 }
1127
1128 #[test]
1129 fn split_markdown_empty_code_block() {
1130 let input = "```rust\n```\n";
1131 let chunks = split_markdown_fenced_code_blocks(input);
1132 assert_eq!(chunks.len(), 1);
1133 assert!(matches!(
1134 &chunks[0],
1135 MarkdownChunk::CodeBlock { language, code }
1136 if language.as_deref() == Some("rust") && code.is_empty()
1137 ));
1138 }
1139
1140 #[test]
1141 fn split_markdown_four_backtick_fence() {
1142 let input = "````rust\ncode\n````\n";
1144 let chunks = split_markdown_fenced_code_blocks(input);
1145 assert_eq!(chunks.len(), 1);
1146 assert!(matches!(&chunks[0], MarkdownChunk::CodeBlock { .. }));
1147 }
1148
1149 #[test]
1150 fn split_markdown_nested_fence_shorter_doesnt_close() {
1151 let input = "````\nsome ```inner``` text\n````\n";
1153 let chunks = split_markdown_fenced_code_blocks(input);
1154 assert_eq!(chunks.len(), 1);
1155 assert!(matches!(
1156 &chunks[0],
1157 MarkdownChunk::CodeBlock { code, .. }
1158 if code.contains("```inner```")
1159 ));
1160 }
1161
1162 #[test]
1163 fn split_markdown_no_code_blocks() {
1164 let input = "Just plain markdown\n\n# Heading\n";
1165 let chunks = split_markdown_fenced_code_blocks(input);
1166 assert_eq!(chunks.len(), 1);
1167 assert!(matches!(&chunks[0], MarkdownChunk::Text(t) if t.contains("plain markdown")));
1168 }
1169
1170 #[test]
1171 fn split_markdown_code_block_at_start() {
1172 let input = "```js\nconsole.log('hi')\n```\ntext after";
1173 let chunks = split_markdown_fenced_code_blocks(input);
1174 assert_eq!(chunks.len(), 2);
1175 assert!(matches!(&chunks[0], MarkdownChunk::CodeBlock { .. }));
1176 assert!(matches!(&chunks[1], MarkdownChunk::Text(t) if t.contains("text after")));
1177 }
1178
1179 #[test]
1181 fn has_multiple_styles_empty() {
1182 assert!(!has_multiple_non_none_styles(&[]));
1183 }
1184
1185 #[test]
1186 fn has_multiple_styles_all_none() {
1187 let segments = vec![Segment::plain("text1"), Segment::plain("text2")];
1188 assert!(!has_multiple_non_none_styles(&segments));
1189 }
1190
1191 #[test]
1192 fn has_multiple_styles_single_style() {
1193 let style = Style::parse("bold").unwrap();
1194 let segments = vec![
1195 Segment::styled("text1", style.clone()),
1196 Segment::styled("text2", style),
1197 ];
1198 assert!(!has_multiple_non_none_styles(&segments));
1199 }
1200
1201 #[test]
1202 fn has_multiple_styles_two_different() {
1203 let bold = Style::parse("bold").unwrap();
1204 let red = Style::parse("red").unwrap();
1205 let segments = vec![
1206 Segment::styled("text1", bold),
1207 Segment::styled("text2", red),
1208 ];
1209 assert!(has_multiple_non_none_styles(&segments));
1210 }
1211
1212 #[test]
1213 fn has_multiple_styles_ignores_whitespace_only() {
1214 let bold = Style::parse("bold").unwrap();
1215 let red = Style::parse("red").unwrap();
1216 let segments = vec![
1217 Segment::styled("text1", bold),
1218 Segment::styled(" ", red), ];
1220 assert!(!has_multiple_non_none_styles(&segments));
1221 }
1222
1223 #[test]
1225 fn spinner_line_frames_and_interval() {
1226 let line = SpinnerStyle::Line;
1227 assert_eq!(line.frames().len(), 6);
1228 assert_eq!(line.interval_ms(), 100);
1229 }
1230
1231 #[test]
1232 fn spinner_all_frames_non_empty() {
1233 for style in [SpinnerStyle::Dots, SpinnerStyle::Line, SpinnerStyle::Simple] {
1234 for frame in style.frames() {
1235 assert!(!frame.is_empty(), "empty frame in {:?}", style.frames());
1236 }
1237 }
1238 }
1239
1240 #[test]
1242 fn parse_fenced_code_language_with_info_string() {
1243 assert_eq!(
1245 parse_fenced_code_language("rust,no_run"),
1246 Some("rust".to_string())
1247 );
1248 }
1249
1250 #[test]
1251 fn parse_fenced_code_language_with_space_and_attr() {
1252 assert_eq!(
1254 parse_fenced_code_language("python {.highlight}"),
1255 Some("python".to_string())
1256 );
1257 }
1258
1259 #[test]
1260 fn render_markdown_code_fences_highlight_multiple_languages_and_fallback_unknown() {
1261 let segments = capture_markdown_segments(
1262 r#"
1263```rust
1264fn main() { println!("hi"); }
1265```
1266
1267```python
1268def foo():
1269 print("hi")
1270```
1271
1272```javascript
1273function foo() { console.log("hi"); }
1274```
1275
1276```typescript
1277interface Foo { x: number }
1278const foo: Foo = { x: 1 };
1279const greeting = "hi";
1280```
1281
1282```notalanguage
1283some_code_here();
1284```
1285"#,
1286 );
1287
1288 for (language, tokens) in [
1289 ("rust", vec!["fn", "println", "\"hi\""]),
1290 ("python", vec!["def", "print", "\"hi\""]),
1291 ("javascript", vec!["function", "console", "\"hi\""]),
1292 ("typescript", vec!["interface", "const", "\"hi\""]),
1293 ] {
1294 let styles = unique_style_debug_for_tokens(&segments, &tokens);
1295 assert!(
1296 styles.len() > 1,
1297 "expected multiple styles for {language} tokens {tokens:?}, got {styles:?}"
1298 );
1299 }
1300
1301 let captured = segments_text(&segments);
1302 assert!(
1303 captured.contains("some_code_here"),
1304 "expected unknown language fence to still render code, got: {captured:?}"
1305 );
1306 }
1307}