Skip to main content

click/
termui.rs

1//! Terminal UI utilities for click-rs.
2//!
3//! This module provides terminal input/output functions for building interactive
4//! command-line applications. It includes styled output, user prompts, progress bars,
5//! and terminal utilities.
6//!
7//! # Features
8//!
9//! - **Output Functions**: `echo`, `secho`, and `style` for formatted terminal output
10//! - **Input Functions**: `prompt`, `confirm`, `getchar`, and `pause` for user input
11//! - **Progress Bars**: Visual progress indication with ETA and percentage
12//! - **Terminal Utilities**: Screen clearing, size detection, TTY checks
13//!
14//! # Example
15//!
16//! ```no_run
17//! use click::termui::{echo, secho, style, prompt, confirm, Color};
18//!
19//! // Simple output
20//! echo("Hello, world!", true, false, None);
21//!
22//! // Styled output
23//! secho("Success!", Some(Color::Green), None, true, false, false, false, false, false, false, true, true, false, None);
24//!
25//! // Create a styled string
26//! let styled = style("Error", Some(Color::Red), None, true, false, false, false, false, false, false, false);
27//!
28//! // Prompt for input
29//! let name = prompt("Enter your name", Some("World".to_string()), false, false, |s| Ok(s.to_string())).unwrap();
30//!
31//! // Confirmation
32//! if confirm("Continue?", Some(true), false).unwrap() {
33//!     // proceed
34//! }
35//! ```
36
37use std::io::{self, BufRead, Write};
38use std::process::{Command as ProcessCommand, Stdio};
39use std::time::Instant;
40
41use crate::error::{ClickError, Result};
42
43// ============================================================================
44// Echo Macro
45// ============================================================================
46
47/// Convenience macro for printing to the terminal.
48///
49/// This macro wraps the `echo` function with a simpler syntax.
50///
51/// # Usage
52///
53/// ```no_run
54/// use click::echo;
55///
56/// // Simple message with newline
57/// echo!("Hello, world!");
58///
59/// // Without newline
60/// echo!("Prompt: ", nl = false);
61///
62/// // To stderr
63/// echo!("Error occurred", err = true);
64///
65/// // Combined
66/// echo!("Warning: ", nl = false, err = true);
67///
68/// // With formatting
69/// let name = "World";
70/// echo!("Hello, {}!", name);
71/// ```
72#[macro_export]
73macro_rules! echo {
74    // Basic message
75    ($msg:expr) => {
76        $crate::termui::echo($msg, true, false, None)
77    };
78    // Message with format args
79    ($fmt:expr, $($arg:tt)*) => {{
80        // Check if first arg after format is a keyword arg
81        echo!(@parse $fmt, $($arg)*)
82    }};
83    // Parse keyword arguments
84    (@parse $fmt:expr, nl = $nl:expr) => {
85        $crate::termui::echo($fmt, $nl, false, None)
86    };
87    (@parse $fmt:expr, err = $err:expr) => {
88        $crate::termui::echo($fmt, true, $err, None)
89    };
90    (@parse $fmt:expr, nl = $nl:expr, err = $err:expr) => {
91        $crate::termui::echo($fmt, $nl, $err, None)
92    };
93    (@parse $fmt:expr, err = $err:expr, nl = $nl:expr) => {
94        $crate::termui::echo($fmt, $nl, $err, None)
95    };
96    (@parse $fmt:expr, color = $color:expr) => {
97        $crate::termui::echo($fmt, true, false, $color)
98    };
99    (@parse $fmt:expr, nl = $nl:expr, color = $color:expr) => {
100        $crate::termui::echo($fmt, $nl, false, $color)
101    };
102    (@parse $fmt:expr, err = $err:expr, color = $color:expr) => {
103        $crate::termui::echo($fmt, true, $err, $color)
104    };
105    (@parse $fmt:expr, nl = $nl:expr, err = $err:expr, color = $color:expr) => {
106        $crate::termui::echo($fmt, $nl, $err, $color)
107    };
108    // Format string with args (no keyword args)
109    (@parse $fmt:expr, $($arg:tt)*) => {
110        $crate::termui::echo(&format!($fmt, $($arg)*), true, false, None)
111    };
112}
113
114// ============================================================================
115// Color Constants
116// ============================================================================
117
118/// Terminal colors for styled output.
119///
120/// These colors correspond to standard ANSI terminal colors.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Color {
123    /// Black (ANSI code 30/40)
124    Black,
125    /// Red (ANSI code 31/41)
126    Red,
127    /// Green (ANSI code 32/42)
128    Green,
129    /// Yellow (ANSI code 33/43)
130    Yellow,
131    /// Blue (ANSI code 34/44)
132    Blue,
133    /// Magenta (ANSI code 35/45)
134    Magenta,
135    /// Cyan (ANSI code 36/46)
136    Cyan,
137    /// White (ANSI code 37/47)
138    White,
139    /// Bright Black (ANSI code 90/100)
140    BrightBlack,
141    /// Bright Red (ANSI code 91/101)
142    BrightRed,
143    /// Bright Green (ANSI code 92/102)
144    BrightGreen,
145    /// Bright Yellow (ANSI code 93/103)
146    BrightYellow,
147    /// Bright Blue (ANSI code 94/104)
148    BrightBlue,
149    /// Bright Magenta (ANSI code 95/105)
150    BrightMagenta,
151    /// Bright Cyan (ANSI code 96/106)
152    BrightCyan,
153    /// Bright White (ANSI code 97/107)
154    BrightWhite,
155    /// Reset color to default
156    Reset,
157}
158
159impl Color {
160    /// Get the ANSI foreground color code.
161    pub fn fg_code(self) -> u8 {
162        match self {
163            Color::Black => 30,
164            Color::Red => 31,
165            Color::Green => 32,
166            Color::Yellow => 33,
167            Color::Blue => 34,
168            Color::Magenta => 35,
169            Color::Cyan => 36,
170            Color::White => 37,
171            Color::BrightBlack => 90,
172            Color::BrightRed => 91,
173            Color::BrightGreen => 92,
174            Color::BrightYellow => 93,
175            Color::BrightBlue => 94,
176            Color::BrightMagenta => 95,
177            Color::BrightCyan => 96,
178            Color::BrightWhite => 97,
179            Color::Reset => 39,
180        }
181    }
182
183    /// Get the ANSI background color code.
184    pub fn bg_code(self) -> u8 {
185        match self {
186            Color::Black => 40,
187            Color::Red => 41,
188            Color::Green => 42,
189            Color::Yellow => 43,
190            Color::Blue => 44,
191            Color::Magenta => 45,
192            Color::Cyan => 46,
193            Color::White => 47,
194            Color::BrightBlack => 100,
195            Color::BrightRed => 101,
196            Color::BrightGreen => 102,
197            Color::BrightYellow => 103,
198            Color::BrightBlue => 104,
199            Color::BrightMagenta => 105,
200            Color::BrightCyan => 106,
201            Color::BrightWhite => 107,
202            Color::Reset => 49,
203        }
204    }
205}
206
207// Color constants for convenience (Python Click compatibility)
208pub const BLACK: Color = Color::Black;
209pub const RED: Color = Color::Red;
210pub const GREEN: Color = Color::Green;
211pub const YELLOW: Color = Color::Yellow;
212pub const BLUE: Color = Color::Blue;
213pub const MAGENTA: Color = Color::Magenta;
214pub const CYAN: Color = Color::Cyan;
215pub const WHITE: Color = Color::White;
216pub const BRIGHT_BLACK: Color = Color::BrightBlack;
217pub const BRIGHT_RED: Color = Color::BrightRed;
218pub const BRIGHT_GREEN: Color = Color::BrightGreen;
219pub const BRIGHT_YELLOW: Color = Color::BrightYellow;
220pub const BRIGHT_BLUE: Color = Color::BrightBlue;
221pub const BRIGHT_MAGENTA: Color = Color::BrightMagenta;
222pub const BRIGHT_CYAN: Color = Color::BrightCyan;
223pub const BRIGHT_WHITE: Color = Color::BrightWhite;
224pub const RESET: Color = Color::Reset;
225
226// ============================================================================
227// Terminal Detection
228// ============================================================================
229
230/// Check if a file descriptor refers to a TTY.
231///
232/// This checks if the given stream is connected to an interactive terminal.
233///
234/// # Arguments
235///
236/// * `stream` - The stream to check: "stdout", "stderr", or "stdin"
237///
238/// # Returns
239///
240/// `true` if the stream is a TTY, `false` otherwise.
241///
242/// # Note
243///
244/// This uses `std::io::IsTerminal` when available (MSRV: 1.70).
245pub fn isatty(stream: &str) -> bool {
246    use std::io::IsTerminal;
247    match stream {
248        "stdin" => std::io::stdin().is_terminal(),
249        "stdout" => std::io::stdout().is_terminal(),
250        "stderr" => std::io::stderr().is_terminal(),
251        _ => false,
252    }
253}
254
255/// Check if stdout is connected to a TTY.
256pub fn stdout_isatty() -> bool {
257    isatty("stdout")
258}
259
260/// Check if stderr is connected to a TTY.
261pub fn stderr_isatty() -> bool {
262    isatty("stderr")
263}
264
265/// Check if stdin is connected to a TTY.
266pub fn stdin_isatty() -> bool {
267    isatty("stdin")
268}
269
270/// Get the terminal size as (width, height).
271///
272/// Returns a default of (80, 24) if detection fails or if not connected to a TTY.
273///
274/// # Returns
275///
276/// A tuple of (width, height) in characters.
277pub fn get_terminal_size() -> (usize, usize) {
278    // Try environment variables first (portable)
279    if let (Ok(cols), Ok(rows)) = (std::env::var("COLUMNS"), std::env::var("LINES")) {
280        if let (Ok(w), Ok(h)) = (cols.parse::<usize>(), rows.parse::<usize>()) {
281            if w > 0 && h > 0 {
282                return (w, h);
283            }
284        }
285    }
286
287    // Use crossterm for OS-level detection (cross-platform, no unsafe)
288    match crossterm::terminal::size() {
289        Ok((cols, rows)) if cols > 0 && rows > 0 => (cols as usize, rows as usize),
290        _ => (80, 24),
291    }
292}
293
294/// Clear the terminal screen.
295///
296/// Uses ANSI escape sequences to clear the screen and move cursor to home.
297/// On non-TTY outputs, this is a no-op.
298pub fn clear() {
299    if stdout_isatty() {
300        // ANSI escape: clear screen and move to home
301        print!("\x1b[2J\x1b[H");
302        let _ = io::stdout().flush();
303    }
304}
305
306// ============================================================================
307// Styling Functions
308// ============================================================================
309
310/// Determine if ANSI codes should be used.
311///
312/// Returns true if:
313/// - `color` is explicitly `Some(true)`
314/// - `color` is `None` and the output stream is a TTY
315fn should_use_color(color: Option<bool>, err: bool) -> bool {
316    match color {
317        Some(true) => true,
318        Some(false) => false,
319        None => {
320            if err {
321                stderr_isatty()
322            } else {
323                stdout_isatty()
324            }
325        }
326    }
327}
328
329/// Style text with ANSI escape codes.
330///
331/// Creates a styled string with the specified formatting. If the terminal
332/// doesn't support colors or formatting is disabled, returns the text unchanged.
333///
334/// # Arguments
335///
336/// * `text` - The text to style
337/// * `fg` - Foreground color
338/// * `bg` - Background color
339/// * `bold` - Bold text
340/// * `dim` - Dim (faint) text
341/// * `underline` - Underlined text
342/// * `overline` - Overlined text (not widely supported)
343/// * `italic` - Italic text
344/// * `blink` - Blinking text
345/// * `strikethrough` - Strikethrough text
346/// * `reset` - Whether to reset all styles at the end
347///
348/// # Returns
349///
350/// The styled string with ANSI escape codes.
351#[allow(clippy::too_many_arguments)]
352pub fn style(
353    text: &str,
354    fg: Option<Color>,
355    bg: Option<Color>,
356    bold: bool,
357    dim: bool,
358    underline: bool,
359    overline: bool,
360    italic: bool,
361    blink: bool,
362    strikethrough: bool,
363    reset: bool,
364) -> String {
365    let mut codes = Vec::new();
366
367    // Collect style codes
368    if bold {
369        codes.push("1".to_string());
370    }
371    if dim {
372        codes.push("2".to_string());
373    }
374    if italic {
375        codes.push("3".to_string());
376    }
377    if underline {
378        codes.push("4".to_string());
379    }
380    if blink {
381        codes.push("5".to_string());
382    }
383    if overline {
384        codes.push("53".to_string()); // ANSI overline
385    }
386    if strikethrough {
387        codes.push("9".to_string());
388    }
389
390    // Add colors
391    if let Some(color) = fg {
392        codes.push(color.fg_code().to_string());
393    }
394    if let Some(color) = bg {
395        codes.push(color.bg_code().to_string());
396    }
397
398    if codes.is_empty() {
399        return text.to_string();
400    }
401
402    let style_start = format!("\x1b[{}m", codes.join(";"));
403    let style_end = if reset { "\x1b[0m" } else { "" };
404
405    format!("{}{}{}", style_start, text, style_end)
406}
407
408/// Print a message to the terminal.
409///
410/// # Arguments
411///
412/// * `message` - The message to print
413/// * `nl` - Whether to append a newline (default: true)
414/// * `err` - Whether to print to stderr instead of stdout
415/// * `color` - Force color on/off, or None to auto-detect
416pub fn echo(message: &str, nl: bool, err: bool, color: Option<bool>) {
417    let output = if should_use_color(color, err) {
418        message.to_string()
419    } else {
420        // Strip ANSI codes if color is disabled
421        strip_ansi_codes(message)
422    };
423
424    if err {
425        if nl {
426            eprintln!("{}", output);
427            let _ = io::stderr().flush();
428        } else {
429            eprint!("{}", output);
430            let _ = io::stderr().flush();
431        }
432    } else if nl {
433        println!("{}", output);
434        let _ = io::stdout().flush();
435    } else {
436        print!("{}", output);
437        let _ = io::stdout().flush();
438    }
439}
440
441/// Print a styled message to the terminal.
442///
443/// This is a convenience function that combines `style` and `echo`.
444///
445/// # Arguments
446///
447/// * `message` - The message to print
448/// * `fg` - Foreground color
449/// * `bg` - Background color
450/// * `bold` - Bold text
451/// * `dim` - Dim text
452/// * `underline` - Underlined text
453/// * `overline` - Overlined text
454/// * `italic` - Italic text
455/// * `blink` - Blinking text
456/// * `strikethrough` - Strikethrough text
457/// * `reset` - Whether to reset styles at end
458/// * `nl` - Whether to append a newline
459/// * `err` - Whether to print to stderr
460/// * `color` - Force color on/off, or None to auto-detect
461#[allow(clippy::too_many_arguments)]
462pub fn secho(
463    message: &str,
464    fg: Option<Color>,
465    bg: Option<Color>,
466    bold: bool,
467    dim: bool,
468    underline: bool,
469    overline: bool,
470    italic: bool,
471    blink: bool,
472    strikethrough: bool,
473    reset: bool,
474    nl: bool,
475    err: bool,
476    color: Option<bool>,
477) {
478    let styled = if should_use_color(color, err) {
479        style(
480            message,
481            fg,
482            bg,
483            bold,
484            dim,
485            underline,
486            overline,
487            italic,
488            blink,
489            strikethrough,
490            reset,
491        )
492    } else {
493        message.to_string()
494    };
495
496    if err {
497        if nl {
498            eprintln!("{}", styled);
499        } else {
500            eprint!("{}", styled);
501            let _ = io::stderr().flush();
502        }
503    } else if nl {
504        println!("{}", styled);
505    } else {
506        print!("{}", styled);
507        let _ = io::stdout().flush();
508    }
509}
510
511// ============================================================================
512// Pager Support
513// ============================================================================
514
515/// Display text via a pager.
516///
517/// Pipes the given text to a pager program ($PAGER, less, or more).
518/// Falls back to `echo()` if no pager is available or if not connected to a TTY.
519///
520/// # Arguments
521///
522/// * `text` - The text to display
523/// * `color` - Whether to preserve ANSI color codes (None = auto-detect)
524///
525/// # Example
526///
527/// ```no_run
528/// use click::termui::echo_via_pager;
529///
530/// let long_text = (0..100).map(|i| format!("Line {}", i)).collect::<Vec<_>>().join("\n");
531/// echo_via_pager(&long_text, None);
532/// ```
533pub fn echo_via_pager(text: &str, color: Option<bool>) {
534    // If not a TTY, just echo the text
535    if !stdin_isatty() || !stdout_isatty() {
536        echo(text, true, false, color);
537        return;
538    }
539
540    // Determine the pager command
541    let pager = std::env::var("PAGER")
542        .ok()
543        .filter(|p| !p.is_empty())
544        .unwrap_or_else(|| {
545            // Try to find less or more
546            if which_pager("less").is_some() {
547                "less".to_string()
548            } else if which_pager("more").is_some() {
549                "more".to_string()
550            } else {
551                String::new()
552            }
553        });
554
555    if pager.is_empty() {
556        // No pager available, fall back to echo
557        echo(text, true, false, color);
558        return;
559    }
560
561    // Prepare the text (strip ANSI codes if color is disabled)
562    let output_text = if color == Some(false) {
563        strip_ansi_codes(text)
564    } else {
565        text.to_string()
566    };
567
568    // Build the pager command with proper arguments
569    let mut parts = pager.split_whitespace();
570    let cmd_name = match parts.next() {
571        Some(name) => name,
572        None => {
573            echo(&output_text, true, false, color);
574            return;
575        }
576    };
577
578    let mut cmd = ProcessCommand::new(cmd_name);
579
580    // Add any additional arguments from $PAGER
581    for arg in parts {
582        cmd.arg(arg);
583    }
584
585    // If using less and color is enabled, add -R flag for raw control characters
586    if cmd_name == "less" && color != Some(false) {
587        cmd.arg("-R");
588    }
589
590    // Try to spawn the pager and pipe text to it
591    match cmd.stdin(Stdio::piped()).spawn() {
592        Ok(mut child) => {
593            if let Some(mut stdin) = child.stdin.take() {
594                let _ = stdin.write_all(output_text.as_bytes());
595            }
596            // Wait for pager to finish
597            let _ = child.wait();
598        }
599        Err(_) => {
600            // Pager failed to start, fall back to echo
601            echo(&output_text, true, false, color);
602        }
603    }
604}
605
606/// Check if a pager command exists in PATH.
607fn which_pager(name: &str) -> Option<String> {
608    if let Ok(path) = std::env::var("PATH") {
609        for dir in path.split(':') {
610            let full_path = std::path::Path::new(dir).join(name);
611            if full_path.exists() {
612                return Some(full_path.to_string_lossy().into_owned());
613            }
614        }
615    }
616    None
617}
618
619// ============================================================================
620// Launch Support
621// ============================================================================
622
623/// Open a URL or file path in the default application.
624///
625/// Uses platform-specific commands to launch the associated application:
626/// - macOS: `open`
627/// - Linux: `xdg-open`
628/// - Windows: `start`
629///
630/// # Arguments
631///
632/// * `url` - The URL or file path to open
633/// * `wait` - If true, wait for the application to finish before returning
634/// * `locate` - If true, open a file manager showing the file location instead
635///
636/// # Returns
637///
638/// `Ok(())` on success, or an error if the launch failed.
639///
640/// # Example
641///
642/// ```no_run
643/// use click::termui::launch;
644///
645/// # fn main() -> Result<(), click::ClickError> {
646/// // Open a URL in the default browser
647/// launch("https://example.com", false, false)?;
648///
649/// // Open a file in its default application
650/// launch("/path/to/document.pdf", false, false)?;
651///
652/// // Show file location in file manager
653/// launch("/path/to/file.txt", false, true)?;
654/// # Ok(())
655/// # }
656/// ```
657pub fn launch(url: &str, wait: bool, locate: bool) -> Result<()> {
658    let (cmd, args) = get_launch_command(url, locate)?;
659
660    let mut command = ProcessCommand::new(&cmd);
661    command.args(&args);
662
663    if wait {
664        let status = command
665            .status()
666            .map_err(|e| ClickError::usage(format!("Failed to launch '{}': {}", url, e)))?;
667
668        if !status.success() {
669            return Err(ClickError::usage(format!(
670                "Launch command failed with exit code: {:?}",
671                status.code()
672            )));
673        }
674    } else {
675        // Spawn without waiting
676        command
677            .spawn()
678            .map_err(|e| ClickError::usage(format!("Failed to launch '{}': {}", url, e)))?;
679    }
680
681    Ok(())
682}
683
684/// Get the platform-specific launch command and arguments.
685fn get_launch_command(url: &str, locate: bool) -> Result<(String, Vec<String>)> {
686    #[cfg(target_os = "macos")]
687    {
688        if locate {
689            Ok(("open".to_string(), vec!["-R".to_string(), url.to_string()]))
690        } else {
691            Ok(("open".to_string(), vec![url.to_string()]))
692        }
693    }
694
695    #[cfg(target_os = "linux")]
696    {
697        if locate {
698            // Try to use a file manager that supports revealing files
699            // First try dbus with nautilus/dolphin, fall back to xdg-open on parent dir
700            let path = std::path::Path::new(url);
701            if let Some(parent) = path.parent() {
702                Ok((
703                    "xdg-open".to_string(),
704                    vec![parent.to_string_lossy().into_owned()],
705                ))
706            } else {
707                Err(ClickError::usage(format!("Cannot locate file: {}", url)))
708            }
709        } else {
710            Ok(("xdg-open".to_string(), vec![url.to_string()]))
711        }
712    }
713
714    #[cfg(target_os = "windows")]
715    {
716        if locate {
717            // Use explorer with /select to highlight the file
718            Ok(("explorer".to_string(), vec!["/select,".to_string() + url]))
719        } else {
720            // Use cmd /c start for URLs and files
721            Ok((
722                "cmd".to_string(),
723                vec![
724                    "/c".to_string(),
725                    "start".to_string(),
726                    "".to_string(),
727                    url.to_string(),
728                ],
729            ))
730        }
731    }
732
733    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
734    {
735        let _ = locate; // suppress unused warning
736        Err(ClickError::usage(format!(
737            "Platform not supported for launch: {}",
738            url
739        )))
740    }
741}
742
743/// Strip ANSI escape codes from a string.
744///
745/// # Arguments
746///
747/// * `text` - The text to strip
748///
749/// # Returns
750///
751/// The text with all ANSI escape codes removed.
752pub fn strip_ansi_codes(text: &str) -> String {
753    let mut result = String::with_capacity(text.len());
754    let mut chars = text.chars().peekable();
755
756    while let Some(c) = chars.next() {
757        if c == '\x1b' {
758            // Skip the escape sequence
759            if chars.peek() == Some(&'[') {
760                chars.next(); // consume '['
761                              // Skip until we hit a letter (end of sequence)
762                while let Some(&next) = chars.peek() {
763                    chars.next();
764                    if next.is_ascii_alphabetic() {
765                        break;
766                    }
767                }
768            }
769        } else {
770            result.push(c);
771        }
772    }
773
774    result
775}
776
777// ============================================================================
778// Input Functions
779// ============================================================================
780
781/// Prompt the user for input with optional type conversion.
782///
783/// # Arguments
784///
785/// * `text` - The prompt text to display
786/// * `default` - Optional default value if user presses Enter
787/// * `hide_input` - Whether to hide user input (for passwords)
788/// * `confirmation` - Whether to prompt twice and require matching input
789/// * `type_converter` - Function to convert and validate the input
790///
791/// # Returns
792///
793/// The converted user input, or an error.
794///
795/// # Notes
796///
797/// Hidden input requires terminal raw mode. If raw mode is unavailable,
798/// input will be visible with a warning.
799pub fn prompt<T, F>(
800    text: &str,
801    default: Option<T>,
802    hide_input: bool,
803    confirmation: bool,
804    type_converter: F,
805) -> Result<T>
806where
807    T: Clone + std::fmt::Display,
808    F: Fn(&str) -> std::result::Result<T, String>,
809{
810    loop {
811        // Build prompt string
812        let prompt_text = if let Some(ref def) = default {
813            format!("{} [{}]: ", text, def)
814        } else {
815            format!("{}: ", text)
816        };
817
818        // Read input
819        let input = if hide_input {
820            read_hidden_input(&prompt_text)?
821        } else {
822            read_line(&prompt_text)?
823        };
824
825        // Handle empty input with default
826        let value = if input.is_empty() {
827            if let Some(def) = default.clone() {
828                return Ok(def);
829            } else {
830                echo("Error: This field is required.", true, true, None);
831                continue;
832            }
833        } else {
834            input
835        };
836
837        // Convert value
838        let converted = match type_converter(&value) {
839            Ok(v) => v,
840            Err(msg) => {
841                echo(&format!("Error: {}", msg), true, true, None);
842                continue;
843            }
844        };
845
846        // Handle confirmation
847        if confirmation {
848            let confirm_prompt = "Repeat for confirmation: ".to_string();
849            let confirm_input = if hide_input {
850                read_hidden_input(&confirm_prompt)?
851            } else {
852                read_line(&confirm_prompt)?
853            };
854
855            if confirm_input != value {
856                echo(
857                    "Error: The two entered values do not match.",
858                    true,
859                    true,
860                    None,
861                );
862                continue;
863            }
864        }
865
866        return Ok(converted);
867    }
868}
869
870/// Prompt for yes/no confirmation.
871///
872/// # Arguments
873///
874/// * `text` - The prompt text to display
875/// * `default` - Optional default value (true=yes, false=no)
876/// * `abort` - Whether to raise Abort error on "no" answer
877///
878/// # Returns
879///
880/// `true` if user answered yes, `false` if no.
881/// Returns `Err(ClickError::Abort)` if `abort` is true and user answered no.
882pub fn confirm(text: &str, default: Option<bool>, abort: bool) -> Result<bool> {
883    let suffix = match default {
884        Some(true) => " [Y/n]: ",
885        Some(false) => " [y/N]: ",
886        None => " [y/n]: ",
887    };
888
889    loop {
890        let prompt_text = format!("{}{}", text, suffix);
891        let input = read_line(&prompt_text)?;
892        let input_lower = input.to_lowercase();
893
894        let result = if input.is_empty() {
895            default
896        } else if input_lower == "y" || input_lower == "yes" {
897            Some(true)
898        } else if input_lower == "n" || input_lower == "no" {
899            Some(false)
900        } else {
901            echo("Error: invalid input", true, true, None);
902            continue;
903        };
904
905        match result {
906            Some(true) => return Ok(true),
907            Some(false) => {
908                if abort {
909                    return Err(ClickError::Abort);
910                }
911                return Ok(false);
912            }
913            None => {
914                echo("Error: invalid input", true, true, None);
915                continue;
916            }
917        }
918    }
919}
920
921/// Read a single character from the terminal.
922///
923/// # Arguments
924///
925/// * `echo_char` - Whether to echo the character back to the terminal
926///
927/// # Returns
928///
929/// The character read from the terminal.
930///
931/// # Notes
932///
933/// This function attempts to use raw mode for immediate character reading.
934/// If raw mode is unavailable, it falls back to reading a line and returning
935/// the first character.
936pub fn getchar(echo_char: bool) -> Result<char> {
937    use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
938    use crossterm::terminal;
939
940    if terminal::enable_raw_mode().is_ok() {
941        let result = loop {
942            match event::read() {
943                Ok(Event::Key(KeyEvent {
944                    code, modifiers, ..
945                })) => {
946                    if modifiers.contains(KeyModifiers::CONTROL) {
947                        if let KeyCode::Char('c') = code {
948                            break Err(ClickError::Abort);
949                        }
950                    }
951                    match code {
952                        KeyCode::Char(c) => break Ok(c),
953                        KeyCode::Enter => break Ok('\n'),
954                        KeyCode::Backspace => break Ok('\x7f'),
955                        KeyCode::Tab => break Ok('\t'),
956                        KeyCode::Esc => break Ok('\x1b'),
957                        _ => continue,
958                    }
959                }
960                Ok(_) => continue,
961                Err(e) => break Err(ClickError::usage(format!("Failed to read key: {}", e))),
962            }
963        };
964        let _ = terminal::disable_raw_mode();
965
966        if let Ok(c) = &result {
967            if echo_char {
968                print!("{}", c);
969                let _ = io::stdout().flush();
970            }
971        }
972        return result;
973    }
974
975    // Fallback: read a line and return the first character
976    let input = read_line("")?;
977    input.chars().next().ok_or(ClickError::Abort)
978}
979
980/// Pause until the user presses any key.
981///
982/// # Arguments
983///
984/// * `info` - Optional message to display (default: "Press any key to continue...")
985pub fn pause(info: Option<&str>) {
986    let message = info.unwrap_or("Press any key to continue...");
987    echo(message, false, false, None);
988
989    // Try to read a single character
990    let _ = getchar(false);
991
992    // Print newline
993    println!();
994}
995
996/// Read a line of input from stdin.
997fn read_line(prompt: &str) -> Result<String> {
998    if !prompt.is_empty() {
999        print!("{}", prompt);
1000        let _ = io::stdout().flush();
1001    }
1002
1003    let stdin = io::stdin();
1004    let mut line = String::new();
1005
1006    stdin
1007        .lock()
1008        .read_line(&mut line)
1009        .map_err(|e| ClickError::usage(format!("Failed to read input: {}", e)))?;
1010
1011    // Trim the trailing newline
1012    if line.ends_with('\n') {
1013        line.pop();
1014        if line.ends_with('\r') {
1015            line.pop();
1016        }
1017    }
1018
1019    Ok(line)
1020}
1021
1022/// Read hidden input (for passwords).
1023///
1024/// Uses crossterm raw mode to read char-by-char without echo. Falls back to
1025/// visible input when not connected to a TTY (e.g., piped stdin in tests).
1026fn read_hidden_input(prompt: &str) -> Result<String> {
1027    use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
1028    use crossterm::terminal;
1029
1030    if !prompt.is_empty() {
1031        print!("{}", prompt);
1032        let _ = io::stdout().flush();
1033    }
1034
1035    // Try crossterm raw mode for hidden char-by-char reading (cross-platform, no unsafe).
1036    if terminal::enable_raw_mode().is_ok() {
1037        let mut input = String::new();
1038        let result = loop {
1039            match event::read() {
1040                Ok(Event::Key(KeyEvent {
1041                    code, modifiers, ..
1042                })) => {
1043                    if modifiers.contains(KeyModifiers::CONTROL) {
1044                        if let KeyCode::Char('c') = code {
1045                            break Err(ClickError::Abort);
1046                        }
1047                    }
1048                    match code {
1049                        KeyCode::Enter => break Ok(input.clone()),
1050                        KeyCode::Char(c) => input.push(c),
1051                        KeyCode::Backspace => {
1052                            input.pop();
1053                        }
1054                        _ => {}
1055                    }
1056                }
1057                Ok(_) => continue,
1058                Err(_) => break Ok(input.clone()),
1059            }
1060        };
1061        let _ = terminal::disable_raw_mode();
1062        println!();
1063        return result;
1064    }
1065
1066    // Fallback: warn user and read normally (non-TTY, e.g., piped stdin)
1067    echo("(Warning: Input will be visible)", true, true, None);
1068    read_line("")
1069}
1070
1071// ============================================================================
1072// Progress Bar
1073// ============================================================================
1074
1075/// A progress bar for displaying operation progress.
1076///
1077/// # Example
1078///
1079/// ```no_run
1080/// use click::termui::ProgressBar;
1081///
1082/// let mut bar = ProgressBar::new(100, Some("Processing"), true, true, true, 40);
1083///
1084/// for i in 0..100 {
1085///     // Do work...
1086///     bar.update(1);
1087/// }
1088///
1089/// bar.finish();
1090///
1091/// // Custom fill/empty characters
1092/// let mut bar = ProgressBar::new(100, None, true, true, false, 30)
1093///     .fill_char('█')
1094///     .empty_char('░');
1095/// ```
1096pub struct ProgressBar {
1097    /// Total length of the progress (number of items)
1098    length: usize,
1099    /// Current position
1100    position: usize,
1101    /// Optional label to display
1102    label: Option<String>,
1103    /// Whether to show ETA
1104    show_eta: bool,
1105    /// Whether to show percentage
1106    show_percent: bool,
1107    /// Whether to show position/length
1108    show_pos: bool,
1109    /// Width of the progress bar in characters
1110    width: usize,
1111    /// Start time for ETA calculation
1112    start_time: Instant,
1113    /// Whether the bar is finished
1114    finished: bool,
1115    /// Whether output is a TTY
1116    is_tty: bool,
1117    /// Last rendered output length (for TTY updates)
1118    #[allow(dead_code)]
1119    last_output_len: usize,
1120    /// Character used for filled portion of bar (default: '#')
1121    fill_char: char,
1122    /// Character used for empty portion of bar (default: '-')
1123    empty_char: char,
1124}
1125
1126impl ProgressBar {
1127    /// Create a new progress bar.
1128    ///
1129    /// # Arguments
1130    ///
1131    /// * `length` - Total number of items to process
1132    /// * `label` - Optional label to display before the bar
1133    /// * `show_eta` - Whether to show estimated time remaining
1134    /// * `show_percent` - Whether to show percentage complete
1135    /// * `show_pos` - Whether to show position/length
1136    /// * `width` - Width of the bar portion in characters
1137    pub fn new(
1138        length: usize,
1139        label: Option<&str>,
1140        show_eta: bool,
1141        show_percent: bool,
1142        show_pos: bool,
1143        width: usize,
1144    ) -> Self {
1145        let bar = Self {
1146            length,
1147            position: 0,
1148            label: label.map(String::from),
1149            show_eta,
1150            show_percent,
1151            show_pos,
1152            width,
1153            start_time: Instant::now(),
1154            finished: false,
1155            is_tty: stdout_isatty(),
1156            last_output_len: 0,
1157            fill_char: '#',
1158            empty_char: '-',
1159        };
1160
1161        // Initial render
1162        bar.render_internal();
1163        bar
1164    }
1165
1166    /// Set the character used for the filled portion of the bar.
1167    ///
1168    /// Default is '#'.
1169    ///
1170    /// # Example
1171    ///
1172    /// ```no_run
1173    /// use click::termui::ProgressBar;
1174    /// let bar = ProgressBar::new(100, None, true, true, false, 30)
1175    ///     .fill_char('█');
1176    /// ```
1177    pub fn fill_char(mut self, c: char) -> Self {
1178        self.fill_char = c;
1179        self
1180    }
1181
1182    /// Set the character used for the empty portion of the bar.
1183    ///
1184    /// Default is '-'.
1185    ///
1186    /// # Example
1187    ///
1188    /// ```no_run
1189    /// use click::termui::ProgressBar;
1190    /// let bar = ProgressBar::new(100, None, true, true, false, 30)
1191    ///     .empty_char('░');
1192    /// ```
1193    pub fn empty_char(mut self, c: char) -> Self {
1194        self.empty_char = c;
1195        self
1196    }
1197
1198    /// Update the progress bar by advancing by `n` items.
1199    ///
1200    /// # Arguments
1201    ///
1202    /// * `n` - Number of items completed since last update
1203    pub fn update(&mut self, n: usize) {
1204        if self.finished {
1205            return;
1206        }
1207
1208        self.position = (self.position + n).min(self.length);
1209        self.render_internal();
1210    }
1211
1212    /// Set the progress bar to a specific position.
1213    ///
1214    /// # Arguments
1215    ///
1216    /// * `pos` - New position
1217    pub fn set_position(&mut self, pos: usize) {
1218        if self.finished {
1219            return;
1220        }
1221
1222        self.position = pos.min(self.length);
1223        self.render_internal();
1224    }
1225
1226    /// Mark the progress bar as finished and render final state.
1227    pub fn finish(&mut self) {
1228        if self.finished {
1229            return;
1230        }
1231
1232        self.position = self.length;
1233        self.finished = true;
1234        self.render_internal();
1235
1236        // Print newline
1237        if self.is_tty {
1238            println!();
1239        }
1240    }
1241
1242    /// Render the current progress bar state to a string.
1243    pub fn render(&self) -> String {
1244        let mut parts = Vec::new();
1245
1246        // Label
1247        if let Some(ref label) = self.label {
1248            parts.push(label.clone());
1249        }
1250
1251        // Calculate progress
1252        let progress = if self.length > 0 {
1253            self.position as f64 / self.length as f64
1254        } else {
1255            0.0
1256        };
1257
1258        // Progress bar
1259        let filled = (progress * self.width as f64) as usize;
1260        let empty = self.width.saturating_sub(filled);
1261        let bar = format!(
1262            "[{}{}]",
1263            self.fill_char.to_string().repeat(filled),
1264            self.empty_char.to_string().repeat(empty)
1265        );
1266        parts.push(bar);
1267
1268        // Percentage
1269        if self.show_percent {
1270            parts.push(format!("{:3.0}%", progress * 100.0));
1271        }
1272
1273        // Position / Length
1274        if self.show_pos {
1275            parts.push(format!("{}/{}", self.position, self.length));
1276        }
1277
1278        // ETA
1279        if self.show_eta && self.position > 0 && !self.finished {
1280            let elapsed = self.start_time.elapsed();
1281            let rate = self.position as f64 / elapsed.as_secs_f64();
1282            let remaining = self.length - self.position;
1283            let eta_secs = if rate > 0.0 {
1284                remaining as f64 / rate
1285            } else {
1286                0.0
1287            };
1288
1289            if eta_secs < 3600.0 {
1290                let mins = (eta_secs / 60.0) as u64;
1291                let secs = (eta_secs % 60.0) as u64;
1292                parts.push(format!("eta {:02}:{:02}", mins, secs));
1293            } else {
1294                let hours = (eta_secs / 3600.0) as u64;
1295                let mins = ((eta_secs % 3600.0) / 60.0) as u64;
1296                parts.push(format!("eta {}h {:02}m", hours, mins));
1297            }
1298        }
1299
1300        parts.join(" ")
1301    }
1302
1303    /// Internal render method that updates the terminal.
1304    fn render_internal(&self) {
1305        let output = self.render();
1306
1307        if self.is_tty {
1308            // Carriage return to beginning of line
1309            print!("\r{}", output);
1310            // Clear any remaining characters from previous output
1311            let clear_len = self.last_output_len.saturating_sub(output.len());
1312            if clear_len > 0 {
1313                print!("{}", " ".repeat(clear_len));
1314                print!("\r{}", output);
1315            }
1316            let _ = io::stdout().flush();
1317        } else {
1318            // Non-TTY: print on new lines
1319            println!("{}", output);
1320        }
1321    }
1322}
1323
1324impl Drop for ProgressBar {
1325    fn drop(&mut self) {
1326        if !self.finished && self.is_tty {
1327            // Ensure we leave the terminal in a clean state
1328            println!();
1329        }
1330    }
1331}
1332
1333/// Wrap an iterator with a progress bar display.
1334///
1335/// # Arguments
1336///
1337/// * `iter` - The iterator to wrap
1338/// * `length` - Total length (for percentage/ETA), or None to infer from iterator
1339/// * `label` - Optional label to display
1340///
1341/// # Returns
1342///
1343/// An iterator that displays progress as items are consumed.
1344///
1345/// # Example
1346///
1347/// ```no_run
1348/// use click::termui::progressbar;
1349///
1350/// let items = vec![1, 2, 3, 4, 5];
1351/// for item in progressbar(items.iter(), Some(items.len()), Some("Processing")) {
1352///     // Process item
1353/// }
1354/// ```
1355pub fn progressbar<I>(
1356    iter: I,
1357    length: Option<usize>,
1358    label: Option<&str>,
1359) -> ProgressBarIter<I::IntoIter>
1360where
1361    I: IntoIterator,
1362    I::IntoIter: ExactSizeIterator,
1363{
1364    let iter = iter.into_iter();
1365    let len = length.unwrap_or_else(|| iter.len());
1366
1367    ProgressBarIter {
1368        iter,
1369        bar: ProgressBar::new(len, label, true, true, true, 30),
1370    }
1371}
1372
1373/// An iterator wrapper that displays a progress bar.
1374pub struct ProgressBarIter<I> {
1375    iter: I,
1376    bar: ProgressBar,
1377}
1378
1379impl<I> Iterator for ProgressBarIter<I>
1380where
1381    I: Iterator,
1382{
1383    type Item = I::Item;
1384
1385    fn next(&mut self) -> Option<Self::Item> {
1386        match self.iter.next() {
1387            Some(item) => {
1388                self.bar.update(1);
1389                Some(item)
1390            }
1391            None => {
1392                self.bar.finish();
1393                None
1394            }
1395        }
1396    }
1397
1398    fn size_hint(&self) -> (usize, Option<usize>) {
1399        self.iter.size_hint()
1400    }
1401}
1402
1403impl<I: ExactSizeIterator> ExactSizeIterator for ProgressBarIter<I> {
1404    fn len(&self) -> usize {
1405        self.iter.len()
1406    }
1407}
1408
1409// ============================================================================
1410// Editor Support
1411// ============================================================================
1412
1413/// Open a text editor for the user to edit content.
1414///
1415/// # Arguments
1416///
1417/// * `text` - Initial text to populate the editor with
1418/// * `editor` - Editor command to use, or None to use $EDITOR/$VISUAL/vi
1419/// * `extension` - File extension for the temporary file
1420/// * `require_save` - If true, return None if user didn't save
1421///
1422/// # Returns
1423///
1424/// The edited text, or None if editing was cancelled.
1425pub fn edit_text(
1426    text: Option<&str>,
1427    editor: Option<&str>,
1428    extension: &str,
1429    require_save: bool,
1430) -> Result<Option<String>> {
1431    use std::fs;
1432    use std::process::Command;
1433
1434    // Create temporary file
1435    let temp_dir = std::env::temp_dir();
1436    let temp_file = temp_dir.join(format!("click_edit_{}.{}", std::process::id(), extension));
1437
1438    // Write initial content
1439    if let Some(initial) = text {
1440        fs::write(&temp_file, initial)
1441            .map_err(|e| ClickError::file_error(&temp_file, e.to_string()))?;
1442    }
1443
1444    // Get modification time before editing
1445    let mtime_before = fs::metadata(&temp_file)
1446        .ok()
1447        .and_then(|m| m.modified().ok());
1448
1449    // Determine editor
1450    let editor_cmd = editor
1451        .map(String::from)
1452        .or_else(|| std::env::var("VISUAL").ok())
1453        .or_else(|| std::env::var("EDITOR").ok())
1454        .unwrap_or_else(|| "vi".to_string());
1455
1456    // Run editor
1457    let status = Command::new(&editor_cmd)
1458        .arg(&temp_file)
1459        .status()
1460        .map_err(|e| ClickError::usage(format!("Failed to run editor '{}': {}", editor_cmd, e)))?;
1461
1462    if !status.success() {
1463        let _ = fs::remove_file(&temp_file);
1464        return Err(ClickError::usage(format!(
1465            "Editor '{}' exited with error",
1466            editor_cmd
1467        )));
1468    }
1469
1470    // Check if file was modified
1471    if require_save {
1472        let mtime_after = fs::metadata(&temp_file)
1473            .ok()
1474            .and_then(|m| m.modified().ok());
1475
1476        if mtime_before == mtime_after {
1477            let _ = fs::remove_file(&temp_file);
1478            return Ok(None);
1479        }
1480    }
1481
1482    // Read edited content
1483    let content = fs::read_to_string(&temp_file)
1484        .map_err(|e| ClickError::file_error(&temp_file, e.to_string()))?;
1485
1486    // Clean up
1487    let _ = fs::remove_file(&temp_file);
1488
1489    Ok(Some(content))
1490}
1491
1492// ============================================================================
1493// Tests
1494// ============================================================================
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499
1500    #[test]
1501    fn test_color_codes() {
1502        assert_eq!(Color::Red.fg_code(), 31);
1503        assert_eq!(Color::Red.bg_code(), 41);
1504        assert_eq!(Color::BrightGreen.fg_code(), 92);
1505        assert_eq!(Color::BrightGreen.bg_code(), 102);
1506        assert_eq!(Color::Reset.fg_code(), 39);
1507        assert_eq!(Color::Reset.bg_code(), 49);
1508    }
1509
1510    #[test]
1511    fn test_style_basic() {
1512        let styled = style(
1513            "hello", None, None, false, false, false, false, false, false, false, false,
1514        );
1515        assert_eq!(styled, "hello");
1516    }
1517
1518    #[test]
1519    fn test_style_with_color() {
1520        let styled = style(
1521            "hello",
1522            Some(Color::Red),
1523            None,
1524            false,
1525            false,
1526            false,
1527            false,
1528            false,
1529            false,
1530            false,
1531            true,
1532        );
1533        assert_eq!(styled, "\x1b[31mhello\x1b[0m");
1534    }
1535
1536    #[test]
1537    fn test_style_bold() {
1538        let styled = style(
1539            "hello", None, None, true, false, false, false, false, false, false, true,
1540        );
1541        assert_eq!(styled, "\x1b[1mhello\x1b[0m");
1542    }
1543
1544    #[test]
1545    fn test_style_multiple() {
1546        let styled = style(
1547            "hello",
1548            Some(Color::Green),
1549            Some(Color::Black),
1550            true,
1551            false,
1552            true,
1553            false,
1554            false,
1555            false,
1556            false,
1557            true,
1558        );
1559        // Should have: bold (1), underline (4), fg green (32), bg black (40)
1560        assert!(styled.starts_with("\x1b["));
1561        assert!(styled.contains("1"));
1562        assert!(styled.contains("4"));
1563        assert!(styled.contains("32"));
1564        assert!(styled.contains("40"));
1565        assert!(styled.ends_with("\x1b[0m"));
1566    }
1567
1568    #[test]
1569    fn test_strip_ansi_codes() {
1570        let styled = "\x1b[31mhello\x1b[0m world";
1571        let stripped = strip_ansi_codes(styled);
1572        assert_eq!(stripped, "hello world");
1573
1574        let plain = "no codes here";
1575        assert_eq!(strip_ansi_codes(plain), "no codes here");
1576    }
1577
1578    #[test]
1579    fn test_strip_ansi_codes_complex() {
1580        let styled = "\x1b[1;31;40mcomplex\x1b[0m";
1581        let stripped = strip_ansi_codes(styled);
1582        assert_eq!(stripped, "complex");
1583    }
1584
1585    #[test]
1586    fn test_get_terminal_size_returns_valid() {
1587        let (width, height) = get_terminal_size();
1588        assert!(width > 0);
1589        assert!(height > 0);
1590    }
1591
1592    #[test]
1593    fn test_progress_bar_render() {
1594        let bar = ProgressBar::new(100, Some("Test"), false, true, true, 20);
1595        let output = bar.render();
1596        assert!(output.contains("Test"));
1597        assert!(output.contains("["));
1598        assert!(output.contains("]"));
1599        assert!(output.contains("0/100"));
1600        assert!(output.contains("0%"));
1601    }
1602
1603    #[test]
1604    fn test_progress_bar_update() {
1605        let mut bar = ProgressBar::new(100, None, false, true, false, 10);
1606        bar.update(50);
1607        let output = bar.render();
1608        assert!(output.contains("50%"));
1609    }
1610
1611    #[test]
1612    fn test_progress_bar_finish() {
1613        let mut bar = ProgressBar::new(100, None, false, true, false, 10);
1614        bar.finish();
1615        let output = bar.render();
1616        assert!(output.contains("100%"));
1617        assert!(bar.finished);
1618    }
1619
1620    #[test]
1621    fn test_progress_bar_zero_length() {
1622        let bar = ProgressBar::new(0, None, false, true, false, 10);
1623        let output = bar.render();
1624        assert!(output.contains("0%"));
1625    }
1626
1627    #[test]
1628    fn test_progress_bar_custom_chars() {
1629        let mut bar = ProgressBar::new(100, None, false, false, false, 10)
1630            .fill_char('=')
1631            .empty_char(' ');
1632        bar.set_position(50);
1633        let output = bar.render();
1634        // Should have 5 '=' chars and 5 ' ' chars (50% of width 10)
1635        assert!(output.contains("[=====     ]"));
1636    }
1637
1638    #[test]
1639    fn test_progress_bar_unicode_chars() {
1640        let bar = ProgressBar::new(100, None, false, false, false, 4)
1641            .fill_char('\u{2588}') // Full block
1642            .empty_char('\u{2591}'); // Light shade
1643        let output = bar.render();
1644        // At 0%, should be all empty chars
1645        assert!(output.contains("[\u{2591}\u{2591}\u{2591}\u{2591}]"));
1646    }
1647
1648    #[test]
1649    fn test_color_constants() {
1650        assert_eq!(BLACK, Color::Black);
1651        assert_eq!(RED, Color::Red);
1652        assert_eq!(GREEN, Color::Green);
1653        assert_eq!(YELLOW, Color::Yellow);
1654        assert_eq!(BLUE, Color::Blue);
1655        assert_eq!(MAGENTA, Color::Magenta);
1656        assert_eq!(CYAN, Color::Cyan);
1657        assert_eq!(WHITE, Color::White);
1658        assert_eq!(BRIGHT_BLACK, Color::BrightBlack);
1659        assert_eq!(BRIGHT_RED, Color::BrightRed);
1660        assert_eq!(BRIGHT_GREEN, Color::BrightGreen);
1661        assert_eq!(BRIGHT_YELLOW, Color::BrightYellow);
1662        assert_eq!(BRIGHT_BLUE, Color::BrightBlue);
1663        assert_eq!(BRIGHT_MAGENTA, Color::BrightMagenta);
1664        assert_eq!(BRIGHT_CYAN, Color::BrightCyan);
1665        assert_eq!(BRIGHT_WHITE, Color::BrightWhite);
1666        assert_eq!(RESET, Color::Reset);
1667    }
1668
1669    #[test]
1670    fn test_style_all_options() {
1671        let styled = style(
1672            "test",
1673            Some(Color::Blue),
1674            Some(Color::White),
1675            true, // bold
1676            true, // dim
1677            true, // underline
1678            true, // overline
1679            true, // italic
1680            true, // blink
1681            true, // strikethrough
1682            true, // reset
1683        );
1684        assert!(styled.starts_with("\x1b["));
1685        assert!(styled.contains("1")); // bold
1686        assert!(styled.contains("2")); // dim
1687        assert!(styled.contains("3")); // italic
1688        assert!(styled.contains("4")); // underline
1689        assert!(styled.contains("5")); // blink
1690        assert!(styled.contains("9")); // strikethrough
1691        assert!(styled.contains("53")); // overline
1692        assert!(styled.contains("34")); // blue fg
1693        assert!(styled.contains("47")); // white bg
1694        assert!(styled.ends_with("\x1b[0m"));
1695    }
1696
1697    #[test]
1698    fn test_style_no_reset() {
1699        let styled = style(
1700            "hello",
1701            Some(Color::Red),
1702            None,
1703            false,
1704            false,
1705            false,
1706            false,
1707            false,
1708            false,
1709            false,
1710            false,
1711        );
1712        assert!(styled.starts_with("\x1b[31m"));
1713        assert!(!styled.ends_with("\x1b[0m"));
1714    }
1715}