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| !matches!(e.category, Category::FileRead))
97 .map(|e| format!("{} ", e.command))
98 .collect()
99}
100
101pub fn hook_bare_commands() -> Vec<&'static str> {
104 REWRITE_COMMANDS
105 .iter()
106 .filter(|e| !matches!(e.category, Category::FileRead))
107 .map(|e| e.command)
108 .collect()
109}
110
111pub fn is_file_read_command(cmd: &str) -> bool {
114 REWRITE_COMMANDS
115 .iter()
116 .filter(|e| e.category == Category::FileRead)
117 .any(|e| {
118 let prefix = format!("{} ", e.command);
119 cmd.starts_with(&prefix) || cmd == e.command
120 })
121}
122
123pub fn shell_alias_commands() -> Vec<&'static str> {
125 REWRITE_COMMANDS.iter().map(|e| e.command).collect()
126}
127
128pub fn bash_case_pattern() -> String {
131 REWRITE_COMMANDS
132 .iter()
133 .filter(|e| !matches!(e.category, Category::FileRead))
134 .map(|e| {
135 if e.command.contains('-') {
136 format!("{}*", e.command.replace('-', r"\-"))
137 } else {
138 format!(r"{}\ *", e.command)
139 }
140 })
141 .collect::<Vec<_>>()
142 .join("|")
143}
144
145pub fn shell_alias_list() -> String {
147 shell_alias_commands().join(" ")
148}
149
150pub fn is_rewritable_command(cmd: &str) -> bool {
153 for entry in REWRITE_COMMANDS {
154 if matches!(entry.category, Category::FileRead) {
155 continue;
156 }
157 let prefix = format!("{} ", entry.command);
158 if cmd.starts_with(&prefix) || cmd == entry.command {
159 return true;
160 }
161 }
162 false
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn no_duplicates() {
171 let mut seen = std::collections::HashSet::new();
172 for entry in REWRITE_COMMANDS {
173 assert!(
174 seen.insert(entry.command),
175 "duplicate command: {}",
176 entry.command
177 );
178 }
179 }
180
181 #[test]
182 fn hook_prefixes_exclude_search_fileread_dirlist() {
183 let prefixes = hook_prefixes();
184 assert!(!prefixes.contains(&"cat ".to_string()));
185 assert!(!prefixes.contains(&"head ".to_string()));
186 assert!(!prefixes.contains(&"tail ".to_string()));
187 assert!(prefixes.contains(&"rg ".to_string()));
188 assert!(prefixes.contains(&"ls ".to_string()));
189 assert!(prefixes.contains(&"find ".to_string()));
190 assert!(prefixes.contains(&"git ".to_string()));
191 assert!(prefixes.contains(&"cargo ".to_string()));
192 }
193
194 #[test]
195 fn is_rewritable_matches() {
196 assert!(is_rewritable_command("git status"));
197 assert!(is_rewritable_command("cargo test --lib"));
198 assert!(is_rewritable_command("npm run build"));
199 assert!(is_rewritable_command("eslint"));
200 assert!(is_rewritable_command("docker-compose up"));
201 assert!(is_rewritable_command("bun install"));
202 assert!(is_rewritable_command("bunx vitest"));
203 assert!(is_rewritable_command("deno test"));
204 assert!(is_rewritable_command("vite build"));
205 assert!(is_rewritable_command("terraform plan"));
206 assert!(is_rewritable_command("make build"));
207 assert!(is_rewritable_command("dotnet build"));
208 }
209
210 #[test]
211 fn is_rewritable_excludes() {
212 assert!(!is_rewritable_command("echo hello"));
213 assert!(!is_rewritable_command("cd src"));
214 assert!(!is_rewritable_command("cat file.rs"));
215 assert!(!is_rewritable_command("head -20 file.rs"));
216 assert!(is_rewritable_command("rg pattern"));
217 assert!(is_rewritable_command("ls /tmp"));
218 assert!(is_rewritable_command("find . -name '*.rs'"));
219 }
220
221 #[test]
222 fn file_read_commands_detected() {
223 assert!(is_file_read_command("cat file.rs"));
224 assert!(is_file_read_command("head -20 file.rs"));
225 assert!(is_file_read_command("tail -n 10 file.rs"));
226 assert!(!is_file_read_command("git status"));
227 assert!(!is_file_read_command("echo hello"));
228 }
229
230 #[test]
231 fn shell_alias_list_includes_all() {
232 let list = shell_alias_list();
233 assert!(list.contains("git"));
234 assert!(list.contains("cargo"));
235 assert!(list.contains("docker-compose"));
236 assert!(list.contains("rg"));
237 assert!(list.contains(" ls ") || list.ends_with(" ls"));
238 assert!(list.contains("find"));
239 }
240
241 #[test]
242 fn bash_case_pattern_valid() {
243 let pattern = bash_case_pattern();
244 assert!(pattern.contains(r"git\ *"));
245 assert!(pattern.contains(r"cargo\ *"));
246 assert!(pattern.contains(r"rg\ *"));
247 assert!(pattern.contains(r"ls\ *"));
248 }
249
250 #[test]
251 fn hook_prefixes_superset_of_bare_commands() {
252 let prefixes = hook_prefixes();
253 let bare = hook_bare_commands();
254 for cmd in &bare {
255 let with_space = format!("{cmd} ");
256 assert!(
257 prefixes.contains(&with_space),
258 "bare command '{cmd}' missing from hook_prefixes"
259 );
260 }
261 assert!(
262 !bare.contains(&"cat"),
263 "FileRead commands must not be in hook_bare_commands"
264 );
265 }
266
267 #[test]
268 fn shell_aliases_superset_of_hook_commands() {
269 let aliases = shell_alias_commands();
270 let hook = hook_bare_commands();
271 for cmd in &hook {
272 assert!(
273 aliases.contains(cmd),
274 "hook command '{cmd}' missing from shell_alias_commands"
275 );
276 }
277 }
278
279 #[test]
280 fn all_categories_represented() {
281 let categories: std::collections::HashSet<_> =
282 REWRITE_COMMANDS.iter().map(|e| e.category).collect();
283 assert!(categories.contains(&Category::Vcs));
284 assert!(categories.contains(&Category::Build));
285 assert!(categories.contains(&Category::PackageManager));
286 assert!(categories.contains(&Category::Lint));
287 assert!(categories.contains(&Category::Infra));
288 assert!(categories.contains(&Category::Http));
289 assert!(categories.contains(&Category::Search));
290 assert!(categories.contains(&Category::DirList));
291 }
292
293 #[test]
294 fn every_command_rewritable_except_fileread() {
295 for entry in REWRITE_COMMANDS {
296 let cmd = format!("{} --version", entry.command);
297 if matches!(entry.category, Category::FileRead) {
298 assert!(
299 !is_rewritable_command(&cmd),
300 "{:?} command '{}' should NOT be rewritable via -c wrap",
301 entry.category,
302 entry.command
303 );
304 } else {
305 assert!(
306 is_rewritable_command(&cmd),
307 "command '{}' should be rewritable",
308 entry.command
309 );
310 }
311 }
312 }
313
314 #[test]
315 fn bash_pattern_has_entry_for_every_hookable_command() {
316 let pattern = bash_case_pattern();
317 for entry in REWRITE_COMMANDS {
318 if matches!(entry.category, Category::FileRead) {
319 continue;
320 }
321 let escaped = if entry.command.contains('-') {
322 format!("{}*", entry.command.replace('-', r"\-"))
323 } else {
324 format!(r"{}\ *", entry.command)
325 };
326 assert!(
327 pattern.contains(&escaped),
328 "bash case pattern missing '{}'",
329 entry.command
330 );
331 }
332 }
333}