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
48const SHELL_METACHARS: &[char] = &[
51 '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '~', '!', '\n',
52];
53
54pub fn has_shell_composition(raw: &str) -> bool {
56 raw.chars().any(|c| SHELL_METACHARS.contains(&c))
57}
58
59pub fn canonicalize(raw: &str, cwd: &str) -> CommandInvocation {
64 if has_shell_composition(raw) || raw.contains('\'') || raw.contains('"') {
65 return CommandInvocation::Shell {
66 script: raw.to_string(),
67 cwd: cwd.to_string(),
68 declared_reads: Vec::new(),
69 declared_writes: Vec::new(),
70 };
71 }
72 let mut tokens = raw.split_whitespace();
73 match tokens.next() {
74 Some(program) => CommandInvocation::Program {
75 program: program.to_string(),
76 args: tokens.map(|s| s.to_string()).collect(),
77 cwd: cwd.to_string(),
78 env: BTreeMap::new(),
79 },
80 None => CommandInvocation::Shell {
81 script: String::new(),
82 cwd: cwd.to_string(),
83 declared_reads: Vec::new(),
84 declared_writes: Vec::new(),
85 },
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum CommandTier {
93 Inspection,
95 PatchPreview,
97 Mutation,
99}
100
101pub fn classify_tier(invocation: &CommandInvocation) -> CommandTier {
105 let (program, args) = match invocation {
106 CommandInvocation::Program { program, args, .. } => (program.as_str(), args.as_slice()),
107 CommandInvocation::Shell {
110 declared_writes, ..
111 } => {
112 return if declared_writes.is_empty() {
113 CommandTier::Inspection
114 } else {
115 CommandTier::Mutation
116 };
117 }
118 };
119
120 let base = program.rsplit('/').next().unwrap_or(program);
121 match base {
122 "rg" | "grep" | "find" | "sort" | "uniq" | "wc" | "comm" | "cat" | "head" | "tail"
124 | "ls" | "git-grep" => CommandTier::Inspection,
125 "git" => match args.first().map(String::as_str) {
127 Some("grep") | Some("diff") | Some("status") | Some("log") | Some("show") => {
128 CommandTier::Inspection
129 }
130 _ => CommandTier::Mutation,
131 },
132 "sed" => {
134 if args.iter().any(|a| a == "-i" || a.starts_with("-i")) {
135 CommandTier::Mutation
136 } else if args.iter().any(|a| a == "-n") {
137 CommandTier::Inspection
138 } else {
139 CommandTier::PatchPreview
140 }
141 }
142 "awk" => CommandTier::Inspection,
143 "rm" | "mv" | "cp" | "cargo" | "npm" | "pnpm" | "yarn" | "pip" | "uv" | "go" => {
145 CommandTier::Mutation
146 }
147 _ => CommandTier::Mutation,
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn simple_command_is_program_form() {
157 let cmd = canonicalize("cargo check --workspace", "/repo");
158 assert!(matches!(cmd, CommandInvocation::Program { .. }));
159 assert!(!cmd.requires_shell());
160 assert_eq!(cmd.program_name(), "cargo");
161 }
162
163 #[test]
164 fn piped_command_is_shell_form() {
165 let cmd = canonicalize("cat x | grep y", "/repo");
166 assert!(cmd.requires_shell());
167 }
168
169 #[test]
170 fn redirect_forces_shell_form() {
171 assert!(canonicalize("echo hi > f", "/repo").requires_shell());
172 assert!(canonicalize("rm -rf $HOME", "/repo").requires_shell());
173 }
174
175 #[test]
176 fn read_only_tools_are_inspection() {
177 assert_eq!(
178 classify_tier(&canonicalize("rg pattern", "/r")),
179 CommandTier::Inspection
180 );
181 assert_eq!(
182 classify_tier(&canonicalize("git grep foo", "/r")),
183 CommandTier::Inspection
184 );
185 assert_eq!(
186 classify_tier(&canonicalize("sed -n 1p file", "/r")),
187 CommandTier::Inspection
188 );
189 }
190
191 #[test]
192 fn sed_in_place_is_mutation() {
193 assert_eq!(
194 classify_tier(&canonicalize("sed -i s/a/b/ file", "/r")),
195 CommandTier::Mutation
196 );
197 }
198
199 #[test]
200 fn package_managers_are_mutation() {
201 assert_eq!(
202 classify_tier(&canonicalize("cargo add serde", "/r")),
203 CommandTier::Mutation
204 );
205 assert_eq!(
206 classify_tier(&canonicalize("rm file", "/r")),
207 CommandTier::Mutation
208 );
209 }
210
211 #[test]
212 fn unknown_tool_defaults_to_mutation() {
213 assert_eq!(
214 classify_tier(&canonicalize("frobnicate x", "/r")),
215 CommandTier::Mutation
216 );
217 }
218}