Skip to main content

rch_common/
exec_misuse.rs

1//! Detect quoted single-command misuse in `rch exec`
2//! (bd-session-history-remediation-ocv9i.13.1).
3//!
4//! Agents repeatedly ran `rch exec "cargo check --all-targets"` — the whole
5//! command as one quoted string, with no `--` separator. Clap then treats
6//! `cargo check --all-targets` as a single program name and RCH tries to exec a
7//! bogus binary. This module detects that shape and, instead of running
8//! anything, returns a clear correction: `rch exec -- cargo check --all-targets`.
9//!
10//! [`detect_exec_misuse`] is pure: given the argv after `exec` and whether a
11//! `--` separator was present, it decides misuse and builds the suggestion. The
12//! [`ExecMisuseReport`] carries `reason_code = exec_quoted_command_misuse`, the
13//! parsed suggestion, and the original argv for a machine-readable error.
14
15use serde::{Deserialize, Serialize};
16
17/// The stable reason code string for this misuse (not an `RCH-Innn` incident
18/// code — it is a CLI-usage error, surfaced verbatim in the JSON error).
19pub const QUOTED_MISUSE_REASON: &str = "exec_quoted_command_misuse";
20
21/// The result of checking `rch exec` argv for quoted-command misuse.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ExecMisuseReport {
24    /// Whether the argv is the single-quoted-command misuse shape.
25    pub misuse: bool,
26    /// Stable reason code, present only on misuse.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub reason_code: Option<String>,
29    /// The original argv after `exec`, echoed for diagnostics.
30    pub original_argv: Vec<String>,
31    /// The suggested corrected command line, present only on misuse.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub suggestion: Option<String>,
34    /// Best-effort whitespace split of the quoted command (present on misuse).
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub parsed_command: Vec<String>,
37}
38
39/// Detect quoted single-command misuse. Pure.
40///
41/// Misuse is exactly: no `--` separator was given **and** the argv after `exec`
42/// is a single element that contains whitespace (i.e. a whole command line was
43/// passed as one quoted string). A single bare executable name, a properly
44/// `--`-separated command, or multiple argv tokens are all legitimate.
45#[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        // A bare program name with no whitespace is a legitimate exec target.
130        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        // `rch exec -- cargo build` arrives as multi-token argv WITH a separator.
139        let report = detect_exec_misuse(&argv(&["cargo", "build"]), true);
140        assert!(!report.misuse);
141        // Even a single token with a separator is fine.
142        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        // Multiple argv tokens are clap's trailing-var-arg form, not the single
148        // quoted-string mistake this detector targets.
149        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}