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("rg", Category::Search),
59 re("cat", Category::FileRead),
61 re("head", Category::FileRead),
62 re("tail", Category::FileRead),
63 re("ls", Category::DirList),
65 re("find", Category::DirList),
66];
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Category {
70 Vcs,
71 Build,
72 PackageManager,
73 Lint,
74 Infra,
75 Http,
76 Search,
77 FileRead,
78 DirList,
79}
80
81#[derive(Debug, Clone, Copy)]
82pub struct RewriteEntry {
83 pub command: &'static str,
84 pub category: Category,
85}
86
87const fn re(command: &'static str, category: Category) -> RewriteEntry {
88 RewriteEntry { command, category }
89}
90
91pub fn hook_prefixes() -> Vec<String> {
94 REWRITE_COMMANDS
95 .iter()
96 .filter(|e| {
97 !matches!(
98 e.category,
99 Category::Search | Category::FileRead | Category::DirList
100 )
101 })
102 .map(|e| format!("{} ", e.command))
103 .collect()
104}
105
106pub fn hook_bare_commands() -> Vec<&'static str> {
109 REWRITE_COMMANDS
110 .iter()
111 .filter(|e| {
112 !matches!(
113 e.category,
114 Category::Search | Category::FileRead | Category::DirList
115 )
116 })
117 .map(|e| e.command)
118 .collect()
119}
120
121pub fn is_file_read_command(cmd: &str) -> bool {
124 REWRITE_COMMANDS
125 .iter()
126 .filter(|e| e.category == Category::FileRead)
127 .any(|e| {
128 let prefix = format!("{} ", e.command);
129 cmd.starts_with(&prefix) || cmd == e.command
130 })
131}
132
133pub fn shell_alias_commands() -> Vec<&'static str> {
135 REWRITE_COMMANDS.iter().map(|e| e.command).collect()
136}
137
138pub fn bash_case_pattern() -> String {
141 REWRITE_COMMANDS
142 .iter()
143 .filter(|e| {
144 !matches!(
145 e.category,
146 Category::Search | Category::FileRead | Category::DirList
147 )
148 })
149 .map(|e| {
150 if e.command.contains('-') {
151 format!("{}*", e.command.replace('-', r"\-"))
152 } else {
153 format!(r"{}\ *", e.command)
154 }
155 })
156 .collect::<Vec<_>>()
157 .join("|")
158}
159
160pub fn shell_alias_list() -> String {
162 shell_alias_commands().join(" ")
163}
164
165pub fn is_rewritable_command(cmd: &str) -> bool {
168 for entry in REWRITE_COMMANDS {
169 if matches!(
170 entry.category,
171 Category::Search | Category::FileRead | Category::DirList
172 ) {
173 continue;
174 }
175 let prefix = format!("{} ", entry.command);
176 if cmd.starts_with(&prefix) || cmd == entry.command {
177 return true;
178 }
179 }
180 false
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn no_duplicates() {
189 let mut seen = std::collections::HashSet::new();
190 for entry in REWRITE_COMMANDS {
191 assert!(
192 seen.insert(entry.command),
193 "duplicate command: {}",
194 entry.command
195 );
196 }
197 }
198
199 #[test]
200 fn hook_prefixes_exclude_search_fileread_dirlist() {
201 let prefixes = hook_prefixes();
202 assert!(!prefixes.contains(&"rg ".to_string()));
203 assert!(!prefixes.contains(&"cat ".to_string()));
204 assert!(!prefixes.contains(&"head ".to_string()));
205 assert!(!prefixes.contains(&"tail ".to_string()));
206 assert!(!prefixes.contains(&"ls ".to_string()));
207 assert!(!prefixes.contains(&"find ".to_string()));
208 assert!(prefixes.contains(&"git ".to_string()));
209 assert!(prefixes.contains(&"cargo ".to_string()));
210 }
211
212 #[test]
213 fn is_rewritable_matches() {
214 assert!(is_rewritable_command("git status"));
215 assert!(is_rewritable_command("cargo test --lib"));
216 assert!(is_rewritable_command("npm run build"));
217 assert!(is_rewritable_command("eslint"));
218 assert!(is_rewritable_command("docker-compose up"));
219 assert!(is_rewritable_command("bun install"));
220 assert!(is_rewritable_command("bunx vitest"));
221 assert!(is_rewritable_command("deno test"));
222 assert!(is_rewritable_command("vite build"));
223 assert!(is_rewritable_command("terraform plan"));
224 assert!(is_rewritable_command("make build"));
225 assert!(is_rewritable_command("dotnet build"));
226 }
227
228 #[test]
229 fn is_rewritable_excludes() {
230 assert!(!is_rewritable_command("echo hello"));
231 assert!(!is_rewritable_command("cd src"));
232 assert!(!is_rewritable_command("rg pattern"));
233 assert!(!is_rewritable_command("cat file.rs"));
234 assert!(!is_rewritable_command("head -20 file.rs"));
235 assert!(!is_rewritable_command("ls /tmp"));
236 assert!(!is_rewritable_command("find . -name '*.rs'"));
237 }
238
239 #[test]
240 fn file_read_commands_detected() {
241 assert!(is_file_read_command("cat file.rs"));
242 assert!(is_file_read_command("head -20 file.rs"));
243 assert!(is_file_read_command("tail -n 10 file.rs"));
244 assert!(!is_file_read_command("git status"));
245 assert!(!is_file_read_command("echo hello"));
246 }
247
248 #[test]
249 fn shell_alias_list_includes_all() {
250 let list = shell_alias_list();
251 assert!(list.contains("git"));
252 assert!(list.contains("cargo"));
253 assert!(list.contains("docker-compose"));
254 assert!(list.contains("rg"));
255 assert!(list.contains(" ls ") || list.ends_with(" ls"));
256 assert!(list.contains("find"));
257 }
258
259 #[test]
260 fn bash_case_pattern_valid() {
261 let pattern = bash_case_pattern();
262 assert!(pattern.contains(r"git\ *"));
263 assert!(pattern.contains(r"cargo\ *"));
264 assert!(
265 !pattern.contains(r"rg\ *"),
266 "rg should not be in hook case pattern"
267 );
268 assert!(
269 !pattern.contains(r"ls\ *"),
270 "ls should not be in hook case pattern"
271 );
272 }
273
274 #[test]
275 fn hook_prefixes_superset_of_bare_commands() {
276 let prefixes = hook_prefixes();
277 let bare = hook_bare_commands();
278 for cmd in &bare {
279 let with_space = format!("{cmd} ");
280 assert!(
281 prefixes.contains(&with_space),
282 "bare command '{cmd}' missing from hook_prefixes"
283 );
284 }
285 assert!(
286 !bare.contains(&"cat"),
287 "FileRead commands must not be in hook_bare_commands"
288 );
289 assert!(
290 !bare.contains(&"ls"),
291 "DirList commands must not be in hook_bare_commands"
292 );
293 }
294
295 #[test]
296 fn shell_aliases_superset_of_hook_commands() {
297 let aliases = shell_alias_commands();
298 let hook = hook_bare_commands();
299 for cmd in &hook {
300 assert!(
301 aliases.contains(cmd),
302 "hook command '{cmd}' missing from shell_alias_commands"
303 );
304 }
305 }
306
307 #[test]
308 fn all_categories_represented() {
309 let categories: std::collections::HashSet<_> =
310 REWRITE_COMMANDS.iter().map(|e| e.category).collect();
311 assert!(categories.contains(&Category::Vcs));
312 assert!(categories.contains(&Category::Build));
313 assert!(categories.contains(&Category::PackageManager));
314 assert!(categories.contains(&Category::Lint));
315 assert!(categories.contains(&Category::Infra));
316 assert!(categories.contains(&Category::Http));
317 assert!(categories.contains(&Category::Search));
318 assert!(categories.contains(&Category::DirList));
319 }
320
321 #[test]
322 fn every_command_rewritable_except_search_fileread_dirlist() {
323 for entry in REWRITE_COMMANDS {
324 let cmd = format!("{} --version", entry.command);
325 if matches!(
326 entry.category,
327 Category::Search | Category::FileRead | Category::DirList
328 ) {
329 assert!(
330 !is_rewritable_command(&cmd),
331 "{:?} command '{}' should NOT be rewritable via -c wrap",
332 entry.category,
333 entry.command
334 );
335 } else {
336 assert!(
337 is_rewritable_command(&cmd),
338 "command '{}' should be rewritable",
339 entry.command
340 );
341 }
342 }
343 }
344
345 #[test]
346 fn bash_pattern_has_entry_for_every_hookable_command() {
347 let pattern = bash_case_pattern();
348 for entry in REWRITE_COMMANDS {
349 if matches!(
350 entry.category,
351 Category::Search | Category::FileRead | Category::DirList
352 ) {
353 continue;
354 }
355 let escaped = if entry.command.contains('-') {
356 format!("{}*", entry.command.replace('-', r"\-"))
357 } else {
358 format!(r"{}\ *", entry.command)
359 };
360 assert!(
361 pattern.contains(&escaped),
362 "bash case pattern missing '{}'",
363 entry.command
364 );
365 }
366 }
367}