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    // File read alternatives (rewritten to lean-ctx read, not lean-ctx -c)
60    re("cat", Category::FileRead),
61    re("head", Category::FileRead),
62    re("tail", Category::FileRead),
63    // Directory listing (shell alias only, like FileRead)
64    re("ls", Category::DirList),
65    re("find", Category::DirList),
66];
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Category {
70    Vcs,
71    Build,
72    PackageManager,
73    Lint,
74    Infra,
75    Http,
76    Search,
77    FileRead,
78    DirList,
79}
80
81#[derive(Debug, Clone, Copy)]
82pub struct RewriteEntry {
83    pub command: &'static str,
84    pub category: Category,
85}
86
87const fn re(command: &'static str, category: Category) -> RewriteEntry {
88    RewriteEntry { command, category }
89}
90
91/// Commands eligible for PreToolUse hook rewriting (IDE hooks).
92/// Excludes `rg` (search tools) and `FileRead` (handled separately in hook_handlers).
93pub fn hook_prefixes() -> Vec<String> {
94    REWRITE_COMMANDS
95        .iter()
96        .filter(|e| {
97            !matches!(
98                e.category,
99                Category::Search | Category::FileRead | Category::DirList
100            )
101        })
102        .map(|e| format!("{} ", e.command))
103        .collect()
104}
105
106/// Commands eligible for PreToolUse hook (bare command match, no trailing space).
107/// Used for commands like `eslint`, `prettier`, `tsc` that may run without args.
108pub fn hook_bare_commands() -> Vec<&'static str> {
109    REWRITE_COMMANDS
110        .iter()
111        .filter(|e| {
112            !matches!(
113                e.category,
114                Category::Search | Category::FileRead | Category::DirList
115            )
116        })
117        .map(|e| e.command)
118        .collect()
119}
120
121/// Check if a command is a file-read alternative (cat/head/tail) that should be
122/// rewritten to `lean-ctx read` rather than `lean-ctx -c`.
123pub fn is_file_read_command(cmd: &str) -> bool {
124    REWRITE_COMMANDS
125        .iter()
126        .filter(|e| e.category == Category::FileRead)
127        .any(|e| {
128            let prefix = format!("{} ", e.command);
129            cmd.starts_with(&prefix) || cmd == e.command
130        })
131}
132
133/// All command names for shell alias generation.
134pub fn shell_alias_commands() -> Vec<&'static str> {
135    REWRITE_COMMANDS.iter().map(|e| e.command).collect()
136}
137
138/// Generates a bash `case` pattern for rewrite scripts.
139/// e.g. `git\ *|gh\ *|cargo\ *|npm\ *|...`
140pub fn bash_case_pattern() -> String {
141    REWRITE_COMMANDS
142        .iter()
143        .filter(|e| {
144            !matches!(
145                e.category,
146                Category::Search | Category::FileRead | Category::DirList
147            )
148        })
149        .map(|e| {
150            if e.command.contains('-') {
151                format!("{}*", e.command.replace('-', r"\-"))
152            } else {
153                format!(r"{}\ *", e.command)
154            }
155        })
156        .collect::<Vec<_>>()
157        .join("|")
158}
159
160/// Space-separated list for shell alias arrays.
161pub fn shell_alias_list() -> String {
162    shell_alias_commands().join(" ")
163}
164
165/// Check if a command string matches a rewritable prefix (for hook handlers).
166/// Excludes Search, FileRead, and DirList (all have dedicated rewrite paths).
167pub fn is_rewritable_command(cmd: &str) -> bool {
168    for entry in REWRITE_COMMANDS {
169        if matches!(
170            entry.category,
171            Category::Search | Category::FileRead | Category::DirList
172        ) {
173            continue;
174        }
175        let prefix = format!("{} ", entry.command);
176        if cmd.starts_with(&prefix) || cmd == entry.command {
177            return true;
178        }
179    }
180    false
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn no_duplicates() {
189        let mut seen = std::collections::HashSet::new();
190        for entry in REWRITE_COMMANDS {
191            assert!(
192                seen.insert(entry.command),
193                "duplicate command: {}",
194                entry.command
195            );
196        }
197    }
198
199    #[test]
200    fn hook_prefixes_exclude_search_fileread_dirlist() {
201        let prefixes = hook_prefixes();
202        assert!(!prefixes.contains(&"rg ".to_string()));
203        assert!(!prefixes.contains(&"cat ".to_string()));
204        assert!(!prefixes.contains(&"head ".to_string()));
205        assert!(!prefixes.contains(&"tail ".to_string()));
206        assert!(!prefixes.contains(&"ls ".to_string()));
207        assert!(!prefixes.contains(&"find ".to_string()));
208        assert!(prefixes.contains(&"git ".to_string()));
209        assert!(prefixes.contains(&"cargo ".to_string()));
210    }
211
212    #[test]
213    fn is_rewritable_matches() {
214        assert!(is_rewritable_command("git status"));
215        assert!(is_rewritable_command("cargo test --lib"));
216        assert!(is_rewritable_command("npm run build"));
217        assert!(is_rewritable_command("eslint"));
218        assert!(is_rewritable_command("docker-compose up"));
219        assert!(is_rewritable_command("bun install"));
220        assert!(is_rewritable_command("bunx vitest"));
221        assert!(is_rewritable_command("deno test"));
222        assert!(is_rewritable_command("vite build"));
223        assert!(is_rewritable_command("terraform plan"));
224        assert!(is_rewritable_command("make build"));
225        assert!(is_rewritable_command("dotnet build"));
226    }
227
228    #[test]
229    fn is_rewritable_excludes() {
230        assert!(!is_rewritable_command("echo hello"));
231        assert!(!is_rewritable_command("cd src"));
232        assert!(!is_rewritable_command("rg pattern"));
233        assert!(!is_rewritable_command("cat file.rs"));
234        assert!(!is_rewritable_command("head -20 file.rs"));
235        assert!(!is_rewritable_command("ls /tmp"));
236        assert!(!is_rewritable_command("find . -name '*.rs'"));
237    }
238
239    #[test]
240    fn file_read_commands_detected() {
241        assert!(is_file_read_command("cat file.rs"));
242        assert!(is_file_read_command("head -20 file.rs"));
243        assert!(is_file_read_command("tail -n 10 file.rs"));
244        assert!(!is_file_read_command("git status"));
245        assert!(!is_file_read_command("echo hello"));
246    }
247
248    #[test]
249    fn shell_alias_list_includes_all() {
250        let list = shell_alias_list();
251        assert!(list.contains("git"));
252        assert!(list.contains("cargo"));
253        assert!(list.contains("docker-compose"));
254        assert!(list.contains("rg"));
255        assert!(list.contains(" ls ") || list.ends_with(" ls"));
256        assert!(list.contains("find"));
257    }
258
259    #[test]
260    fn bash_case_pattern_valid() {
261        let pattern = bash_case_pattern();
262        assert!(pattern.contains(r"git\ *"));
263        assert!(pattern.contains(r"cargo\ *"));
264        assert!(
265            !pattern.contains(r"rg\ *"),
266            "rg should not be in hook case pattern"
267        );
268        assert!(
269            !pattern.contains(r"ls\ *"),
270            "ls should not be in hook case pattern"
271        );
272    }
273
274    #[test]
275    fn hook_prefixes_superset_of_bare_commands() {
276        let prefixes = hook_prefixes();
277        let bare = hook_bare_commands();
278        for cmd in &bare {
279            let with_space = format!("{cmd} ");
280            assert!(
281                prefixes.contains(&with_space),
282                "bare command '{cmd}' missing from hook_prefixes"
283            );
284        }
285        assert!(
286            !bare.contains(&"cat"),
287            "FileRead commands must not be in hook_bare_commands"
288        );
289        assert!(
290            !bare.contains(&"ls"),
291            "DirList commands must not be in hook_bare_commands"
292        );
293    }
294
295    #[test]
296    fn shell_aliases_superset_of_hook_commands() {
297        let aliases = shell_alias_commands();
298        let hook = hook_bare_commands();
299        for cmd in &hook {
300            assert!(
301                aliases.contains(cmd),
302                "hook command '{cmd}' missing from shell_alias_commands"
303            );
304        }
305    }
306
307    #[test]
308    fn all_categories_represented() {
309        let categories: std::collections::HashSet<_> =
310            REWRITE_COMMANDS.iter().map(|e| e.category).collect();
311        assert!(categories.contains(&Category::Vcs));
312        assert!(categories.contains(&Category::Build));
313        assert!(categories.contains(&Category::PackageManager));
314        assert!(categories.contains(&Category::Lint));
315        assert!(categories.contains(&Category::Infra));
316        assert!(categories.contains(&Category::Http));
317        assert!(categories.contains(&Category::Search));
318        assert!(categories.contains(&Category::DirList));
319    }
320
321    #[test]
322    fn every_command_rewritable_except_search_fileread_dirlist() {
323        for entry in REWRITE_COMMANDS {
324            let cmd = format!("{} --version", entry.command);
325            if matches!(
326                entry.category,
327                Category::Search | Category::FileRead | Category::DirList
328            ) {
329                assert!(
330                    !is_rewritable_command(&cmd),
331                    "{:?} command '{}' should NOT be rewritable via -c wrap",
332                    entry.category,
333                    entry.command
334                );
335            } else {
336                assert!(
337                    is_rewritable_command(&cmd),
338                    "command '{}' should be rewritable",
339                    entry.command
340                );
341            }
342        }
343    }
344
345    #[test]
346    fn bash_pattern_has_entry_for_every_hookable_command() {
347        let pattern = bash_case_pattern();
348        for entry in REWRITE_COMMANDS {
349            if matches!(
350                entry.category,
351                Category::Search | Category::FileRead | Category::DirList
352            ) {
353                continue;
354            }
355            let escaped = if entry.command.contains('-') {
356                format!("{}*", entry.command.replace('-', r"\-"))
357            } else {
358                format!(r"{}\ *", entry.command)
359            };
360            assert!(
361                pattern.contains(&escaped),
362                "bash case pattern missing '{}'",
363                entry.command
364            );
365        }
366    }
367}