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