1pub const REWRITE_COMMANDS: &[RewriteEntry] = &[
4 re("git", Category::Vcs),
6 re("gh", Category::Vcs),
7 re("cargo", Category::Build),
9 re("npm", Category::PackageManager),
11 re("pnpm", Category::PackageManager),
12 re("yarn", Category::PackageManager),
13 re("bun", Category::Build),
14 re("bunx", Category::Build),
15 re("deno", Category::Build),
16 re("vite", Category::Build),
17 re("pip", Category::PackageManager),
19 re("pip3", Category::PackageManager),
20 re("pytest", Category::Build),
21 re("mypy", Category::Lint),
22 re("ruff", Category::Lint),
23 re("go", Category::Build),
25 re("golangci-lint", Category::Lint),
26 re("docker", Category::Infra),
28 re("docker-compose", Category::Infra),
29 re("kubectl", Category::Infra),
30 re("helm", Category::Infra),
31 re("aws", Category::Infra),
32 re("terraform", Category::Infra),
33 re("tofu", Category::Infra),
34 re("eslint", Category::Lint),
36 re("prettier", Category::Lint),
37 re("tsc", Category::Lint),
38 re("biome", Category::Lint),
39 re("curl", Category::Http),
41 re("wget", Category::Http),
42 re("php", Category::Build),
44 re("composer", Category::PackageManager),
45 re("dotnet", Category::Build),
47 re("bundle", Category::PackageManager),
49 re("rake", Category::Build),
50 re("mix", Category::Build),
52 re("swift", Category::Build),
54 re("zig", Category::Build),
55 re("cmake", Category::Build),
56 re("make", Category::Build),
57 re("grep", Category::Search),
59 re("egrep", Category::Search),
60 re("fgrep", Category::Search),
61 re("rg", Category::Search),
62 re("cat", Category::FileRead),
64 re("head", Category::FileRead),
65 re("tail", Category::FileRead),
66 re("ls", Category::DirList),
68 re("find", Category::DirList),
69];
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum Category {
73 Vcs,
74 Build,
75 PackageManager,
76 Lint,
77 Infra,
78 Http,
79 Search,
80 FileRead,
81 DirList,
82}
83
84#[derive(Debug, Clone, Copy)]
85pub struct RewriteEntry {
86 pub command: &'static str,
87 pub category: Category,
88}
89
90const fn re(command: &'static str, category: Category) -> RewriteEntry {
91 RewriteEntry { command, category }
92}
93
94pub fn hook_prefixes() -> Vec<String> {
97 REWRITE_COMMANDS
98 .iter()
99 .filter(|e| !matches!(e.category, Category::FileRead))
100 .map(|e| format!("{} ", e.command))
101 .collect()
102}
103
104pub fn hook_bare_commands() -> Vec<&'static str> {
107 REWRITE_COMMANDS
108 .iter()
109 .filter(|e| !matches!(e.category, Category::FileRead))
110 .map(|e| e.command)
111 .collect()
112}
113
114pub fn is_file_read_command(cmd: &str) -> bool {
117 REWRITE_COMMANDS
118 .iter()
119 .filter(|e| e.category == Category::FileRead)
120 .any(|e| {
121 let prefix = format!("{} ", e.command);
122 cmd.starts_with(&prefix) || cmd == e.command
123 })
124}
125
126pub fn shell_alias_commands() -> Vec<&'static str> {
128 REWRITE_COMMANDS.iter().map(|e| e.command).collect()
129}
130
131pub fn bash_case_pattern() -> String {
134 REWRITE_COMMANDS
135 .iter()
136 .filter(|e| !matches!(e.category, Category::FileRead))
137 .map(|e| {
138 if e.command.contains('-') {
139 format!("{}*", e.command.replace('-', r"\-"))
140 } else {
141 format!(r"{}\ *", e.command)
142 }
143 })
144 .collect::<Vec<_>>()
145 .join("|")
146}
147
148pub fn shell_alias_list() -> String {
150 shell_alias_commands().join(" ")
151}
152
153pub fn is_rewritable_command(cmd: &str) -> bool {
156 for entry in REWRITE_COMMANDS {
157 if matches!(entry.category, Category::FileRead) {
158 continue;
159 }
160 let prefix = format!("{} ", entry.command);
161 if cmd.starts_with(&prefix) || cmd == entry.command {
162 return true;
163 }
164 }
165 false
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn no_duplicates() {
174 let mut seen = std::collections::HashSet::new();
175 for entry in REWRITE_COMMANDS {
176 assert!(
177 seen.insert(entry.command),
178 "duplicate command: {}",
179 entry.command
180 );
181 }
182 }
183
184 #[test]
185 fn hook_prefixes_exclude_search_fileread_dirlist() {
186 let prefixes = hook_prefixes();
187 assert!(!prefixes.contains(&"cat ".to_string()));
188 assert!(!prefixes.contains(&"head ".to_string()));
189 assert!(!prefixes.contains(&"tail ".to_string()));
190 assert!(prefixes.contains(&"rg ".to_string()));
191 assert!(prefixes.contains(&"grep ".to_string()));
192 assert!(prefixes.contains(&"egrep ".to_string()));
193 assert!(prefixes.contains(&"fgrep ".to_string()));
194 assert!(prefixes.contains(&"ls ".to_string()));
195 assert!(prefixes.contains(&"find ".to_string()));
196 assert!(prefixes.contains(&"git ".to_string()));
197 assert!(prefixes.contains(&"cargo ".to_string()));
198 }
199
200 #[test]
201 fn is_rewritable_matches() {
202 assert!(is_rewritable_command("git status"));
203 assert!(is_rewritable_command("cargo test --lib"));
204 assert!(is_rewritable_command("npm run build"));
205 assert!(is_rewritable_command("eslint"));
206 assert!(is_rewritable_command("docker-compose up"));
207 assert!(is_rewritable_command("bun install"));
208 assert!(is_rewritable_command("bunx vitest"));
209 assert!(is_rewritable_command("deno test"));
210 assert!(is_rewritable_command("vite build"));
211 assert!(is_rewritable_command("terraform plan"));
212 assert!(is_rewritable_command("make build"));
213 assert!(is_rewritable_command("dotnet build"));
214 }
215
216 #[test]
217 fn is_rewritable_excludes() {
218 assert!(!is_rewritable_command("echo hello"));
219 assert!(!is_rewritable_command("cd src"));
220 assert!(!is_rewritable_command("cat file.rs"));
221 assert!(!is_rewritable_command("head -20 file.rs"));
222 assert!(is_rewritable_command("rg pattern"));
223 assert!(is_rewritable_command("grep -rn pattern src/"));
224 assert!(is_rewritable_command("egrep 'foo|bar' file.rs"));
225 assert!(is_rewritable_command("fgrep literal file.rs"));
226 assert!(is_rewritable_command("ls /tmp"));
227 assert!(is_rewritable_command("find . -name '*.rs'"));
228 }
229
230 #[test]
231 fn file_read_commands_detected() {
232 assert!(is_file_read_command("cat file.rs"));
233 assert!(is_file_read_command("head -20 file.rs"));
234 assert!(is_file_read_command("tail -n 10 file.rs"));
235 assert!(!is_file_read_command("git status"));
236 assert!(!is_file_read_command("echo hello"));
237 }
238
239 #[test]
240 fn shell_alias_list_includes_all() {
241 let list = shell_alias_list();
242 assert!(list.contains("git"));
243 assert!(list.contains("cargo"));
244 assert!(list.contains("docker-compose"));
245 assert!(list.contains("rg"));
246 assert!(list.contains(" ls ") || list.ends_with(" ls"));
247 assert!(list.contains("find"));
248 }
249
250 #[test]
251 fn bash_case_pattern_valid() {
252 let pattern = bash_case_pattern();
253 assert!(pattern.contains(r"git\ *"));
254 assert!(pattern.contains(r"cargo\ *"));
255 assert!(pattern.contains(r"rg\ *"));
256 assert!(pattern.contains(r"ls\ *"));
257 }
258
259 #[test]
260 fn hook_prefixes_superset_of_bare_commands() {
261 let prefixes = hook_prefixes();
262 let bare = hook_bare_commands();
263 for cmd in &bare {
264 let with_space = format!("{cmd} ");
265 assert!(
266 prefixes.contains(&with_space),
267 "bare command '{cmd}' missing from hook_prefixes"
268 );
269 }
270 assert!(
271 !bare.contains(&"cat"),
272 "FileRead commands must not be in hook_bare_commands"
273 );
274 }
275
276 #[test]
277 fn shell_aliases_superset_of_hook_commands() {
278 let aliases = shell_alias_commands();
279 let hook = hook_bare_commands();
280 for cmd in &hook {
281 assert!(
282 aliases.contains(cmd),
283 "hook command '{cmd}' missing from shell_alias_commands"
284 );
285 }
286 }
287
288 #[test]
289 fn all_categories_represented() {
290 let categories: std::collections::HashSet<_> =
291 REWRITE_COMMANDS.iter().map(|e| e.category).collect();
292 assert!(categories.contains(&Category::Vcs));
293 assert!(categories.contains(&Category::Build));
294 assert!(categories.contains(&Category::PackageManager));
295 assert!(categories.contains(&Category::Lint));
296 assert!(categories.contains(&Category::Infra));
297 assert!(categories.contains(&Category::Http));
298 assert!(categories.contains(&Category::Search));
299 assert!(categories.contains(&Category::DirList));
300 }
301
302 #[test]
303 fn every_command_rewritable_except_fileread() {
304 for entry in REWRITE_COMMANDS {
305 let cmd = format!("{} --version", entry.command);
306 if matches!(entry.category, Category::FileRead) {
307 assert!(
308 !is_rewritable_command(&cmd),
309 "{:?} command '{}' should NOT be rewritable via -c wrap",
310 entry.category,
311 entry.command
312 );
313 } else {
314 assert!(
315 is_rewritable_command(&cmd),
316 "command '{}' should be rewritable",
317 entry.command
318 );
319 }
320 }
321 }
322
323 #[test]
324 fn bash_pattern_has_entry_for_every_hookable_command() {
325 let pattern = bash_case_pattern();
326 for entry in REWRITE_COMMANDS {
327 if matches!(entry.category, Category::FileRead) {
328 continue;
329 }
330 let escaped = if entry.command.contains('-') {
331 format!("{}*", entry.command.replace('-', r"\-"))
332 } else {
333 format!(r"{}\ *", entry.command)
334 };
335 assert!(
336 pattern.contains(&escaped),
337 "bash case pattern missing '{}'",
338 entry.command
339 );
340 }
341 }
342}