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