Skip to main content

winx_code_agent/state/
ansi_codes.rs

1//! ANSI terminal code definitions and handlers
2//!
3//! This module provides constants and utilities for handling ANSI escape codes
4//! commonly used in terminal output, focusing on rich text formatting including
5//! 24-bit true color and extended attribute support.
6
7use regex::Regex;
8use std::collections::HashMap;
9use std::str::FromStr;
10use std::sync::OnceLock;
11
12static ANSI_REGEX: OnceLock<Regex> = OnceLock::new();
13const BASIC_COLORS: &[(&str, u8)] = &[
14    ("black", 0),
15    ("red", 1),
16    ("green", 2),
17    ("yellow", 3),
18    ("blue", 4),
19    ("magenta", 5),
20    ("cyan", 6),
21    ("white", 7),
22    ("brightblack", 8),
23    ("brightred", 9),
24    ("brightgreen", 10),
25    ("brightyellow", 11),
26    ("brightblue", 12),
27    ("brightmagenta", 13),
28    ("brightcyan", 14),
29    ("brightwhite", 15),
30];
31
32fn ansi_regex() -> &'static Regex {
33    // `.expect`, not `.ok()`: a compile-time-literal regex that fails is a dev bug.
34    // The old `OnceLock<Result<..>>` + `.ok()` froze the error on first use, then
35    // silently disabled all ANSI stripping for the rest of the process (poisoning
36    // every subsequent output with raw escape sequences). Fail loud instead.
37    #[allow(clippy::expect_used)]
38    ANSI_REGEX.get_or_init(|| {
39        Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])").expect("ANSI regex must compile")
40    })
41}
42
43/// Basic ANSI control codes
44pub mod control {
45    /// Bell
46    pub const BEL: &str = "\x07";
47    /// Backspace
48    pub const BS: &str = "\x08";
49    /// Horizontal tab
50    pub const HT: &str = "\x09";
51    /// Line feed
52    pub const LF: &str = "\x0A";
53    /// Vertical tab
54    pub const VT: &str = "\x0B";
55    /// Form feed
56    pub const FF: &str = "\x0C";
57    /// Carriage return
58    pub const CR: &str = "\x0D";
59    /// Escape
60    pub const ESC: &str = "\x1B";
61    /// Delete
62    pub const DEL: &str = "\x7F";
63}
64
65/// CSI (Control Sequence Introducer) sequences
66pub mod csi {
67    /// CSI sequence start
68    pub const CSI: &str = "\x1B[";
69
70    /// Cursor Up
71    pub fn cursor_up(n: usize) -> String {
72        format!("\x1B[{n}A")
73    }
74
75    /// Cursor Down
76    pub fn cursor_down(n: usize) -> String {
77        format!("\x1B[{n}B")
78    }
79
80    /// Cursor Forward
81    pub fn cursor_forward(n: usize) -> String {
82        format!("\x1B[{n}C")
83    }
84
85    /// Cursor Back
86    pub fn cursor_back(n: usize) -> String {
87        format!("\x1B[{n}D")
88    }
89
90    /// Cursor Next Line
91    pub fn cursor_next_line(n: usize) -> String {
92        format!("\x1B[{n}E")
93    }
94
95    /// Cursor Previous Line
96    pub fn cursor_prev_line(n: usize) -> String {
97        format!("\x1B[{n}F")
98    }
99
100    /// Cursor Horizontal Absolute
101    pub fn cursor_horizontal(n: usize) -> String {
102        format!("\x1B[{n}G")
103    }
104
105    /// Cursor Position (row, column)
106    pub fn cursor_position(row: usize, col: usize) -> String {
107        format!("\x1B[{row};{col}H")
108    }
109
110    /// Erase in Display
111    pub fn erase_in_display(n: usize) -> String {
112        format!("\x1B[{n}J")
113    }
114
115    /// Erase in Line
116    pub fn erase_in_line(n: usize) -> String {
117        format!("\x1B[{n}K")
118    }
119
120    /// Scroll Up
121    pub fn scroll_up(n: usize) -> String {
122        format!("\x1B[{n}S")
123    }
124
125    /// Scroll Down
126    pub fn scroll_down(n: usize) -> String {
127        format!("\x1B[{n}T")
128    }
129
130    /// Request Cursor Position
131    pub const REQUEST_CURSOR_POSITION: &str = "\x1B[6n";
132
133    /// Save Cursor Position
134    pub const SAVE_CURSOR_POSITION: &str = "\x1B[s";
135
136    /// Restore Cursor Position
137    pub const RESTORE_CURSOR_POSITION: &str = "\x1B[u";
138
139    /// Hide Cursor
140    pub const HIDE_CURSOR: &str = "\x1B[?25l";
141
142    /// Show Cursor
143    pub const SHOW_CURSOR: &str = "\x1B[?25h";
144
145    /// Enable Alternative Screen Buffer
146    pub const ENABLE_ALT_SCREEN: &str = "\x1B[?1049h";
147
148    /// Disable Alternative Screen Buffer
149    pub const DISABLE_ALT_SCREEN: &str = "\x1B[?1049l";
150}
151
152/// SGR (Select Graphic Rendition) for text styling
153pub mod sgr {
154    /// Reset all attributes
155    pub const RESET: &str = "\x1B[0m";
156
157    /// Bold
158    pub const BOLD: &str = "\x1B[1m";
159
160    /// Faint/Dim
161    pub const DIM: &str = "\x1B[2m";
162
163    /// Italic
164    pub const ITALIC: &str = "\x1B[3m";
165
166    /// Underline
167    pub const UNDERLINE: &str = "\x1B[4m";
168
169    /// Slow Blink
170    pub const BLINK: &str = "\x1B[5m";
171
172    /// Rapid Blink
173    pub const RAPID_BLINK: &str = "\x1B[6m";
174
175    /// Reverse Video
176    pub const REVERSE: &str = "\x1B[7m";
177
178    /// Conceal/Hide
179    pub const CONCEAL: &str = "\x1B[8m";
180
181    /// Crossed-out/Strike
182    pub const STRIKE: &str = "\x1B[9m";
183
184    /// Primary/Default Font
185    pub const PRIMARY_FONT: &str = "\x1B[10m";
186
187    /// Alternative Font 1-9
188    pub fn alt_font(n: usize) -> String {
189        if !(1..=9).contains(&n) {
190            return String::new();
191        }
192        format!("\x1B[{}m", 10 + n)
193    }
194
195    /// Fraktur (Gothic)
196    pub const FRAKTUR: &str = "\x1B[20m";
197
198    /// Double Underline
199    pub const DOUBLE_UNDERLINE: &str = "\x1B[21m";
200
201    /// Normal Intensity (not bold and not faint)
202    pub const NORMAL_INTENSITY: &str = "\x1B[22m";
203
204    /// Not Italic, Not Fraktur
205    pub const NO_ITALIC: &str = "\x1B[23m";
206
207    /// Not Underlined
208    pub const NO_UNDERLINE: &str = "\x1B[24m";
209
210    /// Not Blinking
211    pub const NO_BLINK: &str = "\x1B[25m";
212
213    /// Proportional Spacing
214    pub const PROPORTIONAL_SPACING: &str = "\x1B[26m";
215
216    /// Not Reversed
217    pub const NO_REVERSE: &str = "\x1B[27m";
218
219    /// Reveal (Not Concealed)
220    pub const REVEAL: &str = "\x1B[28m";
221
222    /// Not Crossed Out
223    pub const NO_STRIKE: &str = "\x1B[29m";
224
225    /// Foreground Color (30-37 for basic colors, 90-97 for bright)
226    pub fn fg_color(n: usize) -> String {
227        format!("\x1B[{n}m")
228    }
229
230    /// Background Color (40-47 for basic colors, 100-107 for bright)
231    pub fn bg_color(n: usize) -> String {
232        format!("\x1B[{n}m")
233    }
234
235    /// 8-bit Foreground Color (0-255)
236    pub fn fg_color_256(n: u8) -> String {
237        format!("\x1B[38;5;{n}m")
238    }
239
240    /// 8-bit Background Color (0-255)
241    pub fn bg_color_256(n: u8) -> String {
242        format!("\x1B[48;5;{n}m")
243    }
244
245    /// 24-bit Foreground Color (RGB)
246    pub fn fg_color_rgb(r: u8, g: u8, b: u8) -> String {
247        format!("\x1B[38;2;{r};{g};{b}m")
248    }
249
250    /// 24-bit Background Color (RGB)
251    pub fn bg_color_rgb(r: u8, g: u8, b: u8) -> String {
252        format!("\x1B[48;2;{r};{g};{b}m")
253    }
254
255    /// Default Foreground Color
256    pub const DEFAULT_FG: &str = "\x1B[39m";
257
258    /// Default Background Color
259    pub const DEFAULT_BG: &str = "\x1B[49m";
260
261    /// Disable Proportional Spacing
262    pub const NO_PROPORTIONAL_SPACING: &str = "\x1B[50m";
263
264    /// Framed
265    pub const FRAMED: &str = "\x1B[51m";
266
267    /// Encircled
268    pub const ENCIRCLED: &str = "\x1B[52m";
269
270    /// Overlined
271    pub const OVERLINED: &str = "\x1B[53m";
272
273    /// Not Framed, Not Encircled
274    pub const NO_FRAMED: &str = "\x1B[54m";
275
276    /// Not Overlined
277    pub const NO_OVERLINED: &str = "\x1B[55m";
278
279    /// Ideogram Underline
280    pub const IDEOGRAM_UNDERLINE: &str = "\x1B[60m";
281
282    /// Ideogram Double Underline
283    pub const IDEOGRAM_DOUBLE_UNDERLINE: &str = "\x1B[61m";
284
285    /// Ideogram Overline
286    pub const IDEOGRAM_OVERLINE: &str = "\x1B[62m";
287
288    /// Ideogram Double Overline
289    pub const IDEOGRAM_DOUBLE_OVERLINE: &str = "\x1B[63m";
290
291    /// Ideogram Stress Marking
292    pub const IDEOGRAM_STRESS: &str = "\x1B[64m";
293
294    /// No Ideogram Attributes
295    pub const NO_IDEOGRAM: &str = "\x1B[65m";
296
297    /// Superscript
298    pub const SUPERSCRIPT: &str = "\x1B[73m";
299
300    /// Subscript
301    pub const SUBSCRIPT: &str = "\x1B[74m";
302
303    /// Neither Superscript nor Subscript
304    pub const NO_SCRIPT: &str = "\x1B[75m";
305}
306
307/// OSC (Operating System Command) sequences
308pub mod osc {
309    /// Set window title
310    pub fn set_title(title: &str) -> String {
311        format!("\x1B]0;{title}\x07")
312    }
313
314    /// Set window and icon title
315    pub fn set_window_icon_title(title: &str) -> String {
316        format!("\x1B]2;{title}\x07")
317    }
318
319    /// Set icon title
320    pub fn set_icon_title(title: &str) -> String {
321        format!("\x1B]1;{title}\x07")
322    }
323
324    /// Set color definition
325    pub fn set_color(num: u8, rgb: &str) -> String {
326        format!("\x1B]4;{num};{rgb}\x07")
327    }
328
329    /// Hyperlink
330    pub fn hyperlink(url: &str, text: &str) -> String {
331        format!("\x1B]8;;{url}\x07{text}\x1B]8;;\x07")
332    }
333}
334
335/// Mouse reporting modes
336pub mod mouse {
337    /// Enable normal mouse tracking
338    pub const NORMAL_TRACKING: &str = "\x1B[?1000h";
339
340    /// Disable normal mouse tracking
341    pub const NO_NORMAL_TRACKING: &str = "\x1B[?1000l";
342
343    /// Enable highlight mouse tracking
344    pub const HIGHLIGHT_TRACKING: &str = "\x1B[?1001h";
345
346    /// Disable highlight mouse tracking
347    pub const NO_HIGHLIGHT_TRACKING: &str = "\x1B[?1001l";
348
349    /// Enable button-event tracking
350    pub const BUTTON_EVENT_TRACKING: &str = "\x1B[?1002h";
351
352    /// Disable button-event tracking
353    pub const NO_BUTTON_EVENT_TRACKING: &str = "\x1B[?1002l";
354
355    /// Enable any-event tracking
356    pub const ANY_EVENT_TRACKING: &str = "\x1B[?1003h";
357
358    /// Disable any-event tracking
359    pub const NO_ANY_EVENT_TRACKING: &str = "\x1B[?1003l";
360
361    /// Enable focus tracking
362    pub const FOCUS_TRACKING: &str = "\x1B[?1004h";
363
364    /// Disable focus tracking
365    pub const NO_FOCUS_TRACKING: &str = "\x1B[?1004l";
366
367    /// Enable extended mouse coordinates
368    pub const EXTENDED_COORDINATES: &str = "\x1B[?1006h";
369
370    /// Disable extended mouse coordinates
371    pub const NO_EXTENDED_COORDINATES: &str = "\x1B[?1006l";
372
373    /// Enable SGR mouse coordinates
374    pub const SGR_COORDINATES: &str = "\x1B[?1016h";
375
376    /// Disable SGR mouse coordinates
377    pub const NO_SGR_COORDINATES: &str = "\x1B[?1016l";
378}
379
380/// Mode switching sequences
381pub mod modes {
382    /// Application Cursor Keys (DECCKM)
383    pub const APPLICATION_CURSOR_KEYS: &str = "\x1B[?1h";
384
385    /// Normal Cursor Keys
386    pub const NORMAL_CURSOR_KEYS: &str = "\x1B[?1l";
387
388    /// ANSI Mode (vs VT52)
389    pub const ANSI_MODE: &str = "\x1B[?2h";
390
391    /// VT52 Mode
392    pub const VT52_MODE: &str = "\x1B[?2l";
393
394    /// 132 Column Mode (DECCOLM)
395    pub const MODE_132_COLUMN: &str = "\x1B[?3h";
396
397    /// 80 Column Mode
398    pub const MODE_80_COLUMN: &str = "\x1B[?3l";
399
400    /// Smooth Scroll (DECSCLM)
401    pub const SMOOTH_SCROLL: &str = "\x1B[?4h";
402
403    /// Jump Scroll
404    pub const JUMP_SCROLL: &str = "\x1B[?4l";
405
406    /// Reverse Screen (DECSCNM)
407    pub const REVERSE_SCREEN: &str = "\x1B[?5h";
408
409    /// Normal Screen
410    pub const NORMAL_SCREEN: &str = "\x1B[?5l";
411
412    /// Application Keypad (DECNKM)
413    pub const APPLICATION_KEYPAD: &str = "\x1B[?66h";
414
415    /// Numeric Keypad
416    pub const NUMERIC_KEYPAD: &str = "\x1B[?66l";
417
418    /// Wraparound Mode (DECAWM)
419    pub const WRAPAROUND: &str = "\x1B[?7h";
420
421    /// No Wraparound
422    pub const NO_WRAPAROUND: &str = "\x1B[?7l";
423
424    /// Auto-repeat Keys (DECARM)
425    pub const AUTOREPEAT_KEYS: &str = "\x1B[?8h";
426
427    /// No Auto-repeat Keys
428    pub const NO_AUTOREPEAT_KEYS: &str = "\x1B[?8l";
429
430    /// Send Mouse X & Y on button press
431    pub const MOUSE_TRACKING: &str = "\x1B[?9h";
432
433    /// No Mouse Tracking
434    pub const NO_MOUSE_TRACKING: &str = "\x1B[?9l";
435
436    /// Show toolbar (rxvt)
437    pub const SHOW_TOOLBAR: &str = "\x1B[?10h";
438
439    /// Hide toolbar
440    pub const HIDE_TOOLBAR: &str = "\x1B[?10l";
441
442    /// Start Blinking Cursor
443    pub const BLINKING_CURSOR: &str = "\x1B[?12h";
444
445    /// Stop Blinking Cursor
446    pub const NO_BLINKING_CURSOR: &str = "\x1B[?12l";
447
448    /// Print Form Feed (DECPFF)
449    pub const PRINT_FORM_FEED: &str = "\x1B[?18h";
450
451    /// No Form Feed
452    pub const NO_PRINT_FORM_FEED: &str = "\x1B[?18l";
453
454    /// Set Print Screen (DECPEX)
455    pub const PRINT_SCREEN: &str = "\x1B[?19h";
456
457    /// No Print Screen
458    pub const NO_PRINT_SCREEN: &str = "\x1B[?19l";
459
460    /// Enable Linefeed/Newline Mode (LNM)
461    pub const NEWLINE_MODE: &str = "\x1B[20h";
462
463    /// Disable Linefeed/Newline Mode
464    pub const NO_NEWLINE_MODE: &str = "\x1B[20l";
465}
466
467/// Terminal color code definitions
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum TermColor {
470    /// Standard basic color (0-15)
471    Basic(u8),
472
473    /// 256-color mode (0-255)
474    Color256(u8),
475
476    /// 24-bit RGB color
477    TrueColor {
478        /// Red component (0-255)
479        r: u8,
480        /// Green component (0-255)
481        g: u8,
482        /// Blue component (0-255)
483        b: u8,
484    },
485}
486
487impl TermColor {
488    /// Get the ANSI code for foreground color
489    pub fn fg_code(&self) -> String {
490        match self {
491            TermColor::Basic(n) if *n < 8 => format!("\x1B[{}m", 30 + n),
492            TermColor::Basic(n) if *n < 16 => format!("\x1B[{}m", 82 + n),
493            TermColor::Basic(n) | TermColor::Color256(n) => sgr::fg_color_256(*n),
494            TermColor::TrueColor { r, g, b } => sgr::fg_color_rgb(*r, *g, *b),
495        }
496    }
497
498    /// Get the ANSI code for background color
499    pub fn bg_code(&self) -> String {
500        match self {
501            TermColor::Basic(n) if *n < 8 => format!("\x1B[{}m", 40 + n),
502            TermColor::Basic(n) if *n < 16 => format!("\x1B[{}m", 92 + n),
503            TermColor::Basic(n) | TermColor::Color256(n) => sgr::bg_color_256(*n),
504            TermColor::TrueColor { r, g, b } => sgr::bg_color_rgb(*r, *g, *b),
505        }
506    }
507}
508
509/// Parse ANSI escape sequences in text
510///
511/// This extracts all escape sequences based on their type and position
512///
513/// # Arguments
514///
515/// * `text` - The text containing ANSI escape sequences
516///
517/// # Returns
518///
519/// A vector of (position, sequence) tuples
520pub fn parse_ansi_sequences(text: &str) -> Vec<(usize, String)> {
521    ansi_regex().find_iter(text).map(|m| (m.start(), m.as_str().to_string())).collect()
522}
523
524/// Color name to ANSI code mapping
525pub fn color_name_to_code(name: &str) -> Option<TermColor> {
526    // Try as a basic color name
527    if let Some((_, code)) = BASIC_COLORS.iter().find(|(color, _)| color.eq_ignore_ascii_case(name))
528    {
529        return Some(TermColor::Basic(*code));
530    }
531
532    // Try as a color number first (before hex parsing)
533    if let Ok(num) = u8::from_str(name) {
534        return Some(TermColor::Color256(num));
535    }
536
537    // Try as a hex color (only if it starts with # or is clearly hex)
538    if name.starts_with('#') || (name.len() == 6 && name.chars().all(|c| c.is_ascii_hexdigit())) {
539        if let Some(color) = parse_hex_color(name) {
540            return Some(color);
541        }
542    }
543
544    None
545}
546
547/// Parse a hex color into `TermColor`
548///
549/// Supports formats like #RGB, #RRGGBB
550fn parse_hex_color(hex: &str) -> Option<TermColor> {
551    let hex = hex.trim_start_matches('#');
552
553    match hex.len() {
554        3 => {
555            // #RGB format
556            let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
557            let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
558            let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
559
560            // Convert from 0-15 to 0-255 range
561            let r = r * 17;
562            let g = g * 17;
563            let b = b * 17;
564
565            Some(TermColor::TrueColor { r, g, b })
566        }
567        6 => {
568            // #RRGGBB format
569            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
570            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
571            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
572
573            Some(TermColor::TrueColor { r, g, b })
574        }
575        _ => None,
576    }
577}
578
579/// Format text with ANSI styling
580///
581/// This is a convenience function to easily add common ANSI styles
582///
583/// # Arguments
584///
585/// * `text` - The text to format
586/// * `bold` - Whether to make the text bold
587/// * `italic` - Whether to make the text italic
588/// * `underline` - Whether to underline the text
589/// * `fg_color` - Optional foreground color
590/// * `bg_color` - Optional background color
591///
592/// # Returns
593///
594/// The formatted text with ANSI codes
595pub fn format_ansi_text(
596    text: &str,
597    bold: bool,
598    italic: bool,
599    underline: bool,
600    fg_color: Option<&TermColor>,
601    bg_color: Option<&TermColor>,
602) -> String {
603    let mut result = String::new();
604
605    // Add style codes
606    if bold {
607        result.push_str(sgr::BOLD);
608    }
609    if italic {
610        result.push_str(sgr::ITALIC);
611    }
612    if underline {
613        result.push_str(sgr::UNDERLINE);
614    }
615
616    // Add color codes
617    if let Some(color) = fg_color {
618        result.push_str(&color.fg_code());
619    }
620    if let Some(color) = bg_color {
621        result.push_str(&color.bg_code());
622    }
623
624    // Add text and reset
625    result.push_str(text);
626    result.push_str(sgr::RESET);
627
628    result
629}
630
631/// Strip all ANSI escape sequences from text
632///
633/// # Arguments
634///
635/// * `text` - The text containing ANSI escape sequences
636///
637/// # Returns
638///
639/// The text with all ANSI sequences removed
640pub fn strip_ansi_codes(text: &str) -> String {
641    // Delegate to the terminal module's stripper, which also handles OSC sequences
642    // and stray ESC bytes. Keeping two divergent implementations meant this weaker
643    // one (CSI-only) could silently under-strip if a caller picked it. Single source
644    // of truth now.
645    crate::state::terminal::strip_ansi_codes(text)
646}
647
648/// Extract structured style information from ANSI-formatted text
649///
650/// # Arguments
651///
652/// * `text` - The text with ANSI escape sequences
653///
654/// # Returns
655///
656/// A vector of (position, styles) where styles is a map of active styles
657pub fn extract_ansi_styles(text: &str) -> Vec<(usize, HashMap<String, String>)> {
658    let mut result = Vec::new();
659    let mut current_styles = HashMap::new();
660    let mut pos = 0;
661
662    for (idx, seq) in parse_ansi_sequences(text) {
663        // Adjust position for any actual text content
664        if idx > pos {
665            result.push((pos, current_styles.clone()));
666            // No need to update pos here as it's updated below
667        }
668
669        // Process the sequence and update styles
670        if seq == sgr::RESET {
671            current_styles.clear();
672        } else if seq == sgr::BOLD {
673            current_styles.insert("weight".to_string(), "bold".to_string());
674        } else if seq == sgr::ITALIC {
675            current_styles.insert("style".to_string(), "italic".to_string());
676        } else if seq == sgr::UNDERLINE {
677            current_styles.insert("text-decoration".to_string(), "underline".to_string());
678        } else if seq.starts_with("\x1B[38;") {
679            current_styles.insert("color".to_string(), extract_color_from_seq(&seq));
680        } else if seq.starts_with("\x1B[48;") {
681            current_styles.insert("background-color".to_string(), extract_color_from_seq(&seq));
682        }
683
684        // Move position past the sequence
685        pos = idx + seq.len();
686    }
687
688    // Add final segment if needed
689    if pos < text.len() {
690        result.push((pos, current_styles));
691    }
692
693    result
694}
695
696/// Extract color information from an SGR color sequence
697fn extract_color_from_seq(seq: &str) -> String {
698    if seq.starts_with("\x1B[38;5;") || seq.starts_with("\x1B[48;5;") {
699        // 8-bit color
700        let parts: Vec<&str> = seq.split(';').collect();
701        if parts.len() >= 3 {
702            if let Some(color_part) = parts[2].strip_suffix('m') {
703                return format!("color-{color_part}");
704            }
705        }
706    } else if seq.starts_with("\x1B[38;2;") || seq.starts_with("\x1B[48;2;") {
707        // 24-bit color
708        let parts: Vec<&str> = seq.split(';').collect();
709        if parts.len() >= 5 {
710            let r = parts[2];
711            let g = parts[3];
712            let b =
713                if let Some(stripped) = parts[4].strip_suffix('m') { stripped } else { parts[4] };
714            return format!("rgb({r},{g},{b})");
715        }
716    }
717
718    "unknown".to_string()
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn test_term_color() {
727        let red = TermColor::Basic(1);
728        assert_eq!(red.fg_code(), "\x1B[31m");
729        assert_eq!(red.bg_code(), "\x1B[41m");
730
731        let color256 = TermColor::Color256(128);
732        assert_eq!(color256.fg_code(), "\x1B[38;5;128m");
733        assert_eq!(color256.bg_code(), "\x1B[48;5;128m");
734
735        let true_color = TermColor::TrueColor { r: 255, g: 128, b: 64 };
736        assert_eq!(true_color.fg_code(), "\x1B[38;2;255;128;64m");
737        assert_eq!(true_color.bg_code(), "\x1B[48;2;255;128;64m");
738    }
739
740    #[test]
741    fn test_color_name_to_code() {
742        assert_eq!(color_name_to_code("red"), Some(TermColor::Basic(1)));
743        assert_eq!(color_name_to_code("brightblue"), Some(TermColor::Basic(12)));
744        assert_eq!(color_name_to_code("123"), Some(TermColor::Color256(123)));
745
746        assert_eq!(
747            color_name_to_code("#ff00ff"),
748            Some(TermColor::TrueColor { r: 255, g: 0, b: 255 })
749        );
750
751        assert_eq!(color_name_to_code("#f0f"), Some(TermColor::TrueColor { r: 255, g: 0, b: 255 }));
752    }
753
754    #[test]
755    fn test_format_ansi_text() {
756        let text = format_ansi_text("Hello", true, false, true, Some(&TermColor::Basic(1)), None);
757        assert_eq!(text, "\x1B[1m\x1B[4m\x1B[31mHello\x1B[0m");
758    }
759
760    #[test]
761    fn test_strip_ansi_codes() {
762        let input = "\x1B[1m\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m";
763        let output = strip_ansi_codes(input);
764        assert_eq!(output, "Hello World");
765    }
766
767    #[test]
768    fn test_parse_ansi_sequences() {
769        let input = "Normal \x1B[1mBold\x1B[0m Normal";
770        let sequences = parse_ansi_sequences(input);
771        assert_eq!(sequences.len(), 2);
772        assert_eq!(sequences[0], (7, "\x1B[1m".to_string()));
773        assert_eq!(sequences[1], (15, "\x1B[0m".to_string()));
774    }
775}