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("python", Category::Build),
19 re("python3", Category::Build),
20 re("pip", Category::PackageManager),
21 re("pip3", Category::PackageManager),
22 re("uv", Category::PackageManager),
23 re("pytest", Category::Build),
24 re("mypy", Category::Lint),
25 re("ruff", Category::Lint),
26 re("go", Category::Build),
28 re("golangci-lint", Category::Lint),
29 re("docker", Category::Infra),
31 re("docker-compose", Category::Infra),
32 re("kubectl", Category::Infra),
33 re("helm", Category::Infra),
34 re("aws", Category::Infra),
35 re("terraform", Category::Infra),
36 re("tofu", Category::Infra),
37 re("eslint", Category::Lint),
39 re("prettier", Category::Lint),
40 re("tsc", Category::Lint),
41 re("biome", Category::Lint),
42 re("curl", Category::Http),
44 re("wget", Category::Http),
45 re("php", Category::Build),
47 re("composer", Category::PackageManager),
48 re("dotnet", Category::Build),
50 re("bundle", Category::PackageManager),
52 re("rake", Category::Build),
53 re("mix", Category::Build),
55 re("swift", Category::Build),
57 re("zig", Category::Build),
58 re("cmake", Category::Build),
59 re("make", Category::Build),
60 re("grep", Category::Search),
62 re("egrep", Category::Search),
63 re("fgrep", Category::Search),
64 re("rg", Category::Search),
65 re("cat", Category::FileRead),
67 re("head", Category::FileRead),
68 re("tail", Category::FileRead),
69 re("ls", Category::DirList),
71 re("find", Category::DirList),
72];
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum Category {
76 Vcs,
77 Build,
78 PackageManager,
79 Lint,
80 Infra,
81 Http,
82 Search,
83 FileRead,
84 DirList,
85}
86
87#[derive(Debug, Clone, Copy)]
88pub struct RewriteEntry {
89 pub command: &'static str,
90 pub category: Category,
91}
92
93const fn re(command: &'static str, category: Category) -> RewriteEntry {
94 RewriteEntry { command, category }
95}
96
97pub fn hook_prefixes() -> Vec<String> {
100 REWRITE_COMMANDS
101 .iter()
102 .filter(|e| !matches!(e.category, Category::FileRead))
103 .map(|e| format!("{} ", e.command))
104 .collect()
105}
106
107pub fn hook_bare_commands() -> Vec<&'static str> {
110 REWRITE_COMMANDS
111 .iter()
112 .filter(|e| !matches!(e.category, Category::FileRead))
113 .map(|e| e.command)
114 .collect()
115}
116
117pub fn is_file_read_command(cmd: &str) -> bool {
120 REWRITE_COMMANDS
121 .iter()
122 .filter(|e| e.category == Category::FileRead)
123 .any(|e| {
124 let prefix = format!("{} ", e.command);
125 cmd.starts_with(&prefix) || cmd == e.command
126 })
127}
128
129pub fn shell_alias_commands() -> Vec<&'static str> {
131 REWRITE_COMMANDS.iter().map(|e| e.command).collect()
132}
133
134pub fn bash_case_pattern() -> String {
137 REWRITE_COMMANDS
138 .iter()
139 .filter(|e| !matches!(e.category, Category::FileRead))
140 .map(|e| {
141 if e.command.contains('-') {
142 format!("{}*", e.command.replace('-', r"\-"))
143 } else {
144 format!(r"{}\ *", e.command)
145 }
146 })
147 .collect::<Vec<_>>()
148 .join("|")
149}
150
151pub fn shell_alias_list() -> String {
153 shell_alias_commands().join(" ")
154}
155
156pub fn is_rewritable_command(cmd: &str) -> bool {
164 let effective = strip_env_prefix(cmd);
165 let basename = extract_command_basename(effective);
166
167 for entry in REWRITE_COMMANDS {
168 if matches!(entry.category, Category::FileRead) {
169 continue;
170 }
171 let prefix = format!("{} ", entry.command);
172 if effective.starts_with(&prefix)
173 || effective == entry.command
174 || basename == entry.command
175 || basename.starts_with(&format!("{} ", entry.command))
176 {
177 return true;
178 }
179 }
180 false
181}
182
183pub(crate) fn strip_env_prefix(cmd: &str) -> &str {
186 let mut rest = cmd;
187 loop {
188 let trimmed = rest.trim_start();
189 if let Some(eq_pos) = trimmed.find('=') {
190 let before_eq = &trimmed[..eq_pos];
191 if !before_eq.is_empty()
192 && before_eq
193 .bytes()
194 .all(|b| b.is_ascii_alphanumeric() || b == b'_')
195 && let Some(space_pos) = trimmed[eq_pos..].find(' ')
196 {
197 rest = &trimmed[eq_pos + space_pos..];
198 continue;
199 }
200 }
201 return trimmed;
202 }
203}
204
205fn extract_command_basename(cmd: &str) -> &str {
209 let first_space = cmd.find(' ').unwrap_or(cmd.len());
210 let cmd_part = &cmd[..first_space];
211 if let Some(slash_pos) = cmd_part.rfind('/') {
212 &cmd[slash_pos + 1..]
213 } else {
214 cmd
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn no_duplicates() {
224 let mut seen = std::collections::HashSet::new();
225 for entry in REWRITE_COMMANDS {
226 assert!(
227 seen.insert(entry.command),
228 "duplicate command: {}",
229 entry.command
230 );
231 }
232 }
233
234 #[test]
235 fn hook_prefixes_exclude_search_fileread_dirlist() {
236 let prefixes = hook_prefixes();
237 assert!(!prefixes.contains(&"cat ".to_string()));
238 assert!(!prefixes.contains(&"head ".to_string()));
239 assert!(!prefixes.contains(&"tail ".to_string()));
240 assert!(prefixes.contains(&"rg ".to_string()));
241 assert!(prefixes.contains(&"grep ".to_string()));
242 assert!(prefixes.contains(&"egrep ".to_string()));
243 assert!(prefixes.contains(&"fgrep ".to_string()));
244 assert!(prefixes.contains(&"ls ".to_string()));
245 assert!(prefixes.contains(&"find ".to_string()));
246 assert!(prefixes.contains(&"git ".to_string()));
247 assert!(prefixes.contains(&"cargo ".to_string()));
248 }
249
250 #[test]
251 fn is_rewritable_matches() {
252 assert!(is_rewritable_command("git status"));
253 assert!(is_rewritable_command("cargo test --lib"));
254 assert!(is_rewritable_command("npm run build"));
255 assert!(is_rewritable_command("eslint"));
256 assert!(is_rewritable_command("docker-compose up"));
257 assert!(is_rewritable_command("bun install"));
258 assert!(is_rewritable_command("bunx vitest"));
259 assert!(is_rewritable_command("deno test"));
260 assert!(is_rewritable_command("vite build"));
261 assert!(is_rewritable_command("terraform plan"));
262 assert!(is_rewritable_command("make build"));
263 assert!(is_rewritable_command("dotnet build"));
264 assert!(is_rewritable_command("python script.py"));
265 assert!(is_rewritable_command("python3 -m pytest"));
266 assert!(is_rewritable_command("uv run test"));
267 }
268
269 #[test]
270 fn is_rewritable_env_prefix() {
271 assert!(is_rewritable_command("PYTHONPATH=src python script.py"));
272 assert!(is_rewritable_command("FOO=bar BAZ=1 cargo test"));
273 assert!(is_rewritable_command("NODE_ENV=test npm run test"));
274 assert!(!is_rewritable_command("FOO=bar echo hello"));
275 }
276
277 #[test]
278 fn is_rewritable_path_qualified() {
279 assert!(is_rewritable_command("./.venv/bin/python script.py"));
280 assert!(is_rewritable_command("/usr/bin/python3 test.py"));
281 assert!(is_rewritable_command("/usr/local/bin/cargo build"));
282 assert!(is_rewritable_command(
283 "PYTHONPATH=src ./.venv/bin/python script.py"
284 ));
285 assert!(!is_rewritable_command("/usr/bin/some-unknown-tool arg"));
286 }
287
288 #[test]
289 fn is_rewritable_excludes() {
290 assert!(!is_rewritable_command("echo hello"));
291 assert!(!is_rewritable_command("cd src"));
292 assert!(!is_rewritable_command("cat file.rs"));
293 assert!(!is_rewritable_command("head -20 file.rs"));
294 assert!(is_rewritable_command("rg pattern"));
295 assert!(is_rewritable_command("grep -rn pattern src/"));
296 assert!(is_rewritable_command("egrep 'foo|bar' file.rs"));
297 assert!(is_rewritable_command("fgrep literal file.rs"));
298 assert!(is_rewritable_command("ls /tmp"));
299 assert!(is_rewritable_command("find . -name '*.rs'"));
300 }
301
302 #[test]
303 fn strip_env_prefix_works() {
304 assert_eq!(strip_env_prefix("python x.py"), "python x.py");
305 assert_eq!(strip_env_prefix("FOO=bar python x.py"), "python x.py");
306 assert_eq!(strip_env_prefix("A=1 B=2 cargo test"), "cargo test");
307 assert_eq!(strip_env_prefix(" FOO=bar cmd"), "cmd");
308 assert_eq!(strip_env_prefix("no_equals here"), "no_equals here");
309 }
310
311 #[test]
312 fn extract_command_basename_works() {
313 assert_eq!(extract_command_basename("python x.py"), "python x.py");
314 assert_eq!(
315 extract_command_basename("./.venv/bin/python x.py"),
316 "python x.py"
317 );
318 assert_eq!(
319 extract_command_basename("/usr/bin/python3 -m pytest"),
320 "python3 -m pytest"
321 );
322 assert_eq!(extract_command_basename("cargo test"), "cargo test");
323 }
324
325 #[test]
326 fn file_read_commands_detected() {
327 assert!(is_file_read_command("cat file.rs"));
328 assert!(is_file_read_command("head -20 file.rs"));
329 assert!(is_file_read_command("tail -n 10 file.rs"));
330 assert!(!is_file_read_command("git status"));
331 assert!(!is_file_read_command("echo hello"));
332 }
333
334 #[test]
335 fn shell_alias_list_includes_all() {
336 let list = shell_alias_list();
337 assert!(list.contains("git"));
338 assert!(list.contains("cargo"));
339 assert!(list.contains("docker-compose"));
340 assert!(list.contains("rg"));
341 assert!(list.contains(" ls ") || list.ends_with(" ls"));
342 assert!(list.contains("find"));
343 }
344
345 #[test]
346 fn bash_case_pattern_valid() {
347 let pattern = bash_case_pattern();
348 assert!(pattern.contains(r"git\ *"));
349 assert!(pattern.contains(r"cargo\ *"));
350 assert!(pattern.contains(r"rg\ *"));
351 assert!(pattern.contains(r"ls\ *"));
352 }
353
354 #[test]
355 fn hook_prefixes_superset_of_bare_commands() {
356 let prefixes = hook_prefixes();
357 let bare = hook_bare_commands();
358 for cmd in &bare {
359 let with_space = format!("{cmd} ");
360 assert!(
361 prefixes.contains(&with_space),
362 "bare command '{cmd}' missing from hook_prefixes"
363 );
364 }
365 assert!(
366 !bare.contains(&"cat"),
367 "FileRead commands must not be in hook_bare_commands"
368 );
369 }
370
371 #[test]
372 fn shell_aliases_superset_of_hook_commands() {
373 let aliases = shell_alias_commands();
374 let hook = hook_bare_commands();
375 for cmd in &hook {
376 assert!(
377 aliases.contains(cmd),
378 "hook command '{cmd}' missing from shell_alias_commands"
379 );
380 }
381 }
382
383 #[test]
384 fn all_categories_represented() {
385 let categories: std::collections::HashSet<_> =
386 REWRITE_COMMANDS.iter().map(|e| e.category).collect();
387 assert!(categories.contains(&Category::Vcs));
388 assert!(categories.contains(&Category::Build));
389 assert!(categories.contains(&Category::PackageManager));
390 assert!(categories.contains(&Category::Lint));
391 assert!(categories.contains(&Category::Infra));
392 assert!(categories.contains(&Category::Http));
393 assert!(categories.contains(&Category::Search));
394 assert!(categories.contains(&Category::DirList));
395 }
396
397 #[test]
398 fn every_command_rewritable_except_fileread() {
399 for entry in REWRITE_COMMANDS {
400 let cmd = format!("{} --version", entry.command);
401 if matches!(entry.category, Category::FileRead) {
402 assert!(
403 !is_rewritable_command(&cmd),
404 "{:?} command '{}' should NOT be rewritable via -c wrap",
405 entry.category,
406 entry.command
407 );
408 } else {
409 assert!(
410 is_rewritable_command(&cmd),
411 "command '{}' should be rewritable",
412 entry.command
413 );
414 }
415 }
416 }
417
418 #[test]
419 fn bash_pattern_has_entry_for_every_hookable_command() {
420 let pattern = bash_case_pattern();
421 for entry in REWRITE_COMMANDS {
422 if matches!(entry.category, Category::FileRead) {
423 continue;
424 }
425 let escaped = if entry.command.contains('-') {
426 format!("{}*", entry.command.replace('-', r"\-"))
427 } else {
428 format!(r"{}\ *", entry.command)
429 };
430 assert!(
431 pattern.contains(&escaped),
432 "bash case pattern missing '{}'",
433 entry.command
434 );
435 }
436 }
437}