use std::io;
use std::path::Path;
use tokio::process::Command;
use crate::core::error::{ErrorCode, Result, UpkeepError};
pub struct ExternalToolConfig<'a> {
pub tool_name: &'a str,
pub install_hint: &'a str,
}
pub async fn run_cargo_tool(
args: &[&str],
workspace_root: &Path,
config: &ExternalToolConfig<'_>,
) -> Result<std::process::Output> {
Command::new("cargo")
.args(args)
.current_dir(workspace_root)
.output()
.await
.map_err(|err| match err.kind() {
io::ErrorKind::NotFound if !workspace_root.is_dir() => UpkeepError::context(
ErrorCode::Io,
format!(
"workspace directory does not exist or is not a directory: {}",
workspace_root.display()
),
err,
),
io::ErrorKind::NotFound => UpkeepError::message(
ErrorCode::MissingTool,
"cargo is not installed or not on PATH",
),
_ => UpkeepError::context(
ErrorCode::ExternalCommand,
format!("failed to execute cargo {}", config.tool_name),
err,
),
})
}
pub fn handle_tool_output(
output: std::process::Output,
config: &ExternalToolConfig<'_>,
is_missing_tool: impl FnOnce(&str) -> bool,
) -> Result<std::process::Output> {
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if is_missing_tool(&stderr) {
return Err(UpkeepError::message(
ErrorCode::MissingTool,
format!(
"cargo-{} is not installed; install with `{}`",
config.tool_name, config.install_hint
),
));
}
if !output.stdout.is_empty() {
return Ok(output);
}
let message = stderr.trim();
if message.is_empty() {
return Err(UpkeepError::message(
ErrorCode::ExternalCommand,
format!("cargo {} failed with no stderr output", config.tool_name),
));
}
Err(UpkeepError::message(
ErrorCode::ExternalCommand,
format!("cargo {} failed: {message}", config.tool_name),
))
}
const MISSING_SUBCOMMAND_PATTERNS: [&str; 3] = [
"no such subcommand",
"unknown subcommand",
"no such command",
];
pub fn is_missing_subcommand(stderr: &str, tool_name: &str) -> bool {
let tool_name_lower = tool_name.to_lowercase();
stderr.lines().any(|line| {
let lower = line.to_lowercase();
MISSING_SUBCOMMAND_PATTERNS.iter().any(|pattern| {
lower
.find(pattern)
.is_some_and(|start| lower[start + pattern.len()..].contains(&tool_name_lower))
})
})
}
const UNKNOWN_FLAG_PATTERNS: [&str; 3] = [
"unexpected argument",
"found argument",
"unrecognized argument",
];
pub fn is_unknown_flag(stderr: &str, flag_name: &str) -> bool {
let flag_name_lower = flag_name.to_lowercase();
stderr.lines().any(|line| {
let lower = line.to_lowercase();
UNKNOWN_FLAG_PATTERNS.iter().any(|pattern| {
lower
.find(pattern)
.is_some_and(|start| lower[start + pattern.len()..].contains(&flag_name_lower))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn exit_status(code: i32) -> std::process::ExitStatus {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code << 8)
}
#[cfg(windows)]
fn exit_status(code: i32) -> std::process::ExitStatus {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(code as u32)
}
fn output_with(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
std::process::Output {
status: exit_status(code),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
fn test_is_missing_subcommand() {
assert!(is_missing_subcommand(
"error: no such subcommand: `geiger`",
"geiger"
));
assert!(is_missing_subcommand(
"error: Unknown subcommand 'machete'",
"machete"
));
assert!(is_missing_subcommand(
"error: NO SUCH SUBCOMMAND: `GEIGER`",
"geiger"
));
assert!(!is_missing_subcommand("some other error", "geiger"));
assert!(!is_missing_subcommand(
"no such subcommand: `other`",
"geiger"
));
assert!(!is_missing_subcommand("geiger is not installed", "geiger"));
assert!(!is_missing_subcommand("", "geiger"));
}
#[test]
fn test_is_missing_subcommand_current_cargo_wording() {
let machete = "error: no such command: `machete`\n\n\
help: view all installed commands with `cargo --list`\n\
help: find a package to install `machete` with `cargo search cargo-machete`\n";
assert!(is_missing_subcommand(machete, "machete"));
let geiger = "error: no such command: `geiger`\n\n\
help: view all installed commands with `cargo --list`\n\
help: find a package to install `geiger` with `cargo search cargo-geiger`\n";
assert!(is_missing_subcommand(geiger, "geiger"));
assert!(!is_missing_subcommand(machete, "geiger"));
assert!(!is_missing_subcommand(
"error: unknown command `machete`",
"machete"
));
}
#[test]
fn handle_tool_output_current_cargo_wording_is_missing_tool() {
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let output = output_with(
101,
"",
"error: no such command: `machete`\n\n\
help: view all installed commands with `cargo --list`\n",
);
let err = handle_tool_output(output, &config, |stderr| {
is_missing_subcommand(stderr, "machete")
})
.unwrap_err();
assert_eq!(err.code(), ErrorCode::MissingTool);
assert!(err.to_string().contains("cargo-machete is not installed"));
}
#[test]
fn is_missing_subcommand_requires_pattern_and_tool_on_one_line() {
let nested = "error: no such command: `metadata`\n\n\
help: view all installed commands with `cargo --list`\n\
error: could not run cargo-geiger\n";
assert!(!is_missing_subcommand(nested, "geiger"));
let genuine = "warning: unrelated noise\n\
error: no such command: `geiger`\n\n\
help: view all installed commands with `cargo --list`\n";
assert!(is_missing_subcommand(genuine, "geiger"));
}
#[test]
fn is_missing_subcommand_requires_tool_name_after_the_pattern() {
assert!(!is_missing_subcommand(
"error: cargo-geiger failed: error: no such command: `metadata`",
"geiger"
));
assert!(is_missing_subcommand(
"error: no such command: `geiger`",
"geiger"
));
}
#[test]
fn test_is_unknown_flag() {
assert!(is_unknown_flag(
"error: unexpected argument '--output-format'",
"--output-format"
));
assert!(is_unknown_flag(
"error: Found argument '--json' which wasn't expected",
"--json"
));
assert!(is_unknown_flag(
"error: UNEXPECTED ARGUMENT '--JSON'",
"--json"
));
assert!(!is_unknown_flag("some other error", "--json"));
assert!(!is_unknown_flag("unexpected argument '--other'", "--json"));
assert!(!is_unknown_flag("--json is not recognized", "--json"));
assert!(!is_unknown_flag("", "--json"));
}
#[test]
fn is_unknown_flag_pins_clap_error_wording() {
let clap2_3 = "error: Found argument '--output-format' which wasn't expected, \
or isn't valid in this context\n\n\
USAGE:\n \
cargo-machete [OPTIONS]\n\n\
For more information try --help\n";
assert!(is_unknown_flag(clap2_3, "--output-format"));
let clap4 = "error: unexpected argument '--output-format' found\n\n\
Usage: cargo-machete [OPTIONS]\n\n\
For more information, try '--help'.\n";
assert!(is_unknown_flag(clap4, "--output-format"));
let other = "error: unexpected argument '--json' found\n\n\
Usage: cargo-machete [OPTIONS]\n";
assert!(is_unknown_flag(other, "--json"));
assert!(!is_unknown_flag(other, "--output-format"));
}
#[test]
fn is_unknown_flag_pins_argh_error_wording() {
let argh = "Unrecognized argument: --json\n\n\
Run --help for more information.\n";
assert!(is_unknown_flag(argh, "--json"));
assert!(!is_unknown_flag(argh, "--with-metadata"));
assert!(!is_unknown_flag(
"error: --json requires metadata: Unrecognized argument: --other",
"--json"
));
}
#[test]
fn is_unknown_flag_requires_pattern_and_flag_on_one_line() {
let nested = "error: unexpected argument '--bogus-flag' found\n\n\
Usage: cargo metadata [OPTIONS]\n\
error: cargo-geiger --output-format failed\n";
assert!(!is_unknown_flag(nested, "--output-format"));
let genuine = "warning: unrelated noise\n\
error: unexpected argument '--output-format' found\n\n\
Usage: cargo-geiger [OPTIONS]\n";
assert!(is_unknown_flag(genuine, "--output-format"));
}
#[test]
fn is_unknown_flag_ignores_flags_echoed_in_a_usage_block() {
let stderr = "error: unexpected argument '--bogus-flag' found\n\n\
Usage: cargo-geiger [OPTIONS]\n\n\
Options:\n \
--output-format <FMT> Output format\n";
assert!(!is_unknown_flag(stderr, "--output-format"));
assert!(is_unknown_flag(stderr, "--bogus-flag"));
}
#[test]
fn is_unknown_flag_requires_flag_name_after_the_pattern() {
assert!(!is_unknown_flag(
"error: --output-format requires metadata: unexpected argument '--bogus-flag' found",
"--output-format"
));
assert!(is_unknown_flag(
"error: unexpected argument '--output-format' found",
"--output-format"
));
}
#[test]
fn test_is_missing_subcommand_with_multiline_stderr() {
let stderr = r#"error: no such subcommand: `geiger`
Did you mean `generate`?
View all installed commands with `cargo --list`"#;
assert!(is_missing_subcommand(stderr, "geiger"));
}
#[test]
fn test_is_unknown_flag_with_multiline_stderr() {
let stderr = r#"error: Found argument '--output-format' which wasn't expected, or isn't valid in this context
USAGE:
cargo machete [OPTIONS]
For more information try --help"#;
assert!(is_unknown_flag(stderr, "--output-format"));
}
#[test]
fn handle_tool_output_success_returns_ok() {
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let output = output_with(0, "{\"unused\":[]}", "");
let result = handle_tool_output(output, &config, |_| false).unwrap();
assert!(!result.stdout.is_empty());
}
#[test]
fn handle_tool_output_missing_tool_returns_missing_tool_error() {
let config = ExternalToolConfig {
tool_name: "geiger",
install_hint: "cargo install cargo-geiger",
};
let output = output_with(1, "", "error: no such subcommand: `geiger`");
let err = handle_tool_output(output, &config, |stderr| {
is_missing_subcommand(stderr, "geiger")
})
.unwrap_err();
assert!(matches!(err, UpkeepError::Message { .. }));
assert_eq!(err.code(), ErrorCode::MissingTool);
assert!(err.to_string().contains("cargo-geiger is not installed"));
}
#[test]
fn handle_tool_output_nonzero_with_stdout_is_ok() {
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let output = output_with(1, "{\"unused\":[]}", "warning");
let result = handle_tool_output(output, &config, |_| false).unwrap();
assert!(!result.stdout.is_empty());
}
#[test]
fn handle_tool_output_empty_stderr_returns_error() {
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let output = output_with(1, "", "");
let err = handle_tool_output(output, &config, |_| false).unwrap_err();
assert_eq!(err.code(), ErrorCode::ExternalCommand);
assert!(err
.to_string()
.contains("cargo machete failed with no stderr output"));
}
#[tokio::test]
async fn run_cargo_tool_missing_workspace_dir_is_not_a_missing_tool() {
use std::error::Error as _;
let temp = tempfile::tempdir().expect("temp dir");
let missing = temp.path().join("no-such-workspace");
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let err = run_cargo_tool(&["--version"], &missing, &config)
.await
.expect_err("spawning in a missing directory must fail");
assert_ne!(err.code(), ErrorCode::MissingTool);
assert!(
err.source().is_some(),
"the io::Error must be preserved as the source"
);
#[cfg(unix)]
{
assert_eq!(err.code(), ErrorCode::Io);
assert!(
err.to_string().contains(&missing.display().to_string()),
"message must name the directory, got: {err}"
);
}
}
#[test]
fn handle_tool_output_stderr_message_bubbles_up() {
let config = ExternalToolConfig {
tool_name: "machete",
install_hint: "cargo install cargo-machete",
};
let output = output_with(1, "", "boom");
let err = handle_tool_output(output, &config, |_| false).unwrap_err();
assert_eq!(err.code(), ErrorCode::ExternalCommand);
assert!(err.to_string().contains("cargo machete failed: boom"));
}
}