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    // Python
14    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    // Go
20    re("go", Category::Build),
21    re("golangci-lint", Category::Lint),
22    // Containers / Infra
23    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    // Linters / Formatters
29    re("eslint", Category::Lint),
30    re("prettier", Category::Lint),
31    re("tsc", Category::Lint),
32    // HTTP
33    re("curl", Category::Http),
34    re("wget", Category::Http),
35    // PHP
36    re("php", Category::Build),
37    re("composer", Category::PackageManager),
38    // Search (only in shell aliases, NOT in hook rewrite to avoid overriding native tools)
39    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
63/// Commands eligible for PreToolUse hook rewriting (IDE hooks).
64/// Excludes `rg` (search tools should use native Read/Grep in IDEs).
65pub 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
73/// Commands eligible for PreToolUse hook (bare command match, no trailing space).
74/// Used for commands like `eslint`, `prettier`, `tsc` that may run without args.
75pub 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
83/// All command names for shell alias generation.
84pub fn shell_alias_commands() -> Vec<&'static str> {
85    REWRITE_COMMANDS.iter().map(|e| e.command).collect()
86}
87
88/// Generates a bash `case` pattern for rewrite scripts.
89/// e.g. `git\ *|gh\ *|cargo\ *|npm\ *|...`
90pub 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
105/// Space-separated list for shell alias arrays.
106pub fn shell_alias_list() -> String {
107    shell_alias_commands().join(" ")
108}
109
110/// Check if a command string matches a rewritable prefix (for hook handlers).
111pub 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}