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