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