1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(tag = "form", rename_all = "snake_case")]
16pub enum CommandInvocation {
17 Program {
19 program: String,
20 args: Vec<String>,
21 cwd: String,
22 env: BTreeMap<String, String>,
23 },
24 Shell {
26 script: String,
27 cwd: String,
28 declared_reads: Vec<String>,
29 declared_writes: Vec<String>,
30 },
31}
32
33impl CommandInvocation {
34 pub fn requires_shell(&self) -> bool {
36 matches!(self, CommandInvocation::Shell { .. })
37 }
38
39 pub fn program_name(&self) -> &str {
41 match self {
42 CommandInvocation::Program { program, .. } => program,
43 CommandInvocation::Shell { .. } => "sh",
44 }
45 }
46
47 pub fn command_line(&self) -> String {
53 match self {
54 CommandInvocation::Program { program, args, .. } => {
55 let mut parts = Vec::with_capacity(args.len() + 1);
56 parts.push(program.clone());
57 parts.extend(args.iter().cloned());
58 shell_words::join(parts.iter().map(String::as_str))
59 }
60 CommandInvocation::Shell { script, .. } => script.clone(),
61 }
62 }
63}
64
65const SHELL_METACHARS: &[char] = &[
68 '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '~', '!', '\n',
69];
70
71pub fn has_shell_composition(raw: &str) -> bool {
73 raw.chars().any(|c| SHELL_METACHARS.contains(&c))
74}
75
76pub fn canonicalize(raw: &str, cwd: &str) -> CommandInvocation {
83 if has_shell_composition(raw) {
84 return CommandInvocation::Shell {
85 script: raw.to_string(),
86 cwd: cwd.to_string(),
87 declared_reads: Vec::new(),
88 declared_writes: Vec::new(),
89 };
90 }
91 let mut tokens = match shell_words::split(raw) {
92 Ok(tokens) => tokens.into_iter(),
93 Err(_) => {
94 return CommandInvocation::Shell {
95 script: raw.to_string(),
96 cwd: cwd.to_string(),
97 declared_reads: Vec::new(),
98 declared_writes: Vec::new(),
99 };
100 }
101 };
102 match tokens.next() {
103 Some(program) => CommandInvocation::Program {
104 program,
105 args: tokens.collect(),
106 cwd: cwd.to_string(),
107 env: BTreeMap::new(),
108 },
109 None => CommandInvocation::Shell {
110 script: String::new(),
111 cwd: cwd.to_string(),
112 declared_reads: Vec::new(),
113 declared_writes: Vec::new(),
114 },
115 }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum CommandTier {
122 Inspection,
124 PatchPreview,
126 Mutation,
128}
129
130pub fn classify_tier(invocation: &CommandInvocation) -> CommandTier {
134 let (program, args) = match invocation {
135 CommandInvocation::Program { program, args, .. } => (program.as_str(), args.as_slice()),
136 CommandInvocation::Shell { .. } => return CommandTier::Mutation,
140 };
141
142 let base = program.rsplit('/').next().unwrap_or(program);
143 match base {
144 "rg" | "grep" | "find" | "sort" | "uniq" | "wc" | "comm" | "cat" | "head" | "tail"
146 | "ls" | "git-grep" => CommandTier::Inspection,
147 "git" => match args.first().map(String::as_str) {
149 Some("grep") | Some("diff") | Some("status") | Some("log") | Some("show") => {
150 CommandTier::Inspection
151 }
152 _ => CommandTier::Mutation,
153 },
154 "sed" => {
156 if args.iter().any(|a| a == "-i" || a.starts_with("-i")) {
157 CommandTier::Mutation
158 } else if args.iter().any(|a| a == "-n") {
159 CommandTier::Inspection
160 } else {
161 CommandTier::PatchPreview
162 }
163 }
164 "awk" => CommandTier::Inspection,
165 "rm" | "mv" | "cp" | "cargo" | "npm" | "pnpm" | "yarn" | "pip" | "uv" | "go" => {
167 CommandTier::Mutation
168 }
169 _ => CommandTier::Mutation,
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn simple_command_is_program_form() {
179 let cmd = canonicalize("cargo check --workspace", "/repo");
180 assert!(matches!(cmd, CommandInvocation::Program { .. }));
181 assert!(!cmd.requires_shell());
182 assert_eq!(cmd.program_name(), "cargo");
183 }
184
185 #[test]
186 fn piped_command_is_shell_form() {
187 let cmd = canonicalize("cat x | grep y", "/repo");
188 assert!(cmd.requires_shell());
189 }
190
191 #[test]
192 fn quotes_preserve_a_direct_program_invocation() {
193 let cmd = canonicalize(r#"rg "hello world" src"#, "/repo");
194 assert_eq!(
195 cmd,
196 CommandInvocation::Program {
197 program: "rg".into(),
198 args: vec!["hello world".into(), "src".into()],
199 cwd: "/repo".into(),
200 env: BTreeMap::new(),
201 }
202 );
203 }
204
205 #[test]
206 fn an_empty_shell_write_declaration_is_not_read_only_proof() {
207 let command = CommandInvocation::Shell {
208 script: "cat x | rg y".into(),
209 cwd: "/repo".into(),
210 declared_reads: vec!["x".into()],
211 declared_writes: Vec::new(),
212 };
213 assert_eq!(classify_tier(&command), CommandTier::Mutation);
214 }
215
216 #[test]
217 fn redirect_forces_shell_form() {
218 assert!(canonicalize("echo hi > f", "/repo").requires_shell());
219 assert!(canonicalize("rm -rf $HOME", "/repo").requires_shell());
220 }
221
222 #[test]
223 fn read_only_tools_are_inspection() {
224 assert_eq!(
225 classify_tier(&canonicalize("rg pattern", "/r")),
226 CommandTier::Inspection
227 );
228 assert_eq!(
229 classify_tier(&canonicalize("git grep foo", "/r")),
230 CommandTier::Inspection
231 );
232 assert_eq!(
233 classify_tier(&canonicalize("sed -n 1p file", "/r")),
234 CommandTier::Inspection
235 );
236 }
237
238 #[test]
239 fn sed_in_place_is_mutation() {
240 assert_eq!(
241 classify_tier(&canonicalize("sed -i s/a/b/ file", "/r")),
242 CommandTier::Mutation
243 );
244 }
245
246 #[test]
247 fn package_managers_are_mutation() {
248 assert_eq!(
249 classify_tier(&canonicalize("cargo add serde", "/r")),
250 CommandTier::Mutation
251 );
252 assert_eq!(
253 classify_tier(&canonicalize("rm file", "/r")),
254 CommandTier::Mutation
255 );
256 }
257
258 #[test]
259 fn unknown_tool_defaults_to_mutation() {
260 assert_eq!(
261 classify_tier(&canonicalize("frobnicate x", "/r")),
262 CommandTier::Mutation
263 );
264 }
265}