Skip to main content

click/
utils.rs

1//! Utility functions for click-rs.
2//!
3//! This module provides utility functions for path handling, stream access,
4//! environment queries, and other common operations needed by CLI applications.
5//!
6//! # Reference
7//!
8//! Based on Python Click's `utils.py`.
9
10use std::env;
11use std::ffi::OsStr;
12use std::io::{self, Stdout};
13use std::path::{Path, PathBuf};
14
15// =============================================================================
16// Stream Functions
17// =============================================================================
18
19/// Get a text stdout writer.
20///
21/// Returns a writer suitable for text output. On most platforms, this is
22/// simply stdout.
23///
24/// # Example
25///
26/// ```rust
27/// use click::utils::get_text_stdout;
28/// use std::io::Write;
29///
30/// let mut stdout = get_text_stdout();
31/// writeln!(stdout, "Hello, World!").unwrap();
32/// ```
33pub fn get_text_stdout() -> Stdout {
34    io::stdout()
35}
36
37/// Get a text stderr writer.
38///
39/// Returns a writer suitable for text error output.
40///
41/// # Example
42///
43/// ```rust
44/// use click::utils::get_text_stderr;
45/// use std::io::Write;
46///
47/// let mut stderr = get_text_stderr();
48/// writeln!(stderr, "Error occurred").unwrap();
49/// ```
50pub fn get_text_stderr() -> io::Stderr {
51    io::stderr()
52}
53
54/// Get a binary stdout writer.
55///
56/// Returns a writer suitable for binary output. This is the same as
57/// `get_text_stdout()` in Rust since stdout handles both.
58pub fn get_binary_stdout() -> Stdout {
59    io::stdout()
60}
61
62/// Get a binary stdin reader.
63///
64/// Returns a reader for binary input.
65pub fn get_binary_stdin() -> io::Stdin {
66    io::stdin()
67}
68
69// =============================================================================
70// Path Utilities
71// =============================================================================
72
73/// Format a path for user-friendly display.
74///
75/// This function shortens paths by replacing the home directory with `~`
76/// and using forward slashes on all platforms.
77///
78/// # Example
79///
80/// ```rust
81/// use click::utils::format_filename;
82/// use std::path::Path;
83///
84/// let path = Path::new("/home/user/documents/file.txt");
85/// let formatted = format_filename(path);
86/// // May return "~/documents/file.txt" if /home/user is the home directory
87/// ```
88pub fn format_filename(path: &Path) -> String {
89    let path_str = path.to_string_lossy();
90
91    // Try to replace home directory with ~
92    if let Some(home) = home_dir() {
93        let home_str = home.to_string_lossy();
94        if path_str.starts_with(home_str.as_ref()) {
95            let relative = &path_str[home_str.len()..];
96            let relative = relative.trim_start_matches(['/', '\\']);
97            return format!("~/{}", relative.replace('\\', "/"));
98        }
99    }
100
101    // Replace backslashes with forward slashes for consistency
102    path_str.replace('\\', "/")
103}
104
105/// Get the platform-specific application directory.
106///
107/// Returns the appropriate directory for storing application data based on
108/// the platform:
109///
110/// - **Unix**: `~/.app_name` (non-roaming) or `~/.local/share/app_name` (roaming)
111/// - **macOS**: `~/Library/Application Support/app_name`
112/// - **Windows**: `%APPDATA%\app_name` (roaming) or `%LOCALAPPDATA%\app_name` (non-roaming)
113///
114/// # Arguments
115///
116/// * `app_name` - The name of the application
117/// * `roaming` - Whether to use the roaming profile (Windows) or XDG data dir (Unix)
118///
119/// # Example
120///
121/// ```rust
122/// use click::utils::get_app_dir;
123///
124/// let app_dir = get_app_dir("myapp", false);
125/// println!("App directory: {}", app_dir.display());
126/// ```
127pub fn get_app_dir(app_name: &str, _roaming: bool) -> PathBuf {
128    #[cfg(target_os = "windows")]
129    {
130        let base = if _roaming {
131            env::var("APPDATA").ok()
132        } else {
133            env::var("LOCALAPPDATA").ok()
134        };
135
136        match base {
137            Some(base) => PathBuf::from(base).join(app_name),
138            None => PathBuf::from(".").join(app_name),
139        }
140    }
141
142    #[cfg(target_os = "macos")]
143    {
144        if let Some(home) = home_dir() {
145            home.join("Library")
146                .join("Application Support")
147                .join(app_name)
148        } else {
149            PathBuf::from(".").join(app_name)
150        }
151    }
152
153    #[cfg(all(unix, not(target_os = "macos")))]
154    {
155        if _roaming {
156            // Use XDG data dir
157            if let Ok(xdg_data) = env::var("XDG_DATA_HOME") {
158                return PathBuf::from(xdg_data).join(app_name);
159            }
160            if let Some(home) = home_dir() {
161                return home.join(".local").join("share").join(app_name);
162            }
163        }
164
165        // Non-roaming: use ~/.app_name
166        if let Some(home) = home_dir() {
167            home.join(format!(".{}", app_name))
168        } else {
169            PathBuf::from(".").join(format!(".{}", app_name))
170        }
171    }
172}
173
174/// Expand a path by resolving `~` and environment variables.
175///
176/// This function expands:
177/// - `~` at the start to the user's home directory
178/// - `$VAR` or `${VAR}` to the value of the environment variable `VAR`
179///
180/// # Example
181///
182/// ```rust
183/// use click::utils::expand_path;
184///
185/// let path = expand_path("~/.config/myapp");
186/// // Returns something like "/home/user/.config/myapp"
187///
188/// // With environment variables
189/// std::env::set_var("MY_DIR", "/custom");
190/// let path = expand_path("$MY_DIR/file.txt");
191/// // Returns "/custom/file.txt"
192/// ```
193pub fn expand_path(path: &str) -> PathBuf {
194    let mut result = path.to_string();
195
196    // Expand ~ at the beginning
197    if result.starts_with('~') {
198        if let Some(home) = home_dir() {
199            let home_str = home.to_string_lossy();
200            if result == "~" {
201                return home;
202            } else if result.starts_with("~/") || result.starts_with("~\\") {
203                result = format!("{}{}", home_str, &result[1..]);
204            }
205        }
206    }
207
208    // Expand environment variables
209    result = expand_env_vars(&result);
210
211    PathBuf::from(result)
212}
213
214/// Expand environment variables in a string.
215///
216/// Supports both `$VAR` and `${VAR}` syntax.
217fn expand_env_vars(s: &str) -> String {
218    let mut result = String::with_capacity(s.len());
219    let mut chars = s.chars().peekable();
220
221    while let Some(c) = chars.next() {
222        if c == '$' {
223            if chars.peek() == Some(&'{') {
224                // ${VAR} syntax
225                chars.next(); // consume '{'
226                let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
227                if let Ok(value) = env::var(&var_name) {
228                    result.push_str(&value);
229                }
230            } else {
231                // $VAR syntax - collect alphanumeric and underscore characters using peek
232                // to avoid consuming the character after the variable name
233                let mut var_name = String::new();
234                while let Some(&next_c) = chars.peek() {
235                    if next_c.is_alphanumeric() || next_c == '_' {
236                        var_name.push(next_c);
237                        chars.next();
238                    } else {
239                        break;
240                    }
241                }
242                if !var_name.is_empty() {
243                    if let Ok(value) = env::var(&var_name) {
244                        result.push_str(&value);
245                    }
246                }
247            }
248        } else {
249            result.push(c);
250        }
251    }
252
253    result
254}
255
256/// Get the user's home directory.
257///
258/// Returns `None` if the home directory cannot be determined.
259pub fn home_dir() -> Option<PathBuf> {
260    // Check HOME first (Unix)
261    if let Ok(home) = env::var("HOME") {
262        return Some(PathBuf::from(home));
263    }
264
265    // On Windows, check USERPROFILE
266    #[cfg(target_os = "windows")]
267    {
268        if let Ok(profile) = env::var("USERPROFILE") {
269            return Some(PathBuf::from(profile));
270        }
271
272        // Fallback: HOMEDRIVE + HOMEPATH
273        if let (Ok(drive), Ok(path)) = (env::var("HOMEDRIVE"), env::var("HOMEPATH")) {
274            return Some(PathBuf::from(format!("{}{}", drive, path)));
275        }
276    }
277
278    None
279}
280
281// =============================================================================
282// Environment Utilities
283// =============================================================================
284
285/// Get the command-line arguments.
286///
287/// Returns all arguments including the program name.
288///
289/// # Example
290///
291/// ```rust
292/// use click::utils::get_os_args;
293///
294/// let args = get_os_args();
295/// // args[0] is typically the program name
296/// ```
297pub fn get_os_args() -> Vec<String> {
298    env::args().collect()
299}
300
301/// Get the command-line arguments without the program name.
302///
303/// Returns arguments starting from index 1.
304pub fn get_os_args_skip_program() -> Vec<String> {
305    env::args().skip(1).collect()
306}
307
308/// Check if ANSI escape codes should be stripped from output.
309///
310/// Returns `true` if:
311/// - The `NO_COLOR` environment variable is set
312/// - The `TERM` is set to `dumb`
313/// - We cannot determine TTY status
314///
315/// # Example
316///
317/// ```rust
318/// use click::utils::should_strip_ansi;
319/// use std::io;
320///
321/// let strip = should_strip_ansi();
322/// if strip {
323///     println!("Plain text output");
324/// } else {
325///     println!("\x1b[32mColored output\x1b[0m");
326/// }
327/// ```
328pub fn should_strip_ansi() -> bool {
329    // Check NO_COLOR environment variable (https://no-color.org/)
330    if env::var("NO_COLOR").is_ok() {
331        return true;
332    }
333
334    // Check for dumb terminal
335    if let Ok(term) = env::var("TERM") {
336        if term == "dumb" {
337            return true;
338        }
339    }
340
341    // Check COLORTERM for color support
342    if env::var("COLORTERM").is_ok() {
343        return false;
344    }
345
346    // Check TERM for known color-supporting terminals
347    if let Ok(term) = env::var("TERM") {
348        if term.contains("color") || term.contains("256") || term.contains("xterm") {
349            return false;
350        }
351    }
352
353    // Default: assume color support if we can't determine
354    false
355}
356
357/// Check if the output stream is a terminal/TTY.
358pub fn is_tty() -> bool {
359    !should_strip_ansi()
360}
361
362/// Get the terminal width.
363///
364/// Returns the terminal width in columns, or `None` if it cannot be determined.
365///
366/// This implementation checks the `COLUMNS` environment variable, which is
367/// commonly set by shells. For more robust terminal size detection, consider
368/// using a crate like `terminal_size`.
369pub fn get_terminal_width() -> Option<usize> {
370    // Try to get from environment
371    if let Ok(cols) = env::var("COLUMNS") {
372        if let Ok(width) = cols.parse::<usize>() {
373            if width > 0 {
374                return Some(width);
375            }
376        }
377    }
378
379    // Default to 80 columns if we can't determine
380    // This is a reasonable fallback for most terminals
381    None
382}
383
384// =============================================================================
385// String Utilities
386// =============================================================================
387
388/// Safely encode a string for terminal output.
389///
390/// Replaces unprintable characters with their escaped representations.
391pub fn safecall<T: AsRef<str>>(s: T) -> String {
392    let s = s.as_ref();
393    let mut result = String::with_capacity(s.len());
394
395    for c in s.chars() {
396        if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
397            // Escape control characters
398            result.push_str(&format!("\\x{:02x}", c as u32));
399        } else {
400            result.push(c);
401        }
402    }
403
404    result
405}
406
407/// Pluralize a word based on a count.
408///
409/// # Example
410///
411/// ```rust
412/// use click::utils::pluralize;
413///
414/// assert_eq!(pluralize(1, "file", "files"), "file");
415/// assert_eq!(pluralize(2, "file", "files"), "files");
416/// assert_eq!(pluralize(0, "file", "files"), "files");
417/// ```
418pub fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
419    if count == 1 {
420        singular
421    } else {
422        plural
423    }
424}
425
426/// Join items with a separator and a final conjunction.
427///
428/// # Example
429///
430/// ```rust
431/// use click::utils::join_with_conjunction;
432///
433/// let items = vec!["a", "b", "c"];
434/// assert_eq!(join_with_conjunction(&items, ", ", " or "), "a, b or c");
435///
436/// let items = vec!["a", "b"];
437/// assert_eq!(join_with_conjunction(&items, ", ", " and "), "a and b");
438///
439/// let items = vec!["a"];
440/// assert_eq!(join_with_conjunction(&items, ", ", " and "), "a");
441/// ```
442pub fn join_with_conjunction(items: &[&str], separator: &str, conjunction: &str) -> String {
443    match items.len() {
444        0 => String::new(),
445        1 => items[0].to_string(),
446        2 => format!("{}{}{}", items[0], conjunction, items[1]),
447        _ => {
448            let (last, rest) = items.split_last().unwrap();
449            format!("{}{}{}", rest.join(separator), conjunction, last)
450        }
451    }
452}
453
454// =============================================================================
455// Filename Utilities
456// =============================================================================
457
458/// Make a filename safe by removing or replacing problematic characters.
459///
460/// This is useful for creating safe filenames from user input.
461///
462/// # Example
463///
464/// ```rust
465/// use click::utils::make_safe_filename;
466///
467/// let safe = make_safe_filename("my file<>.txt");
468/// assert_eq!(safe, "my_file.txt");
469/// ```
470pub fn make_safe_filename(filename: &str) -> String {
471    // Characters not allowed in filenames on various platforms
472    const UNSAFE_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'];
473
474    let mut result = String::with_capacity(filename.len());
475    let mut last_was_space = false;
476
477    for c in filename.chars() {
478        if UNSAFE_CHARS.contains(&c) {
479            // Skip unsafe characters
480            continue;
481        } else if c.is_whitespace() {
482            // Replace multiple whitespace with single underscore
483            if !last_was_space {
484                result.push('_');
485                last_was_space = true;
486            }
487        } else if c.is_control() {
488            // Skip control characters
489            continue;
490        } else {
491            result.push(c);
492            last_was_space = false;
493        }
494    }
495
496    // Trim trailing underscores
497    result.trim_end_matches('_').to_string()
498}
499
500/// Get the file extension from a path, if any.
501pub fn get_extension(path: &Path) -> Option<&str> {
502    path.extension().and_then(OsStr::to_str)
503}
504
505/// Strip the file extension from a path.
506pub fn strip_extension(path: &Path) -> PathBuf {
507    let mut result = path.to_path_buf();
508    result.set_extension("");
509    result
510}
511
512// =============================================================================
513// Shell Argument Parsing
514// =============================================================================
515
516/// Parse a string like a shell would: handle quotes, escapes, and whitespace.
517///
518/// This is useful for parsing command-line strings from environment variables
519/// or configuration files.
520///
521/// # Rules
522///
523/// - Whitespace separates arguments
524/// - Single quotes (`'`) preserve everything literally (no escape sequences)
525/// - Double quotes (`"`) allow escape sequences (`\"`, `\\`)
526/// - Backslash outside quotes escapes the next character
527/// - Empty quotes produce empty strings
528///
529/// # Example
530///
531/// ```rust
532/// use click::utils::split_arg_string;
533///
534/// let args = split_arg_string("foo 'bar baz' \"quoted\"");
535/// assert_eq!(args, vec!["foo", "bar baz", "quoted"]);
536///
537/// let args = split_arg_string(r#"file\ name.txt "hello \"world\"""#);
538/// assert_eq!(args, vec!["file name.txt", r#"hello "world""#]);
539///
540/// let args = split_arg_string("'single' \"double\" plain");
541/// assert_eq!(args, vec!["single", "double", "plain"]);
542/// ```
543///
544/// # Reference
545///
546/// Based on Python Click's `shell_completion.py:split_arg_string`.
547pub fn split_arg_string(s: &str) -> Vec<String> {
548    let mut result = Vec::new();
549    let mut current = String::new();
550    let mut chars = s.chars().peekable();
551    let mut in_single_quote = false;
552    let mut in_double_quote = false;
553
554    while let Some(c) = chars.next() {
555        if in_single_quote {
556            // In single quotes: everything is literal until closing quote
557            if c == '\'' {
558                in_single_quote = false;
559            } else {
560                current.push(c);
561            }
562        } else if in_double_quote {
563            // In double quotes: handle escapes for " and \
564            if c == '"' {
565                in_double_quote = false;
566            } else if c == '\\' {
567                // Check for escape sequences
568                if let Some(&next) = chars.peek() {
569                    if next == '"' || next == '\\' {
570                        current.push(chars.next().unwrap());
571                    } else {
572                        // Not a recognized escape, keep the backslash
573                        current.push(c);
574                    }
575                } else {
576                    current.push(c);
577                }
578            } else {
579                current.push(c);
580            }
581        } else {
582            // Not in quotes
583            if c == '\'' {
584                in_single_quote = true;
585            } else if c == '"' {
586                in_double_quote = true;
587            } else if c == '\\' {
588                // Escape the next character
589                if let Some(next) = chars.next() {
590                    current.push(next);
591                }
592            } else if c.is_whitespace() {
593                // End of argument
594                if !current.is_empty() {
595                    result.push(current);
596                    current = String::new();
597                }
598            } else {
599                current.push(c);
600            }
601        }
602    }
603
604    // Don't forget the last argument
605    if !current.is_empty() {
606        result.push(current);
607    }
608
609    result
610}
611
612// =============================================================================
613// Argument Expansion
614// =============================================================================
615
616/// Expand glob patterns in arguments.
617///
618/// This function expands shell-style glob patterns like `*.txt` or `**/*.rs`
619/// into a list of matching file paths. Arguments that don't contain glob
620/// patterns or don't match any files are returned as-is.
621///
622/// # Glob Patterns
623///
624/// - `*` matches any sequence of characters except path separators
625/// - `?` matches any single character except path separators
626/// - `**` matches any sequence of characters including path separators
627/// - `[...]` matches any character in the brackets
628/// - `[!...]` matches any character not in the brackets
629///
630/// # Example
631///
632/// ```no_run
633/// use click::utils::expand_args;
634///
635/// // Assuming *.txt files exist in the current directory
636/// let args = vec!["file.rs".to_string(), "*.txt".to_string()];
637/// let expanded = expand_args(&args);
638/// // Returns ["file.rs", "a.txt", "b.txt", ...] if those files exist
639/// ```
640///
641/// # Reference
642///
643/// Based on Python Click's `utils.py:_expand_args`.
644pub fn expand_args(args: &[String]) -> Vec<String> {
645    let mut result = Vec::new();
646
647    for arg in args {
648        if has_glob_pattern(arg) {
649            // Try to expand the glob pattern
650            match expand_glob(arg) {
651                Some(matches) if !matches.is_empty() => {
652                    result.extend(matches);
653                }
654                _ => {
655                    // No matches or error: keep original argument
656                    result.push(arg.clone());
657                }
658            }
659        } else {
660            result.push(arg.clone());
661        }
662    }
663
664    result
665}
666
667/// Check if a string contains glob pattern characters.
668fn has_glob_pattern(s: &str) -> bool {
669    s.chars().any(|c| c == '*' || c == '?' || c == '[')
670}
671
672/// Expand a glob pattern into matching file paths.
673///
674/// Returns `None` if the pattern is invalid, or `Some(vec)` with matches.
675/// The result may be empty if no files match.
676fn expand_glob(pattern: &str) -> Option<Vec<String>> {
677    // Simple glob implementation that handles common patterns
678    // For production use, consider using the `glob` crate
679
680    let mut matches = Vec::new();
681
682    // Handle the pattern by converting it to a regex-like matcher
683    // This is a simplified implementation that handles basic patterns
684
685    // Get the directory part and the pattern part
686    let (dir, file_pattern) = split_pattern_path(pattern);
687
688    // Read the directory
689    let read_dir = if dir.is_empty() {
690        std::fs::read_dir(".")
691    } else {
692        std::fs::read_dir(&dir)
693    };
694
695    let entries = match read_dir {
696        Ok(entries) => entries,
697        Err(_) => return None,
698    };
699
700    // Compile the pattern into a matcher
701    let matcher = compile_glob_pattern(&file_pattern);
702
703    for entry in entries.flatten() {
704        let file_name = entry.file_name();
705        let name = file_name.to_string_lossy();
706
707        if matches_pattern(&name, &matcher) {
708            let path = if dir.is_empty() {
709                name.to_string()
710            } else {
711                format!("{}/{}", dir, name)
712            };
713            matches.push(path);
714        }
715    }
716
717    // Sort matches for consistent ordering
718    matches.sort();
719
720    Some(matches)
721}
722
723/// Split a glob pattern into directory and file pattern parts.
724fn split_pattern_path(pattern: &str) -> (String, String) {
725    // Find the last path separator before any glob characters
726    let glob_start = pattern
727        .chars()
728        .position(|c| c == '*' || c == '?' || c == '[')
729        .unwrap_or(pattern.len());
730
731    let prefix = &pattern[..glob_start];
732    let last_sep = prefix.rfind(|c| c == '/' || c == '\\');
733
734    match last_sep {
735        Some(idx) => (pattern[..idx].to_string(), pattern[idx + 1..].to_string()),
736        None => (String::new(), pattern.to_string()),
737    }
738}
739
740/// A compiled glob pattern for matching.
741#[derive(Debug)]
742enum GlobPart {
743    Literal(String),
744    Any,          // ?
745    AnySequence,  // *
746    CharClass(Vec<char>, bool), // [...] or [!...]
747}
748
749/// Compile a glob pattern into parts for matching.
750fn compile_glob_pattern(pattern: &str) -> Vec<GlobPart> {
751    let mut parts = Vec::new();
752    let mut chars = pattern.chars().peekable();
753    let mut literal = String::new();
754
755    while let Some(c) = chars.next() {
756        match c {
757            '*' => {
758                if !literal.is_empty() {
759                    parts.push(GlobPart::Literal(literal));
760                    literal = String::new();
761                }
762                // Collapse multiple *'s
763                while chars.peek() == Some(&'*') {
764                    chars.next();
765                }
766                parts.push(GlobPart::AnySequence);
767            }
768            '?' => {
769                if !literal.is_empty() {
770                    parts.push(GlobPart::Literal(literal));
771                    literal = String::new();
772                }
773                parts.push(GlobPart::Any);
774            }
775            '[' => {
776                if !literal.is_empty() {
777                    parts.push(GlobPart::Literal(literal));
778                    literal = String::new();
779                }
780                // Parse character class
781                let negated = chars.peek() == Some(&'!');
782                if negated {
783                    chars.next();
784                }
785                let mut class_chars = Vec::new();
786                while let Some(&ch) = chars.peek() {
787                    if ch == ']' {
788                        chars.next();
789                        break;
790                    }
791                    class_chars.push(chars.next().unwrap());
792                }
793                parts.push(GlobPart::CharClass(class_chars, negated));
794            }
795            '\\' => {
796                // Escape next character
797                if let Some(next) = chars.next() {
798                    literal.push(next);
799                }
800            }
801            _ => {
802                literal.push(c);
803            }
804        }
805    }
806
807    if !literal.is_empty() {
808        parts.push(GlobPart::Literal(literal));
809    }
810
811    parts
812}
813
814/// Check if a string matches a compiled glob pattern.
815fn matches_pattern(s: &str, parts: &[GlobPart]) -> bool {
816    matches_pattern_recursive(s, parts, 0)
817}
818
819/// Recursive helper for pattern matching.
820fn matches_pattern_recursive(s: &str, parts: &[GlobPart], part_idx: usize) -> bool {
821    if part_idx >= parts.len() {
822        return s.is_empty();
823    }
824
825    let part = &parts[part_idx];
826
827    match part {
828        GlobPart::Literal(lit) => {
829            if s.starts_with(lit.as_str()) {
830                matches_pattern_recursive(&s[lit.len()..], parts, part_idx + 1)
831            } else {
832                false
833            }
834        }
835        GlobPart::Any => {
836            if s.is_empty() {
837                false
838            } else {
839                // Skip one character
840                let mut chars = s.chars();
841                chars.next();
842                matches_pattern_recursive(chars.as_str(), parts, part_idx + 1)
843            }
844        }
845        GlobPart::AnySequence => {
846            // Try matching zero or more characters
847            // First try matching zero characters
848            if matches_pattern_recursive(s, parts, part_idx + 1) {
849                return true;
850            }
851            // Then try matching one or more characters
852            for (i, _) in s.char_indices() {
853                if matches_pattern_recursive(&s[i + 1..], parts, part_idx + 1) {
854                    return true;
855                }
856            }
857            false
858        }
859        GlobPart::CharClass(chars, negated) => {
860            if s.is_empty() {
861                return false;
862            }
863            let first = s.chars().next().unwrap();
864            let in_class = chars.contains(&first);
865            let matches = if *negated { !in_class } else { in_class };
866            if matches {
867                let mut remaining = s.chars();
868                remaining.next();
869                matches_pattern_recursive(remaining.as_str(), parts, part_idx + 1)
870            } else {
871                false
872            }
873        }
874    }
875}
876
877// =============================================================================
878// Tests
879// =============================================================================
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    #[test]
886    fn test_format_filename() {
887        // Test with absolute path
888        let path = Path::new("/usr/local/bin/test");
889        let formatted = format_filename(path);
890        assert!(!formatted.contains('\\'));
891
892        // Test with relative path
893        let path = Path::new("./relative/path");
894        let formatted = format_filename(path);
895        assert_eq!(formatted, "./relative/path");
896    }
897
898    #[test]
899    fn test_expand_path_tilde() {
900        if let Some(home) = home_dir() {
901            let path = expand_path("~/test");
902            assert_eq!(path, home.join("test"));
903
904            let path = expand_path("~");
905            assert_eq!(path, home);
906        }
907    }
908
909    #[test]
910    fn test_expand_path_env_vars() {
911        env::set_var("CLICK_TEST_VAR", "/test/path");
912
913        let path = expand_path("$CLICK_TEST_VAR/file.txt");
914        assert_eq!(path, PathBuf::from("/test/path/file.txt"));
915
916        let path = expand_path("${CLICK_TEST_VAR}/file.txt");
917        assert_eq!(path, PathBuf::from("/test/path/file.txt"));
918
919        env::remove_var("CLICK_TEST_VAR");
920    }
921
922    #[test]
923    fn test_expand_path_no_expansion() {
924        let path = expand_path("/absolute/path");
925        assert_eq!(path, PathBuf::from("/absolute/path"));
926
927        let path = expand_path("relative/path");
928        assert_eq!(path, PathBuf::from("relative/path"));
929    }
930
931    #[test]
932    fn test_get_os_args() {
933        let args = get_os_args();
934        // Should have at least the program name
935        assert!(!args.is_empty());
936    }
937
938    #[test]
939    fn test_get_app_dir() {
940        let app_dir = get_app_dir("testapp", false);
941        // Just verify it returns a path
942        assert!(!app_dir.as_os_str().is_empty());
943    }
944
945    #[test]
946    fn test_pluralize() {
947        assert_eq!(pluralize(0, "file", "files"), "files");
948        assert_eq!(pluralize(1, "file", "files"), "file");
949        assert_eq!(pluralize(2, "file", "files"), "files");
950        assert_eq!(pluralize(100, "item", "items"), "items");
951    }
952
953    #[test]
954    fn test_join_with_conjunction() {
955        assert_eq!(join_with_conjunction(&[], ", ", " and "), "");
956        assert_eq!(join_with_conjunction(&["a"], ", ", " and "), "a");
957        assert_eq!(join_with_conjunction(&["a", "b"], ", ", " and "), "a and b");
958        assert_eq!(
959            join_with_conjunction(&["a", "b", "c"], ", ", " and "),
960            "a, b and c"
961        );
962        assert_eq!(
963            join_with_conjunction(&["a", "b", "c", "d"], ", ", " or "),
964            "a, b, c or d"
965        );
966    }
967
968    #[test]
969    fn test_make_safe_filename() {
970        assert_eq!(make_safe_filename("normal.txt"), "normal.txt");
971        assert_eq!(make_safe_filename("file name.txt"), "file_name.txt");
972        assert_eq!(make_safe_filename("a<b>c.txt"), "abc.txt");
973        assert_eq!(make_safe_filename("a:b/c\\d.txt"), "abcd.txt");
974        assert_eq!(
975            make_safe_filename("  multiple   spaces  "),
976            "_multiple_spaces"
977        );
978    }
979
980    #[test]
981    fn test_safecall() {
982        assert_eq!(safecall("normal text"), "normal text");
983        assert_eq!(safecall("with\nnewline"), "with\nnewline");
984        assert_eq!(safecall("with\ttab"), "with\ttab");
985        // Control character (bell)
986        assert_eq!(safecall("with\x07bell"), "with\\x07bell");
987    }
988
989    #[test]
990    fn test_get_extension() {
991        assert_eq!(get_extension(Path::new("file.txt")), Some("txt"));
992        assert_eq!(get_extension(Path::new("file.tar.gz")), Some("gz"));
993        assert_eq!(get_extension(Path::new("file")), None);
994        assert_eq!(get_extension(Path::new(".hidden")), None);
995    }
996
997    #[test]
998    fn test_strip_extension() {
999        assert_eq!(strip_extension(Path::new("file.txt")), PathBuf::from("file"));
1000        assert_eq!(
1001            strip_extension(Path::new("path/to/file.txt")),
1002            PathBuf::from("path/to/file")
1003        );
1004        assert_eq!(
1005            strip_extension(Path::new("file.tar.gz")),
1006            PathBuf::from("file.tar")
1007        );
1008    }
1009
1010    #[test]
1011    fn test_home_dir() {
1012        // Should return Some on most systems
1013        let home = home_dir();
1014        if home.is_some() {
1015            assert!(home.unwrap().exists() || env::var("HOME").is_ok());
1016        }
1017    }
1018
1019    #[test]
1020    #[cfg(unix)]
1021    fn test_should_strip_ansi_no_color() {
1022        // Save current NO_COLOR
1023        let saved = env::var("NO_COLOR").ok();
1024
1025        env::set_var("NO_COLOR", "1");
1026        assert!(should_strip_ansi());
1027
1028        // Restore
1029        match saved {
1030            Some(v) => env::set_var("NO_COLOR", v),
1031            None => env::remove_var("NO_COLOR"),
1032        }
1033    }
1034
1035    #[test]
1036    #[cfg(unix)]
1037    fn test_should_strip_ansi_dumb_term() {
1038        // Save current TERM
1039        let saved = env::var("TERM").ok();
1040        env::remove_var("NO_COLOR");
1041
1042        env::set_var("TERM", "dumb");
1043        assert!(should_strip_ansi());
1044
1045        // Restore
1046        match saved {
1047            Some(v) => env::set_var("TERM", v),
1048            None => env::remove_var("TERM"),
1049        }
1050    }
1051
1052    // =========================================================================
1053    // Tests for split_arg_string
1054    // =========================================================================
1055
1056    #[test]
1057    fn test_split_arg_string_simple() {
1058        let args = split_arg_string("foo bar baz");
1059        assert_eq!(args, vec!["foo", "bar", "baz"]);
1060    }
1061
1062    #[test]
1063    fn test_split_arg_string_single_quotes() {
1064        let args = split_arg_string("foo 'bar baz' qux");
1065        assert_eq!(args, vec!["foo", "bar baz", "qux"]);
1066
1067        // Empty single quotes
1068        let args = split_arg_string("foo '' bar");
1069        assert_eq!(args, vec!["foo", "bar"]);
1070    }
1071
1072    #[test]
1073    fn test_split_arg_string_double_quotes() {
1074        let args = split_arg_string("foo \"bar baz\" qux");
1075        assert_eq!(args, vec!["foo", "bar baz", "qux"]);
1076
1077        // Escapes in double quotes
1078        let args = split_arg_string(r#"foo "bar \"quoted\"" baz"#);
1079        assert_eq!(args, vec!["foo", r#"bar "quoted""#, "baz"]);
1080    }
1081
1082    #[test]
1083    fn test_split_arg_string_backslash_escape() {
1084        // Backslash escapes space
1085        let args = split_arg_string(r"foo\ bar baz");
1086        assert_eq!(args, vec!["foo bar", "baz"]);
1087
1088        // Backslash escapes backslash
1089        let args = split_arg_string(r"foo\\bar");
1090        assert_eq!(args, vec![r"foo\bar"]);
1091    }
1092
1093    #[test]
1094    fn test_split_arg_string_mixed_quotes() {
1095        let args = split_arg_string("foo 'bar baz' \"quoted\" plain");
1096        assert_eq!(args, vec!["foo", "bar baz", "quoted", "plain"]);
1097    }
1098
1099    #[test]
1100    fn test_split_arg_string_empty() {
1101        let args = split_arg_string("");
1102        assert!(args.is_empty());
1103
1104        let args = split_arg_string("   ");
1105        assert!(args.is_empty());
1106    }
1107
1108    #[test]
1109    fn test_split_arg_string_complex() {
1110        // Example from Python Click documentation
1111        let args = split_arg_string("foo 'bar baz' \"quoted\"");
1112        assert_eq!(args, vec!["foo", "bar baz", "quoted"]);
1113    }
1114
1115    #[test]
1116    fn test_split_arg_string_no_escapes_in_single_quotes() {
1117        // Single quotes preserve everything literally
1118        let args = split_arg_string(r"'foo\\bar'");
1119        assert_eq!(args, vec![r"foo\\bar"]);
1120
1121        let args = split_arg_string(r#"'foo\"bar'"#);
1122        assert_eq!(args, vec![r#"foo\"bar"#]);
1123    }
1124
1125    // =========================================================================
1126    // Tests for expand_args and glob matching
1127    // =========================================================================
1128
1129    #[test]
1130    fn test_has_glob_pattern() {
1131        assert!(has_glob_pattern("*.txt"));
1132        assert!(has_glob_pattern("file?.txt"));
1133        assert!(has_glob_pattern("file[ab].txt"));
1134        assert!(!has_glob_pattern("normal.txt"));
1135        assert!(!has_glob_pattern("/path/to/file"));
1136    }
1137
1138    #[test]
1139    fn test_glob_pattern_literal() {
1140        let parts = compile_glob_pattern("hello");
1141        let is_match = matches_pattern("hello", &parts);
1142        assert!(is_match);
1143
1144        let is_match = matches_pattern("world", &parts);
1145        assert!(!is_match);
1146    }
1147
1148    #[test]
1149    fn test_glob_pattern_star() {
1150        let parts = compile_glob_pattern("*.txt");
1151        assert!(matches_pattern("file.txt", &parts));
1152        assert!(matches_pattern("hello.txt", &parts));
1153        assert!(matches_pattern(".txt", &parts));
1154        assert!(!matches_pattern("file.rs", &parts));
1155    }
1156
1157    #[test]
1158    fn test_glob_pattern_question() {
1159        let parts = compile_glob_pattern("file?.txt");
1160        assert!(matches_pattern("file1.txt", &parts));
1161        assert!(matches_pattern("filea.txt", &parts));
1162        assert!(!matches_pattern("file12.txt", &parts));
1163        assert!(!matches_pattern("file.txt", &parts));
1164    }
1165
1166    #[test]
1167    fn test_glob_pattern_char_class() {
1168        let parts = compile_glob_pattern("file[abc].txt");
1169        assert!(matches_pattern("filea.txt", &parts));
1170        assert!(matches_pattern("fileb.txt", &parts));
1171        assert!(matches_pattern("filec.txt", &parts));
1172        assert!(!matches_pattern("filed.txt", &parts));
1173    }
1174
1175    #[test]
1176    fn test_glob_pattern_negated_char_class() {
1177        let parts = compile_glob_pattern("file[!abc].txt");
1178        assert!(!matches_pattern("filea.txt", &parts));
1179        assert!(!matches_pattern("fileb.txt", &parts));
1180        assert!(matches_pattern("filed.txt", &parts));
1181        assert!(matches_pattern("file1.txt", &parts));
1182    }
1183
1184    #[test]
1185    fn test_expand_args_no_patterns() {
1186        let args = vec!["file.txt".to_string(), "other.rs".to_string()];
1187        let expanded = expand_args(&args);
1188        assert_eq!(expanded, args);
1189    }
1190
1191    #[test]
1192    fn test_expand_args_pattern_no_matches() {
1193        // Pattern that won't match anything
1194        let args = vec!["__nonexistent_pattern_xyz_*.abc".to_string()];
1195        let expanded = expand_args(&args);
1196        // Should keep original if no matches
1197        assert_eq!(expanded, args);
1198    }
1199
1200    #[test]
1201    fn test_split_pattern_path() {
1202        let (dir, pattern) = split_pattern_path("*.txt");
1203        assert_eq!(dir, "");
1204        assert_eq!(pattern, "*.txt");
1205
1206        let (dir, pattern) = split_pattern_path("src/*.rs");
1207        assert_eq!(dir, "src");
1208        assert_eq!(pattern, "*.rs");
1209
1210        let (dir, pattern) = split_pattern_path("/home/user/*.txt");
1211        assert_eq!(dir, "/home/user");
1212        assert_eq!(pattern, "*.txt");
1213    }
1214}