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("pip", Category::PackageManager),
15 re("pip3", Category::PackageManager),
16 re("pytest", Category::Build),
17 re("mypy", Category::Lint),
18 re("ruff", Category::Lint),
19 re("go", Category::Build),
21 re("golangci-lint", Category::Lint),
22 re("docker", Category::Infra),
24 re("docker-compose", Category::Infra),
25 re("kubectl", Category::Infra),
26 re("helm", Category::Infra),
27 re("aws", Category::Infra),
28 re("eslint", Category::Lint),
30 re("prettier", Category::Lint),
31 re("tsc", Category::Lint),
32 re("curl", Category::Http),
34 re("wget", Category::Http),
35 re("php", Category::Build),
37 re("composer", Category::PackageManager),
38 re("rg", Category::Search),
40];
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum Category {
44 Vcs,
45 Build,
46 PackageManager,
47 Lint,
48 Infra,
49 Http,
50 Search,
51}
52
53#[derive(Debug, Clone, Copy)]
54pub struct RewriteEntry {
55 pub command: &'static str,
56 pub category: Category,
57}
58
59const fn re(command: &'static str, category: Category) -> RewriteEntry {
60 RewriteEntry { command, category }
61}
62
63pub fn hook_prefixes() -> Vec<String> {
66 REWRITE_COMMANDS
67 .iter()
68 .filter(|e| e.category != Category::Search)
69 .map(|e| format!("{} ", e.command))
70 .collect()
71}
72
73pub fn hook_bare_commands() -> Vec<&'static str> {
76 REWRITE_COMMANDS
77 .iter()
78 .filter(|e| e.category != Category::Search)
79 .map(|e| e.command)
80 .collect()
81}
82
83pub fn shell_alias_commands() -> Vec<&'static str> {
85 REWRITE_COMMANDS.iter().map(|e| e.command).collect()
86}
87
88pub fn bash_case_pattern() -> String {
91 REWRITE_COMMANDS
92 .iter()
93 .filter(|e| e.category != Category::Search)
94 .map(|e| {
95 if e.command.contains('-') {
96 format!("{}*", e.command.replace('-', r"\-"))
97 } else {
98 format!(r"{}\ *", e.command)
99 }
100 })
101 .collect::<Vec<_>>()
102 .join("|")
103}
104
105pub fn shell_alias_list() -> String {
107 shell_alias_commands().join(" ")
108}
109
110pub fn is_rewritable_command(cmd: &str) -> bool {
112 for entry in REWRITE_COMMANDS {
113 if entry.category == Category::Search {
114 continue;
115 }
116 let prefix = format!("{} ", entry.command);
117 if cmd.starts_with(&prefix) || cmd == entry.command {
118 return true;
119 }
120 }
121 false
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn no_duplicates() {
130 let mut seen = std::collections::HashSet::new();
131 for entry in REWRITE_COMMANDS {
132 assert!(
133 seen.insert(entry.command),
134 "duplicate command: {}",
135 entry.command
136 );
137 }
138 }
139
140 #[test]
141 fn hook_prefixes_exclude_search() {
142 let prefixes = hook_prefixes();
143 assert!(!prefixes.contains(&"rg ".to_string()));
144 assert!(prefixes.contains(&"git ".to_string()));
145 assert!(prefixes.contains(&"cargo ".to_string()));
146 }
147
148 #[test]
149 fn is_rewritable_matches() {
150 assert!(is_rewritable_command("git status"));
151 assert!(is_rewritable_command("cargo test --lib"));
152 assert!(is_rewritable_command("npm run build"));
153 assert!(is_rewritable_command("eslint"));
154 assert!(is_rewritable_command("docker-compose up"));
155 }
156
157 #[test]
158 fn is_rewritable_excludes() {
159 assert!(!is_rewritable_command("echo hello"));
160 assert!(!is_rewritable_command("cd src"));
161 assert!(!is_rewritable_command("rg pattern"));
162 }
163
164 #[test]
165 fn shell_alias_list_includes_all() {
166 let list = shell_alias_list();
167 assert!(list.contains("git"));
168 assert!(list.contains("cargo"));
169 assert!(list.contains("docker-compose"));
170 assert!(list.contains("rg"));
171 }
172
173 #[test]
174 fn bash_case_pattern_valid() {
175 let pattern = bash_case_pattern();
176 assert!(pattern.contains(r"git\ *"));
177 assert!(pattern.contains(r"cargo\ *"));
178 assert!(
179 !pattern.contains(r"rg\ *"),
180 "rg should not be in hook case pattern"
181 );
182 }
183
184 #[test]
185 fn hook_prefixes_superset_of_bare_commands() {
186 let prefixes = hook_prefixes();
187 let bare = hook_bare_commands();
188 for cmd in &bare {
189 let with_space = format!("{cmd} ");
190 assert!(
191 prefixes.contains(&with_space),
192 "bare command '{cmd}' missing from hook_prefixes"
193 );
194 }
195 }
196
197 #[test]
198 fn shell_aliases_superset_of_hook_commands() {
199 let aliases = shell_alias_commands();
200 let hook = hook_bare_commands();
201 for cmd in &hook {
202 assert!(
203 aliases.contains(cmd),
204 "hook command '{cmd}' missing from shell_alias_commands"
205 );
206 }
207 }
208
209 #[test]
210 fn all_categories_represented() {
211 let categories: std::collections::HashSet<_> =
212 REWRITE_COMMANDS.iter().map(|e| e.category).collect();
213 assert!(categories.contains(&Category::Vcs));
214 assert!(categories.contains(&Category::Build));
215 assert!(categories.contains(&Category::PackageManager));
216 assert!(categories.contains(&Category::Lint));
217 assert!(categories.contains(&Category::Infra));
218 assert!(categories.contains(&Category::Http));
219 assert!(categories.contains(&Category::Search));
220 }
221
222 #[test]
223 fn every_command_rewritable_except_search() {
224 for entry in REWRITE_COMMANDS {
225 let cmd = format!("{} --version", entry.command);
226 if entry.category == Category::Search {
227 assert!(
228 !is_rewritable_command(&cmd),
229 "search command '{}' should NOT be rewritable",
230 entry.command
231 );
232 } else {
233 assert!(
234 is_rewritable_command(&cmd),
235 "command '{}' should be rewritable",
236 entry.command
237 );
238 }
239 }
240 }
241
242 #[test]
243 fn bash_pattern_has_entry_for_every_non_search_command() {
244 let pattern = bash_case_pattern();
245 for entry in REWRITE_COMMANDS {
246 if entry.category == Category::Search {
247 continue;
248 }
249 let escaped = if entry.command.contains('-') {
250 format!("{}*", entry.command.replace('-', r"\-"))
251 } else {
252 format!(r"{}\ *", entry.command)
253 };
254 assert!(
255 pattern.contains(&escaped),
256 "bash case pattern missing '{}'",
257 entry.command
258 );
259 }
260 }
261}