use serde::{Deserialize, Serialize};
pub const QUOTED_MISUSE_REASON: &str = "exec_quoted_command_misuse";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecMisuseReport {
pub misuse: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<String>,
pub original_argv: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parsed_command: Vec<String>,
}
#[must_use]
pub fn detect_exec_misuse(argv: &[String], had_separator: bool) -> ExecMisuseReport {
let is_single_quoted =
!had_separator && argv.len() == 1 && argv[0].split_whitespace().count() > 1;
if is_single_quoted {
let raw = argv[0].trim();
let parsed_command = raw.split_whitespace().map(str::to_string).collect();
ExecMisuseReport {
misuse: true,
reason_code: Some(QUOTED_MISUSE_REASON.to_string()),
original_argv: argv.to_vec(),
suggestion: Some(format!("rch exec -- {raw}")),
parsed_command,
}
} else {
ExecMisuseReport {
misuse: false,
reason_code: None,
original_argv: argv.to_vec(),
suggestion: None,
parsed_command: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn cargo_quoted_command_is_misuse() {
let report = detect_exec_misuse(&argv(&["cargo check --all-targets"]), false);
assert!(report.misuse);
assert_eq!(report.reason_code.as_deref(), Some(QUOTED_MISUSE_REASON));
assert_eq!(
report.suggestion.as_deref(),
Some("rch exec -- cargo check --all-targets")
);
assert_eq!(
report.parsed_command,
argv(&["cargo", "check", "--all-targets"])
);
}
#[test]
fn bun_quoted_command_is_misuse() {
let report = detect_exec_misuse(&argv(&["bun test --coverage"]), false);
assert!(report.misuse);
assert_eq!(
report.suggestion.as_deref(),
Some("rch exec -- bun test --coverage")
);
}
#[test]
fn env_prefixed_quoted_command_is_misuse() {
let report = detect_exec_misuse(&argv(&["env CARGO_TARGET_DIR=/tmp/t cargo build"]), false);
assert!(report.misuse);
assert_eq!(
report.suggestion.as_deref(),
Some("rch exec -- env CARGO_TARGET_DIR=/tmp/t cargo build")
);
}
#[test]
fn shell_quoted_command_is_misuse() {
let report = detect_exec_misuse(&argv(&["bash -lc 'cargo build'"]), false);
assert!(report.misuse);
assert!(
report
.suggestion
.as_deref()
.unwrap()
.contains("bash -lc 'cargo build'")
);
}
#[test]
fn legitimate_single_executable_is_not_misuse() {
let report = detect_exec_misuse(&argv(&["mybinary"]), false);
assert!(!report.misuse);
assert!(report.suggestion.is_none());
assert!(report.reason_code.is_none());
}
#[test]
fn properly_separated_command_is_not_misuse() {
let report = detect_exec_misuse(&argv(&["cargo", "build"]), true);
assert!(!report.misuse);
assert!(!detect_exec_misuse(&argv(&["cargo build"]), true).misuse);
}
#[test]
fn multi_token_argv_without_separator_is_not_this_misuse() {
let report = detect_exec_misuse(&argv(&["cargo", "check", "--all-targets"]), false);
assert!(!report.misuse);
}
#[test]
fn empty_argv_is_not_misuse() {
let report = detect_exec_misuse(&[], false);
assert!(!report.misuse);
}
#[test]
fn report_serializes_with_reason_and_original_argv() {
let report = detect_exec_misuse(&argv(&["cargo check --all-targets"]), false);
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["misuse"], true);
assert_eq!(value["reason_code"], "exec_quoted_command_misuse");
assert_eq!(value["original_argv"][0], "cargo check --all-targets");
assert_eq!(value["suggestion"], "rch exec -- cargo check --all-targets");
let back: ExecMisuseReport = serde_json::from_value(value).unwrap();
assert_eq!(back, report);
}
#[test]
fn non_misuse_omits_reason_and_suggestion_on_the_wire() {
let report = detect_exec_misuse(&argv(&["mybinary"]), false);
let value = serde_json::to_value(&report).unwrap();
assert!(value.get("reason_code").is_none());
assert!(value.get("suggestion").is_none());
assert!(value.get("parsed_command").is_none());
}
}