use std::env;
use std::ffi::OsStr;
use std::io::{self, Stdout};
use std::path::{Path, PathBuf};
pub fn get_text_stdout() -> Stdout {
io::stdout()
}
pub fn get_text_stderr() -> io::Stderr {
io::stderr()
}
pub fn get_binary_stdout() -> Stdout {
io::stdout()
}
pub fn get_binary_stdin() -> io::Stdin {
io::stdin()
}
pub fn format_filename(path: &Path) -> String {
let path_str = path.to_string_lossy();
if let Some(home) = home_dir() {
let home_str = home.to_string_lossy();
if path_str.starts_with(home_str.as_ref()) {
let relative = &path_str[home_str.len()..];
let relative = relative.trim_start_matches(['/', '\\']);
return format!("~/{}", relative.replace('\\', "/"));
}
}
path_str.replace('\\', "/")
}
pub fn get_app_dir(app_name: &str, _roaming: bool) -> PathBuf {
#[cfg(target_os = "windows")]
{
let base = if _roaming {
env::var("APPDATA").ok()
} else {
env::var("LOCALAPPDATA").ok()
};
match base {
Some(base) => PathBuf::from(base).join(app_name),
None => PathBuf::from(".").join(app_name),
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = home_dir() {
home.join("Library")
.join("Application Support")
.join(app_name)
} else {
PathBuf::from(".").join(app_name)
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if _roaming {
if let Ok(xdg_data) = env::var("XDG_DATA_HOME") {
return PathBuf::from(xdg_data).join(app_name);
}
if let Some(home) = home_dir() {
return home.join(".local").join("share").join(app_name);
}
}
if let Some(home) = home_dir() {
home.join(format!(".{}", app_name))
} else {
PathBuf::from(".").join(format!(".{}", app_name))
}
}
}
pub fn expand_path(path: &str) -> PathBuf {
let mut result = path.to_string();
if result.starts_with('~') {
if let Some(home) = home_dir() {
let home_str = home.to_string_lossy();
if result == "~" {
return home;
} else if result.starts_with("~/") || result.starts_with("~\\") {
result = format!("{}{}", home_str, &result[1..]);
}
}
}
result = expand_env_vars(&result);
PathBuf::from(result)
}
fn expand_env_vars(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' {
if chars.peek() == Some(&'{') {
chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
if let Ok(value) = env::var(&var_name) {
result.push_str(&value);
}
} else {
let mut var_name = String::new();
while let Some(&next_c) = chars.peek() {
if next_c.is_alphanumeric() || next_c == '_' {
var_name.push(next_c);
chars.next();
} else {
break;
}
}
if !var_name.is_empty() {
if let Ok(value) = env::var(&var_name) {
result.push_str(&value);
}
}
}
} else {
result.push(c);
}
}
result
}
pub fn home_dir() -> Option<PathBuf> {
if let Ok(home) = env::var("HOME") {
return Some(PathBuf::from(home));
}
#[cfg(target_os = "windows")]
{
if let Ok(profile) = env::var("USERPROFILE") {
return Some(PathBuf::from(profile));
}
if let (Ok(drive), Ok(path)) = (env::var("HOMEDRIVE"), env::var("HOMEPATH")) {
return Some(PathBuf::from(format!("{}{}", drive, path)));
}
}
None
}
pub fn get_os_args() -> Vec<String> {
env::args().collect()
}
pub fn get_os_args_skip_program() -> Vec<String> {
env::args().skip(1).collect()
}
pub fn should_strip_ansi() -> bool {
if env::var("NO_COLOR").is_ok() {
return true;
}
if let Ok(term) = env::var("TERM") {
if term == "dumb" {
return true;
}
}
if env::var("COLORTERM").is_ok() {
return false;
}
if let Ok(term) = env::var("TERM") {
if term.contains("color") || term.contains("256") || term.contains("xterm") {
return false;
}
}
false
}
pub fn is_tty() -> bool {
!should_strip_ansi()
}
pub fn get_terminal_width() -> Option<usize> {
if let Ok(cols) = env::var("COLUMNS") {
if let Ok(width) = cols.parse::<usize>() {
if width > 0 {
return Some(width);
}
}
}
None
}
pub fn safecall<T: AsRef<str>>(s: T) -> String {
let s = s.as_ref();
let mut result = String::with_capacity(s.len());
for c in s.chars() {
if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
result.push_str(&format!("\\x{:02x}", c as u32));
} else {
result.push(c);
}
}
result
}
pub fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 {
singular
} else {
plural
}
}
pub fn join_with_conjunction(items: &[&str], separator: &str, conjunction: &str) -> String {
match items.len() {
0 => String::new(),
1 => items[0].to_string(),
2 => format!("{}{}{}", items[0], conjunction, items[1]),
_ => {
let (last, rest) = items.split_last().unwrap();
format!("{}{}{}", rest.join(separator), conjunction, last)
}
}
}
pub fn make_safe_filename(filename: &str) -> String {
const UNSAFE_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'];
let mut result = String::with_capacity(filename.len());
let mut last_was_space = false;
for c in filename.chars() {
if UNSAFE_CHARS.contains(&c) {
continue;
} else if c.is_whitespace() {
if !last_was_space {
result.push('_');
last_was_space = true;
}
} else if c.is_control() {
continue;
} else {
result.push(c);
last_was_space = false;
}
}
result.trim_end_matches('_').to_string()
}
pub fn get_extension(path: &Path) -> Option<&str> {
path.extension().and_then(OsStr::to_str)
}
pub fn strip_extension(path: &Path) -> PathBuf {
let mut result = path.to_path_buf();
result.set_extension("");
result
}
pub fn split_arg_string(s: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
let mut in_single_quote = false;
let mut in_double_quote = false;
while let Some(c) = chars.next() {
if in_single_quote {
if c == '\'' {
in_single_quote = false;
} else {
current.push(c);
}
} else if in_double_quote {
if c == '"' {
in_double_quote = false;
} else if c == '\\' {
if let Some(&next) = chars.peek() {
if next == '"' || next == '\\' {
current.push(chars.next().unwrap());
} else {
current.push(c);
}
} else {
current.push(c);
}
} else {
current.push(c);
}
} else {
if c == '\'' {
in_single_quote = true;
} else if c == '"' {
in_double_quote = true;
} else if c == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if c.is_whitespace() {
if !current.is_empty() {
result.push(current);
current = String::new();
}
} else {
current.push(c);
}
}
}
if !current.is_empty() {
result.push(current);
}
result
}
pub fn expand_args(args: &[String]) -> Vec<String> {
let mut result = Vec::new();
for arg in args {
if has_glob_pattern(arg) {
match expand_glob(arg) {
Some(matches) if !matches.is_empty() => {
result.extend(matches);
}
_ => {
result.push(arg.clone());
}
}
} else {
result.push(arg.clone());
}
}
result
}
fn has_glob_pattern(s: &str) -> bool {
s.chars().any(|c| c == '*' || c == '?' || c == '[')
}
fn expand_glob(pattern: &str) -> Option<Vec<String>> {
let mut matches = Vec::new();
let (dir, file_pattern) = split_pattern_path(pattern);
let read_dir = if dir.is_empty() {
std::fs::read_dir(".")
} else {
std::fs::read_dir(&dir)
};
let entries = match read_dir {
Ok(entries) => entries,
Err(_) => return None,
};
let matcher = compile_glob_pattern(&file_pattern);
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if matches_pattern(&name, &matcher) {
let path = if dir.is_empty() {
name.to_string()
} else {
format!("{}/{}", dir, name)
};
matches.push(path);
}
}
matches.sort();
Some(matches)
}
fn split_pattern_path(pattern: &str) -> (String, String) {
let glob_start = pattern
.chars()
.position(|c| c == '*' || c == '?' || c == '[')
.unwrap_or(pattern.len());
let prefix = &pattern[..glob_start];
let last_sep = prefix.rfind(|c| c == '/' || c == '\\');
match last_sep {
Some(idx) => (pattern[..idx].to_string(), pattern[idx + 1..].to_string()),
None => (String::new(), pattern.to_string()),
}
}
#[derive(Debug)]
enum GlobPart {
Literal(String),
Any, AnySequence, CharClass(Vec<char>, bool), }
fn compile_glob_pattern(pattern: &str) -> Vec<GlobPart> {
let mut parts = Vec::new();
let mut chars = pattern.chars().peekable();
let mut literal = String::new();
while let Some(c) = chars.next() {
match c {
'*' => {
if !literal.is_empty() {
parts.push(GlobPart::Literal(literal));
literal = String::new();
}
while chars.peek() == Some(&'*') {
chars.next();
}
parts.push(GlobPart::AnySequence);
}
'?' => {
if !literal.is_empty() {
parts.push(GlobPart::Literal(literal));
literal = String::new();
}
parts.push(GlobPart::Any);
}
'[' => {
if !literal.is_empty() {
parts.push(GlobPart::Literal(literal));
literal = String::new();
}
let negated = chars.peek() == Some(&'!');
if negated {
chars.next();
}
let mut class_chars = Vec::new();
while let Some(&ch) = chars.peek() {
if ch == ']' {
chars.next();
break;
}
class_chars.push(chars.next().unwrap());
}
parts.push(GlobPart::CharClass(class_chars, negated));
}
'\\' => {
if let Some(next) = chars.next() {
literal.push(next);
}
}
_ => {
literal.push(c);
}
}
}
if !literal.is_empty() {
parts.push(GlobPart::Literal(literal));
}
parts
}
fn matches_pattern(s: &str, parts: &[GlobPart]) -> bool {
matches_pattern_recursive(s, parts, 0)
}
fn matches_pattern_recursive(s: &str, parts: &[GlobPart], part_idx: usize) -> bool {
if part_idx >= parts.len() {
return s.is_empty();
}
let part = &parts[part_idx];
match part {
GlobPart::Literal(lit) => {
if s.starts_with(lit.as_str()) {
matches_pattern_recursive(&s[lit.len()..], parts, part_idx + 1)
} else {
false
}
}
GlobPart::Any => {
if s.is_empty() {
false
} else {
let mut chars = s.chars();
chars.next();
matches_pattern_recursive(chars.as_str(), parts, part_idx + 1)
}
}
GlobPart::AnySequence => {
if matches_pattern_recursive(s, parts, part_idx + 1) {
return true;
}
for (i, _) in s.char_indices() {
if matches_pattern_recursive(&s[i + 1..], parts, part_idx + 1) {
return true;
}
}
false
}
GlobPart::CharClass(chars, negated) => {
if s.is_empty() {
return false;
}
let first = s.chars().next().unwrap();
let in_class = chars.contains(&first);
let matches = if *negated { !in_class } else { in_class };
if matches {
let mut remaining = s.chars();
remaining.next();
matches_pattern_recursive(remaining.as_str(), parts, part_idx + 1)
} else {
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_filename() {
let path = Path::new("/usr/local/bin/test");
let formatted = format_filename(path);
assert!(!formatted.contains('\\'));
let path = Path::new("./relative/path");
let formatted = format_filename(path);
assert_eq!(formatted, "./relative/path");
}
#[test]
fn test_expand_path_tilde() {
if let Some(home) = home_dir() {
let path = expand_path("~/test");
assert_eq!(path, home.join("test"));
let path = expand_path("~");
assert_eq!(path, home);
}
}
#[test]
fn test_expand_path_env_vars() {
env::set_var("CLICK_TEST_VAR", "/test/path");
let path = expand_path("$CLICK_TEST_VAR/file.txt");
assert_eq!(path, PathBuf::from("/test/path/file.txt"));
let path = expand_path("${CLICK_TEST_VAR}/file.txt");
assert_eq!(path, PathBuf::from("/test/path/file.txt"));
env::remove_var("CLICK_TEST_VAR");
}
#[test]
fn test_expand_path_no_expansion() {
let path = expand_path("/absolute/path");
assert_eq!(path, PathBuf::from("/absolute/path"));
let path = expand_path("relative/path");
assert_eq!(path, PathBuf::from("relative/path"));
}
#[test]
fn test_get_os_args() {
let args = get_os_args();
assert!(!args.is_empty());
}
#[test]
fn test_get_app_dir() {
let app_dir = get_app_dir("testapp", false);
assert!(!app_dir.as_os_str().is_empty());
}
#[test]
fn test_pluralize() {
assert_eq!(pluralize(0, "file", "files"), "files");
assert_eq!(pluralize(1, "file", "files"), "file");
assert_eq!(pluralize(2, "file", "files"), "files");
assert_eq!(pluralize(100, "item", "items"), "items");
}
#[test]
fn test_join_with_conjunction() {
assert_eq!(join_with_conjunction(&[], ", ", " and "), "");
assert_eq!(join_with_conjunction(&["a"], ", ", " and "), "a");
assert_eq!(join_with_conjunction(&["a", "b"], ", ", " and "), "a and b");
assert_eq!(
join_with_conjunction(&["a", "b", "c"], ", ", " and "),
"a, b and c"
);
assert_eq!(
join_with_conjunction(&["a", "b", "c", "d"], ", ", " or "),
"a, b, c or d"
);
}
#[test]
fn test_make_safe_filename() {
assert_eq!(make_safe_filename("normal.txt"), "normal.txt");
assert_eq!(make_safe_filename("file name.txt"), "file_name.txt");
assert_eq!(make_safe_filename("a<b>c.txt"), "abc.txt");
assert_eq!(make_safe_filename("a:b/c\\d.txt"), "abcd.txt");
assert_eq!(
make_safe_filename(" multiple spaces "),
"_multiple_spaces"
);
}
#[test]
fn test_safecall() {
assert_eq!(safecall("normal text"), "normal text");
assert_eq!(safecall("with\nnewline"), "with\nnewline");
assert_eq!(safecall("with\ttab"), "with\ttab");
assert_eq!(safecall("with\x07bell"), "with\\x07bell");
}
#[test]
fn test_get_extension() {
assert_eq!(get_extension(Path::new("file.txt")), Some("txt"));
assert_eq!(get_extension(Path::new("file.tar.gz")), Some("gz"));
assert_eq!(get_extension(Path::new("file")), None);
assert_eq!(get_extension(Path::new(".hidden")), None);
}
#[test]
fn test_strip_extension() {
assert_eq!(
strip_extension(Path::new("file.txt")),
PathBuf::from("file")
);
assert_eq!(
strip_extension(Path::new("path/to/file.txt")),
PathBuf::from("path/to/file")
);
assert_eq!(
strip_extension(Path::new("file.tar.gz")),
PathBuf::from("file.tar")
);
}
#[test]
fn test_home_dir() {
let home = home_dir();
if home.is_some() {
assert!(home.unwrap().exists() || env::var("HOME").is_ok());
}
}
#[test]
#[cfg(unix)]
fn test_should_strip_ansi_no_color() {
let saved = env::var("NO_COLOR").ok();
env::set_var("NO_COLOR", "1");
assert!(should_strip_ansi());
match saved {
Some(v) => env::set_var("NO_COLOR", v),
None => env::remove_var("NO_COLOR"),
}
}
#[test]
#[cfg(unix)]
fn test_should_strip_ansi_dumb_term() {
let saved = env::var("TERM").ok();
env::remove_var("NO_COLOR");
env::set_var("TERM", "dumb");
assert!(should_strip_ansi());
match saved {
Some(v) => env::set_var("TERM", v),
None => env::remove_var("TERM"),
}
}
#[test]
fn test_split_arg_string_simple() {
let args = split_arg_string("foo bar baz");
assert_eq!(args, vec!["foo", "bar", "baz"]);
}
#[test]
fn test_split_arg_string_single_quotes() {
let args = split_arg_string("foo 'bar baz' qux");
assert_eq!(args, vec!["foo", "bar baz", "qux"]);
let args = split_arg_string("foo '' bar");
assert_eq!(args, vec!["foo", "bar"]);
}
#[test]
fn test_split_arg_string_double_quotes() {
let args = split_arg_string("foo \"bar baz\" qux");
assert_eq!(args, vec!["foo", "bar baz", "qux"]);
let args = split_arg_string(r#"foo "bar \"quoted\"" baz"#);
assert_eq!(args, vec!["foo", r#"bar "quoted""#, "baz"]);
}
#[test]
fn test_split_arg_string_backslash_escape() {
let args = split_arg_string(r"foo\ bar baz");
assert_eq!(args, vec!["foo bar", "baz"]);
let args = split_arg_string(r"foo\\bar");
assert_eq!(args, vec![r"foo\bar"]);
}
#[test]
fn test_split_arg_string_mixed_quotes() {
let args = split_arg_string("foo 'bar baz' \"quoted\" plain");
assert_eq!(args, vec!["foo", "bar baz", "quoted", "plain"]);
}
#[test]
fn test_split_arg_string_empty() {
let args = split_arg_string("");
assert!(args.is_empty());
let args = split_arg_string(" ");
assert!(args.is_empty());
}
#[test]
fn test_split_arg_string_complex() {
let args = split_arg_string("foo 'bar baz' \"quoted\"");
assert_eq!(args, vec!["foo", "bar baz", "quoted"]);
}
#[test]
fn test_split_arg_string_no_escapes_in_single_quotes() {
let args = split_arg_string(r"'foo\\bar'");
assert_eq!(args, vec![r"foo\\bar"]);
let args = split_arg_string(r#"'foo\"bar'"#);
assert_eq!(args, vec![r#"foo\"bar"#]);
}
#[test]
fn test_has_glob_pattern() {
assert!(has_glob_pattern("*.txt"));
assert!(has_glob_pattern("file?.txt"));
assert!(has_glob_pattern("file[ab].txt"));
assert!(!has_glob_pattern("normal.txt"));
assert!(!has_glob_pattern("/path/to/file"));
}
#[test]
fn test_glob_pattern_literal() {
let parts = compile_glob_pattern("hello");
let is_match = matches_pattern("hello", &parts);
assert!(is_match);
let is_match = matches_pattern("world", &parts);
assert!(!is_match);
}
#[test]
fn test_glob_pattern_star() {
let parts = compile_glob_pattern("*.txt");
assert!(matches_pattern("file.txt", &parts));
assert!(matches_pattern("hello.txt", &parts));
assert!(matches_pattern(".txt", &parts));
assert!(!matches_pattern("file.rs", &parts));
}
#[test]
fn test_glob_pattern_question() {
let parts = compile_glob_pattern("file?.txt");
assert!(matches_pattern("file1.txt", &parts));
assert!(matches_pattern("filea.txt", &parts));
assert!(!matches_pattern("file12.txt", &parts));
assert!(!matches_pattern("file.txt", &parts));
}
#[test]
fn test_glob_pattern_char_class() {
let parts = compile_glob_pattern("file[abc].txt");
assert!(matches_pattern("filea.txt", &parts));
assert!(matches_pattern("fileb.txt", &parts));
assert!(matches_pattern("filec.txt", &parts));
assert!(!matches_pattern("filed.txt", &parts));
}
#[test]
fn test_glob_pattern_negated_char_class() {
let parts = compile_glob_pattern("file[!abc].txt");
assert!(!matches_pattern("filea.txt", &parts));
assert!(!matches_pattern("fileb.txt", &parts));
assert!(matches_pattern("filed.txt", &parts));
assert!(matches_pattern("file1.txt", &parts));
}
#[test]
fn test_expand_args_no_patterns() {
let args = vec!["file.txt".to_string(), "other.rs".to_string()];
let expanded = expand_args(&args);
assert_eq!(expanded, args);
}
#[test]
fn test_expand_args_pattern_no_matches() {
let args = vec!["__nonexistent_pattern_xyz_*.abc".to_string()];
let expanded = expand_args(&args);
assert_eq!(expanded, args);
}
#[test]
fn test_split_pattern_path() {
let (dir, pattern) = split_pattern_path("*.txt");
assert_eq!(dir, "");
assert_eq!(pattern, "*.txt");
let (dir, pattern) = split_pattern_path("src/*.rs");
assert_eq!(dir, "src");
assert_eq!(pattern, "*.rs");
let (dir, pattern) = split_pattern_path("/home/user/*.txt");
assert_eq!(dir, "/home/user");
assert_eq!(pattern, "*.txt");
}
}