use std::collections::HashSet;
const PREFIX_COMMANDS: &[&str] = &[
"sudo",
"env",
"nix-shell",
"command",
"exec",
"nice",
"time",
];
const SHELL_BUILTINS: &[&str] = &[
"cd", "echo", "export", "source", "set", "unset", "true", "false", "test", "[", "]", "declare",
"local", "readonly", "typeset", "alias", "bg", "fg", "jobs", "kill", "wait", "trap", "eval",
"let", "shift", "return", "exit", "break", "continue", "pwd", "pushd", "popd", "dirs", "umask",
"ulimit", "hash", "type", "builtin", "caller", "compgen", "complete", "printf", "read",
];
pub fn extract_software(command: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut result = Vec::new();
let segments = split_command(command);
for segment in segments {
if let Some(name) = extract_first_tool(segment.trim()) {
if !SHELL_BUILTINS.contains(&name.as_str()) && seen.insert(name.clone()) {
result.push(name);
}
}
}
result
}
fn split_command(command: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let bytes = command.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
while i < len {
let b = bytes[i];
if b == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
i += 1;
continue;
}
if b == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
i += 1;
continue;
}
if in_single_quote || in_double_quote {
i += 1;
continue;
}
if i + 1 < len {
let next = bytes[i + 1];
if (b == b'|' && next == b'|') || (b == b'&' && next == b'&') {
segments.push(&command[start..i]);
i += 2;
start = i;
continue;
}
}
if b == b'|' || b == b';' {
segments.push(&command[start..i]);
i += 1;
start = i;
continue;
}
i += 1;
}
if start < len {
segments.push(&command[start..]);
}
segments
}
fn extract_first_tool(segment: &str) -> Option<String> {
let mut tokens = segment.split_whitespace();
let mut token = tokens.next()?;
while PREFIX_COMMANDS.contains(&strip_path(token).as_str()) {
token = tokens.next()?;
loop {
if token.starts_with('-') {
token = tokens.next()?;
if token.parse::<f64>().is_ok() {
token = tokens.next()?;
}
} else if token.contains('=') {
token = tokens.next()?;
} else {
break;
}
}
}
let name = strip_path(token);
if name.is_empty() {
return None;
}
Some(name)
}
fn strip_path(token: &str) -> String {
match token.rfind('/') {
Some(idx) => token[idx + 1..].to_string(),
None => token.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_command() {
assert_eq!(extract_software("git commit -m \"fix\""), vec!["git"]);
}
#[test]
fn chained_same_tool() {
assert_eq!(extract_software("cargo build && cargo test"), vec!["cargo"]);
}
#[test]
fn piped_different_tools() {
assert_eq!(
extract_software("cat foo.txt | grep bar | wc -l"),
vec!["cat", "grep", "wc"]
);
}
#[test]
fn sudo_prefix() {
assert_eq!(extract_software("sudo docker compose up"), vec!["docker"]);
}
#[test]
fn cd_filtered_out() {
assert_eq!(extract_software("cd /tmp && npm install"), vec!["npm"]);
}
#[test]
fn echo_filtered_out() {
let result = extract_software("echo \"hello\"");
assert!(result.is_empty());
}
#[test]
fn absolute_path_stripped() {
assert_eq!(
extract_software("/usr/local/bin/python3 script.py"),
vec!["python3"]
);
}
#[test]
fn empty_command() {
assert!(extract_software("").is_empty());
}
#[test]
fn whitespace_only() {
assert!(extract_software(" ").is_empty());
}
#[test]
fn semicolon_separator() {
assert_eq!(
extract_software("git add . ; git commit -m 'test'"),
vec!["git"]
);
}
#[test]
fn or_chain() {
assert_eq!(
extract_software("docker ps || docker start mycontainer"),
vec!["docker"]
);
}
#[test]
fn env_prefix() {
assert_eq!(
extract_software("env NODE_ENV=production node server.js"),
vec!["node"]
);
}
#[test]
fn time_prefix() {
assert_eq!(extract_software("time cargo build"), vec!["cargo"]);
}
#[test]
fn nice_prefix() {
assert_eq!(extract_software("nice -n 10 make -j4"), vec!["make"]);
}
#[test]
fn multiple_builtins_all_filtered() {
assert!(extract_software("export FOO=bar && cd /tmp && echo done").is_empty());
}
#[test]
fn mixed_builtins_and_real() {
assert_eq!(
extract_software("export PATH=$PATH:/foo && cargo build"),
vec!["cargo"]
);
}
#[test]
fn complex_pipeline() {
assert_eq!(
extract_software("find . -name '*.rs' | xargs grep 'TODO' | sort -u"),
vec!["find", "xargs", "sort"]
);
}
#[test]
fn quoted_pipe_not_split() {
let result = extract_software(r#"echo "hello | world""#);
assert!(result.is_empty());
}
#[test]
fn flag_with_equals() {
assert_eq!(
extract_software("cargo build --target=x86_64"),
vec!["cargo"]
);
}
#[test]
fn trailing_operator() {
assert_eq!(extract_software("git add . &&"), vec!["git"]);
}
}