Skip to main content

lean_ctx/
rewrite_registry.rs

1/// Single source of truth for all commands that lean-ctx rewrites/compresses.
2/// Used by: hook_handlers (PreToolUse), hooks.rs (bash scripts), cli.rs (shell aliases).
3pub const REWRITE_COMMANDS: &[RewriteEntry] = &[
4    // Version control
5    re("git", Category::Vcs),
6    re("gh", Category::Vcs),
7    // Rust
8    re("cargo", Category::Build),
9    // JavaScript/Node
10    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    // Python
18    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    // Go
24    re("go", Category::Build),
25    re("golangci-lint", Category::Lint),
26    // Containers / Infra
27    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    // Linters / Formatters
35    re("eslint", Category::Lint),
36    re("prettier", Category::Lint),
37    re("tsc", Category::Lint),
38    re("biome", Category::Lint),
39    // HTTP
40    re("curl", Category::Http),
41    re("wget", Category::Http),
42    // PHP
43    re("php", Category::Build),
44    re("composer", Category::PackageManager),
45    // .NET
46    re("dotnet", Category::Build),
47    // Ruby
48    re("bundle", Category::PackageManager),
49    re("rake", Category::Build),
50    // Elixir
51    re("mix", Category::Build),
52    // Swift / Zig / CMake
53    re("swift", Category::Build),
54    re("zig", Category::Build),
55    re("cmake", Category::Build),
56    re("make", Category::Build),
57    // Search (only in shell aliases, NOT in hook rewrite to avoid overriding native tools)
58    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
82/// Commands eligible for PreToolUse hook rewriting (IDE hooks).
83/// Excludes `rg` (search tools should use native Read/Grep in IDEs).
84pub 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
92/// Commands eligible for PreToolUse hook (bare command match, no trailing space).
93/// Used for commands like `eslint`, `prettier`, `tsc` that may run without args.
94pub 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
102/// All command names for shell alias generation.
103pub fn shell_alias_commands() -> Vec<&'static str> {
104    REWRITE_COMMANDS.iter().map(|e| e.command).collect()
105}
106
107/// Generates a bash `case` pattern for rewrite scripts.
108/// e.g. `git\ *|gh\ *|cargo\ *|npm\ *|...`
109pub 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
124/// Space-separated list for shell alias arrays.
125pub fn shell_alias_list() -> String {
126    shell_alias_commands().join(" ")
127}
128
129/// Check if a command string matches a rewritable prefix (for hook handlers).
130pub 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}