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("python", Category::Build),
19    re("python3", Category::Build),
20    re("pip", Category::PackageManager),
21    re("pip3", Category::PackageManager),
22    re("uv", Category::PackageManager),
23    re("pytest", Category::Build),
24    re("mypy", Category::Lint),
25    re("ruff", Category::Lint),
26    // Go
27    re("go", Category::Build),
28    re("golangci-lint", Category::Lint),
29    // Containers / Infra
30    re("docker", Category::Infra),
31    re("docker-compose", Category::Infra),
32    re("kubectl", Category::Infra),
33    re("helm", Category::Infra),
34    re("aws", Category::Infra),
35    re("terraform", Category::Infra),
36    re("tofu", Category::Infra),
37    // Linters / Formatters
38    re("eslint", Category::Lint),
39    re("prettier", Category::Lint),
40    re("tsc", Category::Lint),
41    re("biome", Category::Lint),
42    // HTTP
43    re("curl", Category::Http),
44    re("wget", Category::Http),
45    // PHP
46    re("php", Category::Build),
47    re("composer", Category::PackageManager),
48    // .NET
49    re("dotnet", Category::Build),
50    // Ruby
51    re("bundle", Category::PackageManager),
52    re("rake", Category::Build),
53    // Elixir
54    re("mix", Category::Build),
55    // Swift / Zig / CMake
56    re("swift", Category::Build),
57    re("zig", Category::Build),
58    re("cmake", Category::Build),
59    re("make", Category::Build),
60    // Search (rewritten in hooks to enforce hybrid)
61    re("grep", Category::Search),
62    re("egrep", Category::Search),
63    re("fgrep", Category::Search),
64    re("rg", Category::Search),
65    // File read alternatives (rewritten to lean-ctx read, not lean-ctx -c)
66    re("cat", Category::FileRead),
67    re("head", Category::FileRead),
68    re("tail", Category::FileRead),
69    // Directory listing (rewritten in hooks to enforce hybrid; may fall back to `lean-ctx -c`)
70    re("ls", Category::DirList),
71    re("find", Category::DirList),
72];
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum Category {
76    Vcs,
77    Build,
78    PackageManager,
79    Lint,
80    Infra,
81    Http,
82    Search,
83    FileRead,
84    DirList,
85}
86
87#[derive(Debug, Clone, Copy)]
88pub struct RewriteEntry {
89    pub command: &'static str,
90    pub category: Category,
91}
92
93const fn re(command: &'static str, category: Category) -> RewriteEntry {
94    RewriteEntry { command, category }
95}
96
97/// Commands eligible for PreToolUse hook rewriting (IDE hooks).
98/// Excludes `FileRead` (handled separately in hook_handlers).
99pub fn hook_prefixes() -> Vec<String> {
100    REWRITE_COMMANDS
101        .iter()
102        .filter(|e| !matches!(e.category, Category::FileRead))
103        .map(|e| format!("{} ", e.command))
104        .collect()
105}
106
107/// Commands eligible for PreToolUse hook (bare command match, no trailing space).
108/// Used for commands like `eslint`, `prettier`, `tsc` that may run without args.
109pub fn hook_bare_commands() -> Vec<&'static str> {
110    REWRITE_COMMANDS
111        .iter()
112        .filter(|e| !matches!(e.category, Category::FileRead))
113        .map(|e| e.command)
114        .collect()
115}
116
117/// Check if a command is a file-read alternative (cat/head/tail) that should be
118/// rewritten to `lean-ctx read` rather than `lean-ctx -c`.
119pub fn is_file_read_command(cmd: &str) -> bool {
120    REWRITE_COMMANDS
121        .iter()
122        .filter(|e| e.category == Category::FileRead)
123        .any(|e| {
124            let prefix = format!("{} ", e.command);
125            cmd.starts_with(&prefix) || cmd == e.command
126        })
127}
128
129/// All command names for shell alias generation.
130pub fn shell_alias_commands() -> Vec<&'static str> {
131    REWRITE_COMMANDS.iter().map(|e| e.command).collect()
132}
133
134/// Generates a bash `case` pattern for rewrite scripts.
135/// e.g. `git\ *|gh\ *|cargo\ *|npm\ *|...`
136pub fn bash_case_pattern() -> String {
137    REWRITE_COMMANDS
138        .iter()
139        .filter(|e| !matches!(e.category, Category::FileRead))
140        .map(|e| {
141            if e.command.contains('-') {
142                format!("{}*", e.command.replace('-', r"\-"))
143            } else {
144                format!(r"{}\ *", e.command)
145            }
146        })
147        .collect::<Vec<_>>()
148        .join("|")
149}
150
151/// Space-separated list for shell alias arrays.
152pub fn shell_alias_list() -> String {
153    shell_alias_commands().join(" ")
154}
155
156/// Check if a command string matches a rewritable prefix (for hook handlers).
157/// Excludes FileRead (handled separately in hook_handlers).
158///
159/// Handles common shell patterns:
160/// - Bare command: `git status`
161/// - Env-var prefixed: `PYTHONPATH=src python script.py`
162/// - Path-qualified: `./.venv/bin/python`, `/usr/bin/python3`
163pub fn is_rewritable_command(cmd: &str) -> bool {
164    let effective = strip_env_prefix(cmd);
165    let basename = extract_command_basename(effective);
166
167    for entry in REWRITE_COMMANDS {
168        if matches!(entry.category, Category::FileRead) {
169            continue;
170        }
171        let prefix = format!("{} ", entry.command);
172        if effective.starts_with(&prefix)
173            || effective == entry.command
174            || basename == entry.command
175            || basename.starts_with(&format!("{} ", entry.command))
176        {
177            return true;
178        }
179    }
180    false
181}
182
183/// Strip leading `KEY=value` env-var assignments from a command string.
184/// e.g. `PYTHONPATH=src FOO=bar python x.py` → `python x.py`
185fn strip_env_prefix(cmd: &str) -> &str {
186    let mut rest = cmd;
187    loop {
188        let trimmed = rest.trim_start();
189        if let Some(eq_pos) = trimmed.find('=') {
190            let before_eq = &trimmed[..eq_pos];
191            if !before_eq.is_empty()
192                && before_eq
193                    .bytes()
194                    .all(|b| b.is_ascii_alphanumeric() || b == b'_')
195                && let Some(space_pos) = trimmed[eq_pos..].find(' ')
196            {
197                rest = &trimmed[eq_pos + space_pos..];
198                continue;
199            }
200        }
201        return trimmed;
202    }
203}
204
205/// Extract the basename of a potentially path-qualified command.
206/// e.g. `./.venv/bin/python script.py` → `python script.py`
207///      `/usr/local/bin/cargo test` → `cargo test`
208fn extract_command_basename(cmd: &str) -> &str {
209    let first_space = cmd.find(' ').unwrap_or(cmd.len());
210    let cmd_part = &cmd[..first_space];
211    if let Some(slash_pos) = cmd_part.rfind('/') {
212        &cmd[slash_pos + 1..]
213    } else {
214        cmd
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn no_duplicates() {
224        let mut seen = std::collections::HashSet::new();
225        for entry in REWRITE_COMMANDS {
226            assert!(
227                seen.insert(entry.command),
228                "duplicate command: {}",
229                entry.command
230            );
231        }
232    }
233
234    #[test]
235    fn hook_prefixes_exclude_search_fileread_dirlist() {
236        let prefixes = hook_prefixes();
237        assert!(!prefixes.contains(&"cat ".to_string()));
238        assert!(!prefixes.contains(&"head ".to_string()));
239        assert!(!prefixes.contains(&"tail ".to_string()));
240        assert!(prefixes.contains(&"rg ".to_string()));
241        assert!(prefixes.contains(&"grep ".to_string()));
242        assert!(prefixes.contains(&"egrep ".to_string()));
243        assert!(prefixes.contains(&"fgrep ".to_string()));
244        assert!(prefixes.contains(&"ls ".to_string()));
245        assert!(prefixes.contains(&"find ".to_string()));
246        assert!(prefixes.contains(&"git ".to_string()));
247        assert!(prefixes.contains(&"cargo ".to_string()));
248    }
249
250    #[test]
251    fn is_rewritable_matches() {
252        assert!(is_rewritable_command("git status"));
253        assert!(is_rewritable_command("cargo test --lib"));
254        assert!(is_rewritable_command("npm run build"));
255        assert!(is_rewritable_command("eslint"));
256        assert!(is_rewritable_command("docker-compose up"));
257        assert!(is_rewritable_command("bun install"));
258        assert!(is_rewritable_command("bunx vitest"));
259        assert!(is_rewritable_command("deno test"));
260        assert!(is_rewritable_command("vite build"));
261        assert!(is_rewritable_command("terraform plan"));
262        assert!(is_rewritable_command("make build"));
263        assert!(is_rewritable_command("dotnet build"));
264        assert!(is_rewritable_command("python script.py"));
265        assert!(is_rewritable_command("python3 -m pytest"));
266        assert!(is_rewritable_command("uv run test"));
267    }
268
269    #[test]
270    fn is_rewritable_env_prefix() {
271        assert!(is_rewritable_command("PYTHONPATH=src python script.py"));
272        assert!(is_rewritable_command("FOO=bar BAZ=1 cargo test"));
273        assert!(is_rewritable_command("NODE_ENV=test npm run test"));
274        assert!(!is_rewritable_command("FOO=bar echo hello"));
275    }
276
277    #[test]
278    fn is_rewritable_path_qualified() {
279        assert!(is_rewritable_command("./.venv/bin/python script.py"));
280        assert!(is_rewritable_command("/usr/bin/python3 test.py"));
281        assert!(is_rewritable_command("/usr/local/bin/cargo build"));
282        assert!(is_rewritable_command(
283            "PYTHONPATH=src ./.venv/bin/python script.py"
284        ));
285        assert!(!is_rewritable_command("/usr/bin/some-unknown-tool arg"));
286    }
287
288    #[test]
289    fn is_rewritable_excludes() {
290        assert!(!is_rewritable_command("echo hello"));
291        assert!(!is_rewritable_command("cd src"));
292        assert!(!is_rewritable_command("cat file.rs"));
293        assert!(!is_rewritable_command("head -20 file.rs"));
294        assert!(is_rewritable_command("rg pattern"));
295        assert!(is_rewritable_command("grep -rn pattern src/"));
296        assert!(is_rewritable_command("egrep 'foo|bar' file.rs"));
297        assert!(is_rewritable_command("fgrep literal file.rs"));
298        assert!(is_rewritable_command("ls /tmp"));
299        assert!(is_rewritable_command("find . -name '*.rs'"));
300    }
301
302    #[test]
303    fn strip_env_prefix_works() {
304        assert_eq!(strip_env_prefix("python x.py"), "python x.py");
305        assert_eq!(strip_env_prefix("FOO=bar python x.py"), "python x.py");
306        assert_eq!(strip_env_prefix("A=1 B=2 cargo test"), "cargo test");
307        assert_eq!(strip_env_prefix("  FOO=bar cmd"), "cmd");
308        assert_eq!(strip_env_prefix("no_equals here"), "no_equals here");
309    }
310
311    #[test]
312    fn extract_command_basename_works() {
313        assert_eq!(extract_command_basename("python x.py"), "python x.py");
314        assert_eq!(
315            extract_command_basename("./.venv/bin/python x.py"),
316            "python x.py"
317        );
318        assert_eq!(
319            extract_command_basename("/usr/bin/python3 -m pytest"),
320            "python3 -m pytest"
321        );
322        assert_eq!(extract_command_basename("cargo test"), "cargo test");
323    }
324
325    #[test]
326    fn file_read_commands_detected() {
327        assert!(is_file_read_command("cat file.rs"));
328        assert!(is_file_read_command("head -20 file.rs"));
329        assert!(is_file_read_command("tail -n 10 file.rs"));
330        assert!(!is_file_read_command("git status"));
331        assert!(!is_file_read_command("echo hello"));
332    }
333
334    #[test]
335    fn shell_alias_list_includes_all() {
336        let list = shell_alias_list();
337        assert!(list.contains("git"));
338        assert!(list.contains("cargo"));
339        assert!(list.contains("docker-compose"));
340        assert!(list.contains("rg"));
341        assert!(list.contains(" ls ") || list.ends_with(" ls"));
342        assert!(list.contains("find"));
343    }
344
345    #[test]
346    fn bash_case_pattern_valid() {
347        let pattern = bash_case_pattern();
348        assert!(pattern.contains(r"git\ *"));
349        assert!(pattern.contains(r"cargo\ *"));
350        assert!(pattern.contains(r"rg\ *"));
351        assert!(pattern.contains(r"ls\ *"));
352    }
353
354    #[test]
355    fn hook_prefixes_superset_of_bare_commands() {
356        let prefixes = hook_prefixes();
357        let bare = hook_bare_commands();
358        for cmd in &bare {
359            let with_space = format!("{cmd} ");
360            assert!(
361                prefixes.contains(&with_space),
362                "bare command '{cmd}' missing from hook_prefixes"
363            );
364        }
365        assert!(
366            !bare.contains(&"cat"),
367            "FileRead commands must not be in hook_bare_commands"
368        );
369    }
370
371    #[test]
372    fn shell_aliases_superset_of_hook_commands() {
373        let aliases = shell_alias_commands();
374        let hook = hook_bare_commands();
375        for cmd in &hook {
376            assert!(
377                aliases.contains(cmd),
378                "hook command '{cmd}' missing from shell_alias_commands"
379            );
380        }
381    }
382
383    #[test]
384    fn all_categories_represented() {
385        let categories: std::collections::HashSet<_> =
386            REWRITE_COMMANDS.iter().map(|e| e.category).collect();
387        assert!(categories.contains(&Category::Vcs));
388        assert!(categories.contains(&Category::Build));
389        assert!(categories.contains(&Category::PackageManager));
390        assert!(categories.contains(&Category::Lint));
391        assert!(categories.contains(&Category::Infra));
392        assert!(categories.contains(&Category::Http));
393        assert!(categories.contains(&Category::Search));
394        assert!(categories.contains(&Category::DirList));
395    }
396
397    #[test]
398    fn every_command_rewritable_except_fileread() {
399        for entry in REWRITE_COMMANDS {
400            let cmd = format!("{} --version", entry.command);
401            if matches!(entry.category, Category::FileRead) {
402                assert!(
403                    !is_rewritable_command(&cmd),
404                    "{:?} command '{}' should NOT be rewritable via -c wrap",
405                    entry.category,
406                    entry.command
407                );
408            } else {
409                assert!(
410                    is_rewritable_command(&cmd),
411                    "command '{}' should be rewritable",
412                    entry.command
413                );
414            }
415        }
416    }
417
418    #[test]
419    fn bash_pattern_has_entry_for_every_hookable_command() {
420        let pattern = bash_case_pattern();
421        for entry in REWRITE_COMMANDS {
422            if matches!(entry.category, Category::FileRead) {
423                continue;
424            }
425            let escaped = if entry.command.contains('-') {
426                format!("{}*", entry.command.replace('-', r"\-"))
427            } else {
428                format!(r"{}\ *", entry.command)
429            };
430            assert!(
431                pattern.contains(&escaped),
432                "bash case pattern missing '{}'",
433                entry.command
434            );
435        }
436    }
437}