rch_common/
exec_misuse.rs1use serde::{Deserialize, Serialize};
16
17pub const QUOTED_MISUSE_REASON: &str = "exec_quoted_command_misuse";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ExecMisuseReport {
24 pub misuse: bool,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub reason_code: Option<String>,
29 pub original_argv: Vec<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub suggestion: Option<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub parsed_command: Vec<String>,
37}
38
39#[must_use]
46pub fn detect_exec_misuse(argv: &[String], had_separator: bool) -> ExecMisuseReport {
47 let is_single_quoted =
48 !had_separator && argv.len() == 1 && argv[0].split_whitespace().count() > 1;
49
50 if is_single_quoted {
51 let raw = argv[0].trim();
52 let parsed_command = raw.split_whitespace().map(str::to_string).collect();
53 ExecMisuseReport {
54 misuse: true,
55 reason_code: Some(QUOTED_MISUSE_REASON.to_string()),
56 original_argv: argv.to_vec(),
57 suggestion: Some(format!("rch exec -- {raw}")),
58 parsed_command,
59 }
60 } else {
61 ExecMisuseReport {
62 misuse: false,
63 reason_code: None,
64 original_argv: argv.to_vec(),
65 suggestion: None,
66 parsed_command: Vec::new(),
67 }
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 fn argv(parts: &[&str]) -> Vec<String> {
76 parts.iter().map(|s| (*s).to_string()).collect()
77 }
78
79 #[test]
80 fn cargo_quoted_command_is_misuse() {
81 let report = detect_exec_misuse(&argv(&["cargo check --all-targets"]), false);
82 assert!(report.misuse);
83 assert_eq!(report.reason_code.as_deref(), Some(QUOTED_MISUSE_REASON));
84 assert_eq!(
85 report.suggestion.as_deref(),
86 Some("rch exec -- cargo check --all-targets")
87 );
88 assert_eq!(
89 report.parsed_command,
90 argv(&["cargo", "check", "--all-targets"])
91 );
92 }
93
94 #[test]
95 fn bun_quoted_command_is_misuse() {
96 let report = detect_exec_misuse(&argv(&["bun test --coverage"]), false);
97 assert!(report.misuse);
98 assert_eq!(
99 report.suggestion.as_deref(),
100 Some("rch exec -- bun test --coverage")
101 );
102 }
103
104 #[test]
105 fn env_prefixed_quoted_command_is_misuse() {
106 let report = detect_exec_misuse(&argv(&["env CARGO_TARGET_DIR=/tmp/t cargo build"]), false);
107 assert!(report.misuse);
108 assert_eq!(
109 report.suggestion.as_deref(),
110 Some("rch exec -- env CARGO_TARGET_DIR=/tmp/t cargo build")
111 );
112 }
113
114 #[test]
115 fn shell_quoted_command_is_misuse() {
116 let report = detect_exec_misuse(&argv(&["bash -lc 'cargo build'"]), false);
117 assert!(report.misuse);
118 assert!(
119 report
120 .suggestion
121 .as_deref()
122 .unwrap()
123 .contains("bash -lc 'cargo build'")
124 );
125 }
126
127 #[test]
128 fn legitimate_single_executable_is_not_misuse() {
129 let report = detect_exec_misuse(&argv(&["mybinary"]), false);
131 assert!(!report.misuse);
132 assert!(report.suggestion.is_none());
133 assert!(report.reason_code.is_none());
134 }
135
136 #[test]
137 fn properly_separated_command_is_not_misuse() {
138 let report = detect_exec_misuse(&argv(&["cargo", "build"]), true);
140 assert!(!report.misuse);
141 assert!(!detect_exec_misuse(&argv(&["cargo build"]), true).misuse);
143 }
144
145 #[test]
146 fn multi_token_argv_without_separator_is_not_this_misuse() {
147 let report = detect_exec_misuse(&argv(&["cargo", "check", "--all-targets"]), false);
150 assert!(!report.misuse);
151 }
152
153 #[test]
154 fn empty_argv_is_not_misuse() {
155 let report = detect_exec_misuse(&[], false);
156 assert!(!report.misuse);
157 }
158
159 #[test]
160 fn report_serializes_with_reason_and_original_argv() {
161 let report = detect_exec_misuse(&argv(&["cargo check --all-targets"]), false);
162 let value = serde_json::to_value(&report).unwrap();
163 assert_eq!(value["misuse"], true);
164 assert_eq!(value["reason_code"], "exec_quoted_command_misuse");
165 assert_eq!(value["original_argv"][0], "cargo check --all-targets");
166 assert_eq!(value["suggestion"], "rch exec -- cargo check --all-targets");
167 let back: ExecMisuseReport = serde_json::from_value(value).unwrap();
168 assert_eq!(back, report);
169 }
170
171 #[test]
172 fn non_misuse_omits_reason_and_suggestion_on_the_wire() {
173 let report = detect_exec_misuse(&argv(&["mybinary"]), false);
174 let value = serde_json::to_value(&report).unwrap();
175 assert!(value.get("reason_code").is_none());
176 assert!(value.get("suggestion").is_none());
177 assert!(value.get("parsed_command").is_none());
178 }
179}