Skip to main content

lean_ctx/
hook_handlers.rs

1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4use std::sync::mpsc;
5use std::time::Duration;
6
7const HOOK_STDIN_TIMEOUT: Duration = Duration::from_secs(3);
8
9fn is_disabled() -> bool {
10    std::env::var("LEAN_CTX_DISABLED").is_ok()
11}
12
13/// Mark this process as a hook child so the daemon-client never auto-starts
14/// the daemon from inside a hook (which would create zombie processes).
15pub fn mark_hook_environment() {
16    std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
17}
18
19/// Arms a watchdog that force-exits the process after the given duration.
20/// Prevents hook processes from becoming zombies when stdin pipes break or
21/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
22/// (to avoid orphan zombies), a simple exit(1) suffices.
23pub fn arm_watchdog(timeout: Duration) {
24    std::thread::spawn(move || {
25        std::thread::sleep(timeout);
26        eprintln!(
27            "[lean-ctx hook] watchdog timeout after {}s — force exit",
28            timeout.as_secs()
29        );
30        std::process::exit(1);
31    });
32}
33
34/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
35fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
36    let (tx, rx) = mpsc::channel();
37    std::thread::spawn(move || {
38        let mut buf = String::new();
39        let result = std::io::stdin().read_to_string(&mut buf);
40        let _ = tx.send(result.ok().map(|_| buf));
41    });
42    match rx.recv_timeout(timeout) {
43        Ok(Some(s)) if !s.is_empty() => Some(s),
44        _ => None,
45    }
46}
47
48fn build_dual_deny_output(reason: &str) -> String {
49    serde_json::json!({
50        "permission": "deny",
51        "reason": reason,
52        "hookSpecificOutput": {
53            "hookEventName": "PreToolUse",
54            "permissionDecision": "deny",
55        }
56    })
57    .to_string()
58}
59
60fn build_dual_allow_output() -> String {
61    serde_json::json!({
62        "permission": "allow",
63        "hookSpecificOutput": {
64            "hookEventName": "PreToolUse",
65            "permissionDecision": "allow"
66        }
67    })
68    .to_string()
69}
70
71fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
72    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
73        let mut m = obj.clone();
74        m.insert(
75            "command".to_string(),
76            serde_json::Value::String(rewritten.to_string()),
77        );
78        serde_json::Value::Object(m)
79    } else {
80        serde_json::json!({ "command": rewritten })
81    };
82
83    serde_json::json!({
84        // Cursor hook output format
85        "permission": "allow",
86        "updated_input": updated_input,
87        // Claude Code hook output format (extra fields are ignored by other hosts)
88        "hookSpecificOutput": {
89            "hookEventName": "PreToolUse",
90            "permissionDecision": "allow",
91            "updatedInput": {
92                "command": rewritten
93            }
94        }
95    })
96    .to_string()
97}
98
99pub fn handle_rewrite() {
100    if is_disabled() {
101        return;
102    }
103    let binary = resolve_binary();
104    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
105        return;
106    };
107
108    let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
109        v
110    } else {
111        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
112        return;
113    };
114
115    let tool = v.get("tool_name").and_then(|t| t.as_str());
116    let Some(tool_name) = tool else {
117        return;
118    };
119
120    // Claude Code uses Bash; Cursor uses Shell; Copilot uses runInTerminal.
121    let is_shell_tool = matches!(
122        tool_name,
123        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
124    );
125    if !is_shell_tool {
126        return;
127    }
128
129    let tool_input = v.get("tool_input");
130    let Some(cmd) = tool_input
131        .and_then(|ti| ti.get("command"))
132        .and_then(|c| c.as_str())
133        .or_else(|| v.get("command").and_then(|c| c.as_str()))
134    else {
135        return;
136    };
137
138    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
139        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
140    } else {
141        // Always return a valid allow JSON for hosts that require JSON on exit 0.
142        print!("{}", build_dual_allow_output());
143    }
144}
145
146fn is_rewritable(cmd: &str) -> bool {
147    rewrite_registry::is_rewritable_command(cmd)
148}
149
150fn wrap_single_command(cmd: &str, binary: &str) -> String {
151    let shell_escaped = cmd.replace('\'', "'\\''");
152    format!("{binary} -c '{shell_escaped}'")
153}
154
155fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
156    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
157        return None;
158    }
159
160    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
161    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
162    if cmd.contains("<<") {
163        return None;
164    }
165
166    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
167        return Some(rewritten);
168    }
169
170    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
171        return Some(rewritten);
172    }
173
174    if is_rewritable(cmd) {
175        return Some(wrap_single_command(cmd, binary));
176    }
177
178    None
179}
180
181/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
182fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
183    if !rewrite_registry::is_file_read_command(cmd) {
184        return None;
185    }
186
187    let parts: Vec<&str> = cmd.split_whitespace().collect();
188    if parts.len() < 2 {
189        return None;
190    }
191
192    match parts[0] {
193        "cat" => {
194            let path = parts[1..].join(" ");
195            Some(format!("{binary} read {path}"))
196        }
197        "head" => {
198            let (n, path) = parse_head_tail_args(&parts[1..]);
199            let path = path?;
200            match n {
201                Some(lines) => Some(format!("{binary} read {path} -m lines:1-{lines}")),
202                None => Some(format!("{binary} read {path} -m lines:1-10")),
203            }
204        }
205        "tail" => {
206            let (n, path) = parse_head_tail_args(&parts[1..]);
207            let path = path?;
208            let lines = n.unwrap_or(10);
209            Some(format!("{binary} read {path} -m lines:-{lines}"))
210        }
211        _ => None,
212    }
213}
214
215fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
216    let mut n: Option<usize> = None;
217    let mut path: Option<&str> = None;
218
219    let mut i = 0;
220    while i < args.len() {
221        if args[i] == "-n" && i + 1 < args.len() {
222            n = args[i + 1].parse().ok();
223            i += 2;
224        } else if let Some(num) = args[i].strip_prefix("-n") {
225            n = num.parse().ok();
226            i += 1;
227        } else if args[i].starts_with('-') && args[i].len() > 1 {
228            if let Ok(num) = args[i][1..].parse::<usize>() {
229                n = Some(num);
230            }
231            i += 1;
232        } else {
233            path = Some(args[i]);
234            i += 1;
235        }
236    }
237
238    (n, path)
239}
240
241fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
242    compound_lexer::rewrite_compound(cmd, |segment| {
243        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
244            return None;
245        }
246        if is_rewritable(segment) {
247            Some(wrap_single_command(segment, binary))
248        } else {
249            None
250        }
251    })
252}
253
254fn emit_rewrite(rewritten: &str) {
255    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
256    print!(
257        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
258    );
259}
260
261pub fn handle_redirect() {
262    if is_disabled() {
263        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
264        print!("{}", build_dual_allow_output());
265        return;
266    }
267
268    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
269        return;
270    };
271
272    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
273        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
274        return;
275    };
276
277    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
278    let tool_input = v.get("tool_input");
279
280    match tool_name {
281        "Read" | "read" | "read_file" => redirect_read(tool_input),
282        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
283        _ => print!("{}", build_dual_allow_output()),
284    }
285}
286
287/// Redirect Read through lean-ctx for compression + caching.
288/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
289/// prevents daemon auto-start. The subprocess uses the fast local-only path.
290fn redirect_read(tool_input: Option<&serde_json::Value>) {
291    let path = tool_input
292        .and_then(|ti| ti.get("path"))
293        .and_then(|p| p.as_str())
294        .unwrap_or("");
295
296    if path.is_empty() || should_passthrough(path) {
297        print!("{}", build_dual_allow_output());
298        return;
299    }
300
301    let binary = resolve_binary();
302    let temp_path = redirect_temp_path(path);
303
304    if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
305        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
306            let temp_str = temp_path.to_str().unwrap_or("");
307            print!("{}", build_redirect_output(tool_input, "path", temp_str));
308            return;
309        }
310    }
311
312    print!("{}", build_dual_allow_output());
313}
314
315/// Redirect Grep through lean-ctx for compressed results.
316fn redirect_grep(tool_input: Option<&serde_json::Value>) {
317    let pattern = tool_input
318        .and_then(|ti| ti.get("pattern"))
319        .and_then(|p| p.as_str())
320        .unwrap_or("");
321    let search_path = tool_input
322        .and_then(|ti| ti.get("path"))
323        .and_then(|p| p.as_str())
324        .unwrap_or(".");
325
326    if pattern.is_empty() {
327        print!("{}", build_dual_allow_output());
328        return;
329    }
330
331    let binary = resolve_binary();
332    let key = format!("grep:{pattern}:{search_path}");
333    let temp_path = redirect_temp_path(&key);
334
335    if let Some(output) = run_with_timeout(
336        &binary,
337        &["grep", pattern, search_path],
338        REDIRECT_SUBPROCESS_TIMEOUT,
339    ) {
340        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
341            let temp_str = temp_path.to_str().unwrap_or("");
342            print!("{}", build_redirect_output(tool_input, "path", temp_str));
343            return;
344        }
345    }
346
347    print!("{}", build_dual_allow_output());
348}
349
350const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(3);
351
352/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
353/// Kills the child if it exceeds the timeout to prevent orphan processes.
354fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
355    let mut child = std::process::Command::new(binary)
356        .args(args)
357        .stdout(std::process::Stdio::piped())
358        .stderr(std::process::Stdio::null())
359        .spawn()
360        .ok()?;
361
362    let deadline = std::time::Instant::now() + timeout;
363    loop {
364        match child.try_wait() {
365            Ok(Some(status)) if status.success() => {
366                let mut stdout = Vec::new();
367                if let Some(mut out) = child.stdout.take() {
368                    let _ = out.read_to_end(&mut stdout);
369                }
370                return if stdout.is_empty() {
371                    None
372                } else {
373                    Some(stdout)
374                };
375            }
376            Ok(Some(_)) | Err(_) => return None,
377            Ok(None) => {
378                if std::time::Instant::now() > deadline {
379                    let _ = child.kill();
380                    let _ = child.wait();
381                    return None;
382                }
383                std::thread::sleep(Duration::from_millis(10));
384            }
385        }
386    }
387}
388
389fn redirect_temp_path(key: &str) -> std::path::PathBuf {
390    use std::collections::hash_map::DefaultHasher;
391    use std::hash::{Hash, Hasher};
392
393    let mut hasher = DefaultHasher::new();
394    key.hash(&mut hasher);
395    std::process::id().hash(&mut hasher);
396    let hash = hasher.finish();
397
398    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
399    let _ = std::fs::create_dir_all(&temp_dir);
400    #[cfg(unix)]
401    {
402        use std::os::unix::fs::PermissionsExt;
403        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
404    }
405    temp_dir.join(format!("{hash:016x}.lctx"))
406}
407
408fn build_redirect_output(
409    tool_input: Option<&serde_json::Value>,
410    field: &str,
411    temp_path: &str,
412) -> String {
413    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
414        let mut m = obj.clone();
415        m.insert(
416            field.to_string(),
417            serde_json::Value::String(temp_path.to_string()),
418        );
419        serde_json::Value::Object(m)
420    } else {
421        serde_json::json!({ field: temp_path })
422    };
423
424    serde_json::json!({
425        "permission": "allow",
426        "updated_input": updated_input,
427        "hookSpecificOutput": {
428            "hookEventName": "PreToolUse",
429            "permissionDecision": "allow",
430            "updatedInput": { field: temp_path }
431        }
432    })
433    .to_string()
434}
435
436const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
437    ".cursorrules",
438    ".cursor/rules",
439    ".cursor/hooks",
440    "skill.md",
441    "agents.md",
442    ".env",
443    "hooks.json",
444    "node_modules",
445];
446
447const PASSTHROUGH_EXTENSIONS: &[&str] = &[
448    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
449];
450
451fn should_passthrough(path: &str) -> bool {
452    let p = path.to_lowercase();
453
454    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
455        return true;
456    }
457
458    std::path::Path::new(&p)
459        .extension()
460        .and_then(|ext| ext.to_str())
461        .is_some_and(|ext| {
462            PASSTHROUGH_EXTENSIONS
463                .iter()
464                .any(|e| ext.eq_ignore_ascii_case(e))
465        })
466}
467
468fn codex_reroute_message(rewritten: &str) -> String {
469    format!(
470        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
471    )
472}
473
474pub fn handle_codex_pretooluse() {
475    if is_disabled() {
476        return;
477    }
478    let binary = resolve_binary();
479    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
480        return;
481    };
482
483    let tool = extract_json_field(&input, "tool_name");
484    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
485        return;
486    }
487
488    let Some(cmd) = extract_json_field(&input, "command") else {
489        return;
490    };
491
492    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
493        eprintln!("{}", codex_reroute_message(&rewritten));
494        std::process::exit(2);
495    }
496}
497
498pub fn handle_codex_session_start() {
499    println!(
500        "For shell commands matched by lean-ctx compression rules, prefer `lean-ctx -c \"<command>\"`. If a Bash call is blocked, rerun it with the exact command suggested by the hook."
501    );
502}
503
504/// Copilot-specific PreToolUse handler.
505/// VS Code Copilot Chat uses the same hook format as Claude Code.
506/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
507pub fn handle_copilot() {
508    if is_disabled() {
509        return;
510    }
511    let binary = resolve_binary();
512    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
513        return;
514    };
515
516    let tool = extract_json_field(&input, "tool_name");
517    let Some(tool_name) = tool.as_deref() else {
518        return;
519    };
520
521    let is_shell_tool = matches!(
522        tool_name,
523        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
524    );
525    if !is_shell_tool {
526        return;
527    }
528
529    let Some(cmd) = extract_json_field(&input, "command") else {
530        return;
531    };
532
533    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
534        emit_rewrite(&rewritten);
535    }
536}
537
538/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
539/// Used by the OpenCode TS plugin where the command is passed as an argument,
540/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
541/// shell may be PowerShell or cmd on Windows.
542pub fn handle_rewrite_inline() {
543    if is_disabled() {
544        return;
545    }
546    let binary = resolve_binary_native();
547    let args: Vec<String> = std::env::args().collect();
548    // args: [binary, "hook", "rewrite-inline", ...command parts]
549    if args.len() < 4 {
550        return;
551    }
552    let cmd = args[3..].join(" ");
553
554    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
555        print!("{rewritten}");
556        return;
557    }
558
559    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
560        print!("{cmd}");
561        return;
562    }
563
564    print!("{cmd}");
565}
566
567fn resolve_binary() -> String {
568    let path = crate::core::portable_binary::resolve_portable_binary();
569    crate::hooks::to_bash_compatible_path(&path)
570}
571
572fn resolve_binary_native() -> String {
573    crate::core::portable_binary::resolve_portable_binary()
574}
575
576fn extract_json_field(input: &str, field: &str) -> Option<String> {
577    let key = format!("\"{field}\":");
578    let key_pos = input.find(&key)?;
579    let after_colon = &input[key_pos + key.len()..];
580    let trimmed = after_colon.trim_start();
581    if !trimmed.starts_with('"') {
582        return None;
583    }
584    let rest = &trimmed[1..];
585    let bytes = rest.as_bytes();
586    let mut end = 0;
587    while end < bytes.len() {
588        if bytes[end] == b'\\' && end + 1 < bytes.len() {
589            end += 2;
590            continue;
591        }
592        if bytes[end] == b'"' {
593            break;
594        }
595        end += 1;
596    }
597    if end >= bytes.len() {
598        return None;
599    }
600    let raw = &rest[..end];
601    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn is_rewritable_basic() {
610        assert!(is_rewritable("git status"));
611        assert!(is_rewritable("cargo test --lib"));
612        assert!(is_rewritable("npm run build"));
613        assert!(!is_rewritable("echo hello"));
614        assert!(!is_rewritable("cd src"));
615        assert!(!is_rewritable("cat file.rs"));
616    }
617
618    #[test]
619    fn file_read_rewrite_cat() {
620        let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
621        assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
622    }
623
624    #[test]
625    fn file_read_rewrite_head_with_n() {
626        let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
627        assert_eq!(
628            r,
629            Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
630        );
631    }
632
633    #[test]
634    fn file_read_rewrite_head_short() {
635        let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
636        assert_eq!(
637            r,
638            Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
639        );
640    }
641
642    #[test]
643    fn file_read_rewrite_tail() {
644        let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
645        assert_eq!(
646            r,
647            Some("lean-ctx read src/main.rs -m lines:-10".to_string())
648        );
649    }
650
651    #[test]
652    fn file_read_rewrite_not_git() {
653        assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
654    }
655
656    #[test]
657    fn parse_head_tail_args_basic() {
658        let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
659        assert_eq!(n, Some(20));
660        assert_eq!(path, Some("file.rs"));
661    }
662
663    #[test]
664    fn parse_head_tail_args_combined() {
665        let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
666        assert_eq!(n, Some(20));
667        assert_eq!(path, Some("file.rs"));
668    }
669
670    #[test]
671    fn parse_head_tail_args_short_flag() {
672        let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
673        assert_eq!(n, Some(50));
674        assert_eq!(path, Some("file.rs"));
675    }
676
677    #[test]
678    fn should_passthrough_rules_files() {
679        assert!(should_passthrough("/home/user/.cursorrules"));
680        assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
681        assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
682        assert!(should_passthrough("/project/SKILL.md"));
683        assert!(should_passthrough("/project/AGENTS.md"));
684        assert!(should_passthrough("/project/icon.png"));
685        assert!(!should_passthrough("/project/src/main.rs"));
686        assert!(!should_passthrough("/project/src/lib.ts"));
687    }
688
689    #[test]
690    fn wrap_single() {
691        let r = wrap_single_command("git status", "lean-ctx");
692        assert_eq!(r, "lean-ctx -c 'git status'");
693    }
694
695    #[test]
696    fn wrap_with_quotes() {
697        let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
698        assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
699    }
700
701    #[test]
702    fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
703        assert_eq!(
704            rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
705            None
706        );
707    }
708
709    #[test]
710    fn rewrite_candidate_wraps_single_command() {
711        assert_eq!(
712            rewrite_candidate("git status", "lean-ctx"),
713            Some("lean-ctx -c 'git status'".to_string())
714        );
715    }
716
717    #[test]
718    fn rewrite_candidate_passes_through_heredoc() {
719        assert_eq!(
720            rewrite_candidate(
721                "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
722                "lean-ctx"
723            ),
724            None
725        );
726    }
727
728    #[test]
729    fn rewrite_candidate_passes_through_heredoc_compound() {
730        assert_eq!(
731            rewrite_candidate(
732                "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
733                "lean-ctx"
734            ),
735            None
736        );
737    }
738
739    #[test]
740    fn codex_reroute_message_includes_exact_rewritten_command() {
741        let message = codex_reroute_message("lean-ctx -c 'git status'");
742        assert_eq!(
743            message,
744            "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
745        );
746    }
747
748    #[test]
749    fn compound_rewrite_and_chain() {
750        let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
751        assert_eq!(
752            result,
753            Some("cd src && lean-ctx -c 'git status' && echo done".into())
754        );
755    }
756
757    #[test]
758    fn compound_rewrite_pipe() {
759        let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
760        assert_eq!(
761            result,
762            Some("lean-ctx -c 'git log --oneline' | head -5".into())
763        );
764    }
765
766    #[test]
767    fn compound_rewrite_no_match() {
768        let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
769        assert_eq!(result, None);
770    }
771
772    #[test]
773    fn compound_rewrite_multiple_rewritable() {
774        let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
775        assert_eq!(
776            result,
777            Some(
778                "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
779                    .into()
780            )
781        );
782    }
783
784    #[test]
785    fn compound_rewrite_semicolons() {
786        let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
787        assert_eq!(
788            result,
789            Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
790        );
791    }
792
793    #[test]
794    fn compound_rewrite_or_chain() {
795        let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
796        assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
797    }
798
799    #[test]
800    fn compound_skips_already_rewritten() {
801        let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
802        assert_eq!(
803            result,
804            Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
805        );
806    }
807
808    #[test]
809    fn single_command_not_compound() {
810        let result = build_rewrite_compound("git status", "lean-ctx");
811        assert_eq!(result, None);
812    }
813
814    #[test]
815    fn extract_field_works() {
816        let input = r#"{"tool_name":"Bash","command":"git status"}"#;
817        assert_eq!(
818            extract_json_field(input, "tool_name"),
819            Some("Bash".to_string())
820        );
821        assert_eq!(
822            extract_json_field(input, "command"),
823            Some("git status".to_string())
824        );
825    }
826
827    #[test]
828    fn extract_field_with_spaces_after_colon() {
829        let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
830        assert_eq!(
831            extract_json_field(input, "tool_name"),
832            Some("Bash".to_string())
833        );
834        assert_eq!(
835            extract_json_field(input, "command"),
836            Some("git status".to_string())
837        );
838    }
839
840    #[test]
841    fn extract_field_pretty_printed() {
842        let input = "{\n  \"tool_name\": \"Bash\",\n  \"tool_input\": {\n    \"command\": \"npm test\"\n  }\n}";
843        assert_eq!(
844            extract_json_field(input, "tool_name"),
845            Some("Bash".to_string())
846        );
847        assert_eq!(
848            extract_json_field(input, "command"),
849            Some("npm test".to_string())
850        );
851    }
852
853    #[test]
854    fn extract_field_handles_escaped_quotes() {
855        let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
856        assert_eq!(
857            extract_json_field(input, "command"),
858            Some(r#"grep -r "TODO" src/"#.to_string())
859        );
860    }
861
862    #[test]
863    fn extract_field_handles_escaped_backslash() {
864        let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
865        assert_eq!(
866            extract_json_field(input, "command"),
867            Some(r#"echo \"hello\""#.to_string())
868        );
869    }
870
871    #[test]
872    fn extract_field_handles_complex_curl() {
873        let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
874        assert_eq!(
875            extract_json_field(input, "command"),
876            Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
877        );
878    }
879
880    #[test]
881    fn to_bash_compatible_path_windows_drive() {
882        let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
883        assert_eq!(p, "/e/packages/lean-ctx.exe");
884    }
885
886    #[test]
887    fn to_bash_compatible_path_backslashes() {
888        let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
889        assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
890    }
891
892    #[test]
893    fn to_bash_compatible_path_unix_unchanged() {
894        let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
895        assert_eq!(p, "/usr/local/bin/lean-ctx");
896    }
897
898    #[test]
899    fn to_bash_compatible_path_msys2_unchanged() {
900        let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
901        assert_eq!(p, "/e/packages/lean-ctx.exe");
902    }
903
904    #[test]
905    fn wrap_command_with_bash_path() {
906        let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
907        let result = wrap_single_command("git status", &binary);
908        assert!(
909            !result.contains('\\'),
910            "wrapped command must not contain backslashes, got: {result}"
911        );
912        assert!(
913            result.starts_with("/e/packages/lean-ctx.exe"),
914            "must use bash-compatible path, got: {result}"
915        );
916    }
917
918    #[test]
919    fn wrap_single_command_em_dash() {
920        let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
921        assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
922    }
923
924    #[test]
925    fn wrap_single_command_dollar_sign() {
926        let r = wrap_single_command("echo $HOME", "lean-ctx");
927        assert_eq!(r, "lean-ctx -c 'echo $HOME'");
928    }
929
930    #[test]
931    fn wrap_single_command_backticks() {
932        let r = wrap_single_command("echo `date`", "lean-ctx");
933        assert_eq!(r, "lean-ctx -c 'echo `date`'");
934    }
935
936    #[test]
937    fn wrap_single_command_nested_single_quotes() {
938        let r = wrap_single_command("echo 'hello world'", "lean-ctx");
939        assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
940    }
941
942    #[test]
943    fn wrap_single_command_exclamation_mark() {
944        let r = wrap_single_command("echo hello!", "lean-ctx");
945        assert_eq!(r, "lean-ctx -c 'echo hello!'");
946    }
947
948    #[test]
949    fn wrap_single_command_find_with_many_excludes() {
950        let r = wrap_single_command(
951            "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
952            "lean-ctx",
953        );
954        assert_eq!(
955            r,
956            "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
957        );
958    }
959}