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