mod io;
pub fn split_command(cmd: &str) -> std::io::Result<Vec<String>> {
let cmd = cmd.trim();
if cmd.is_empty() {
return Ok(vec![]);
}
shell_words::split(cmd).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Failed to parse command string: {err}"),
)
})
}
pub(crate) fn is_sensitive_key(key: &str) -> bool {
let key = key.trim().trim_start_matches('-').trim_start_matches('-');
let key = key
.split_once('=')
.or_else(|| key.split_once(':'))
.map_or(key, |(k, _)| k)
.trim()
.to_ascii_lowercase()
.replace('_', "-");
matches!(
key.as_str(),
"token"
| "access-token"
| "api-key"
| "apikey"
| "auth"
| "authorization"
| "bearer"
| "client-secret"
| "password"
| "pass"
| "passwd"
| "private-key"
| "secret"
)
}
fn redact_arg_value(key: &str, value: &str) -> String {
if is_sensitive_key(key) {
return "<redacted>".to_string();
}
io::secret_like_regex().map_or_else(
|| value.to_string(),
|re| re.replace_all(value, "<redacted>").to_string(),
)
}
fn shell_quote_for_log(arg: &str) -> String {
if arg.is_empty() {
return "''".to_string();
}
if !arg
.chars()
.any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\'))
{
return arg.to_string();
}
let escaped = arg.replace('\'', r#"'\"'\"'"#);
format!("'{escaped}'")
}
pub fn format_argv_for_log(argv: &[String]) -> String {
let indices = 0..argv.len();
let out: Vec<String> = indices
.map(|i| {
let arg = &argv[i];
let prev_was_sensitive = i > 0 && is_sensitive_key(&argv[i - 1]);
if prev_was_sensitive {
return "<redacted>".to_string();
}
if let Some((k, v)) = arg.split_once('=') {
let env_key = k.to_ascii_uppercase();
let looks_like_secret_env = env_key.contains("TOKEN")
|| env_key.contains("SECRET")
|| env_key.contains("PASSWORD")
|| env_key.contains("PASS")
|| env_key.contains("KEY");
if is_sensitive_key(k) || looks_like_secret_env {
return format!("{}=<redacted>", shell_quote_for_log(k));
}
let redacted = redact_arg_value(k, v);
return shell_quote_for_log(&format!("{k}={redacted}"));
}
if is_sensitive_key(arg) {
return arg.to_string();
}
let redacted = io::secret_like_regex().map_or_else(
|| arg.clone(),
|re| re.replace_all(arg, "<redacted>").to_string(),
);
shell_quote_for_log(&redacted)
})
.collect();
out.join(" ")
}
#[must_use]
pub fn truncate_text(text: &str, limit: usize) -> String {
if limit <= 3 {
return text.chars().take(limit).collect();
}
let char_count = text.chars().count();
if char_count <= limit {
text.to_string()
} else {
let truncate_at = limit.saturating_sub(3);
let truncated: String = text.chars().take(truncate_at).collect();
format!("{truncated}...")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_command_simple() {
let result = split_command("echo hello").unwrap();
assert_eq!(result, vec!["echo", "hello"]);
}
#[test]
fn test_split_command_with_quotes() {
let result = split_command("echo 'hello world'").unwrap();
assert_eq!(result, vec!["echo", "hello world"]);
}
#[test]
fn test_split_command_empty() {
let result = split_command("").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_split_command_whitespace() {
let result = split_command(" ").unwrap();
assert!(result.is_empty());
}
#[test]
fn test_truncate_text_no_truncation() {
assert_eq!(truncate_text("hello", 10), "hello");
assert_eq!(truncate_text("hello", 5), "hello");
}
#[test]
fn test_truncate_text_with_ellipsis() {
assert_eq!(truncate_text("hello world", 8), "hello...");
}
#[test]
fn test_truncate_text_unicode() {
let text = "日本語テスト"; assert_eq!(truncate_text(text, 10), "日本語テスト");
assert_eq!(truncate_text(text, 6), "日本語テスト");
assert_eq!(truncate_text(text, 5), "日本...");
}
#[test]
fn test_truncate_text_emoji() {
let text = "Hello 👋 World";
assert_eq!(truncate_text(text, 20), "Hello 👋 World");
assert_eq!(truncate_text(text, 10), "Hello 👋...");
}
#[test]
fn test_truncate_text_edge_cases() {
assert_eq!(truncate_text("abc", 3), "abc");
assert_eq!(truncate_text("abcd", 3), "abc"); assert_eq!(truncate_text("ab", 1), "a");
assert_eq!(truncate_text("", 5), "");
}
#[test]
fn test_truncate_text_cjk_characters() {
let text = "日本語テスト"; assert_eq!(truncate_text(text, 4), "日...");
assert_eq!(truncate_text(text, 6), "日本語テスト");
}
#[test]
fn test_truncate_text_mixed_multibyte() {
let text = "Hello 世界 test"; assert_eq!(truncate_text(text, 20), "Hello 世界 test");
assert_eq!(truncate_text(text, 10), "Hello 世...");
}
#[test]
fn test_truncate_text_exact_boundary() {
let text = "ab日cd"; assert_eq!(truncate_text(text, 5), "ab日cd");
assert_eq!(truncate_text(text, 4), "a...");
}
#[test]
fn test_truncate_text_error_message_style() {
let text = "Error: ".to_string() + &"日".repeat(200);
let result = truncate_text(&text, 50);
assert!(result.ends_with("..."), "Result should end with '...'");
assert!(
result.chars().count() <= 50,
"Result char count {} exceeds limit 50",
result.chars().count()
);
}
#[test]
fn test_truncate_text_4byte_emoji() {
let text = "🎉🎊🎈"; assert_eq!(truncate_text(text, 3), "🎉🎊🎈"); assert_eq!(truncate_text(text, 4), "🎉🎊🎈"); assert_eq!(truncate_text(text, 5), "🎉🎊🎈");
assert_eq!(truncate_text(text, 2), "🎉🎊");
}
#[test]
fn test_truncate_text_combining_characters() {
let text = "cafe\u{0301}"; let result = truncate_text(text, 10);
assert_eq!(result, "cafe\u{0301}"); }
}