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
9// ---------------------------------------------------------------------------
10// Observe handler — records ALL hook events for context awareness
11// ---------------------------------------------------------------------------
12
13/// Unified observe handler for all IDE hook events.
14/// Reads JSON from stdin, normalizes to `ObserveEvent`, counts tokens,
15/// appends to `context_radar.jsonl`, and exits immediately.
16pub fn handle_observe() {
17    if is_disabled() {
18        return;
19    }
20    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
21        return;
22    };
23    let Some(event) = parse_observe_event(&input) else {
24        return;
25    };
26    append_radar_event(&event);
27}
28
29#[derive(serde::Serialize)]
30struct ObserveEvent {
31    ts: u64,
32    event_type: &'static str,
33    tokens: usize,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    tool_name: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    detail: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    content: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    model: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    conversation_id: Option<String>,
44}
45
46const MAX_CONTENT_CHARS: usize = 50_000;
47
48fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
49    let v: serde_json::Value = serde_json::from_str(input).ok()?;
50
51    let ts = std::time::SystemTime::now()
52        .duration_since(std::time::UNIX_EPOCH)
53        .unwrap_or_default()
54        .as_secs();
55
56    let model = v
57        .get("model")
58        .and_then(|m| m.as_str())
59        .filter(|m| !m.is_empty())
60        .map(String::from);
61    let conversation_id = v
62        .get("conversation_id")
63        .and_then(|c| c.as_str())
64        .filter(|c| !c.is_empty())
65        .map(String::from);
66
67    let transcript_path = v
68        .get("transcript_path")
69        .and_then(|t| t.as_str())
70        .filter(|t| !t.is_empty())
71        .map(String::from);
72
73    if let Some(ref m) = model {
74        persist_detected_model(m);
75    }
76    if let Some(ref tp) = transcript_path {
77        persist_transcript_path(tp, conversation_id.as_deref());
78    }
79
80    let mut event = detect_event_type(&v, ts)?;
81    event.model = model;
82    event.conversation_id = conversation_id;
83    Some(event)
84}
85
86fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
87    if let Some(result) = v.get("result_json").or_else(|| v.get("result")) {
88        let tool = v
89            .get("tool_name")
90            .and_then(|t| t.as_str())
91            .unwrap_or("unknown");
92        let tokens = estimate_tokens_json(result);
93        let content_str = match result {
94            serde_json::Value::String(s) => s.clone(),
95            other => other.to_string(),
96        };
97        return Some(ObserveEvent {
98            ts,
99            event_type: "mcp_call",
100            tokens,
101            tool_name: Some(tool.to_string()),
102            detail: v
103                .get("server_name")
104                .and_then(|s| s.as_str())
105                .map(String::from),
106            content: Some(cap_content(&content_str)),
107            model: None,
108            conversation_id: None,
109        });
110    }
111
112    if let Some(output) = v.get("output") {
113        let cmd = v
114            .get("command")
115            .and_then(|c| c.as_str())
116            .unwrap_or("")
117            .to_string();
118        let tokens = estimate_tokens_value(output);
119        let out_str = match output {
120            serde_json::Value::String(s) => s.clone(),
121            other => other.to_string(),
122        };
123        return Some(ObserveEvent {
124            ts,
125            event_type: "shell",
126            tokens,
127            tool_name: None,
128            detail: Some(truncate_str(&cmd, 80)),
129            content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
130            model: None,
131            conversation_id: None,
132        });
133    }
134
135    if v.get("content").is_some() && v.get("file_path").is_some() {
136        let path = v
137            .get("file_path")
138            .and_then(|p| p.as_str())
139            .unwrap_or("")
140            .to_string();
141        let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
142        let tokens = file_content.len() / 4;
143        return Some(ObserveEvent {
144            ts,
145            event_type: "file_read",
146            tokens,
147            tool_name: None,
148            detail: Some(truncate_str(&path, 120)),
149            content: Some(cap_content(file_content)),
150            model: None,
151            conversation_id: None,
152        });
153    }
154
155    if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
156        let has_duration = v.get("duration_ms").is_some();
157        let event_type = if has_duration {
158            "thinking"
159        } else {
160            "agent_response"
161        };
162        let tokens = text.len() / 4;
163        return Some(ObserveEvent {
164            ts,
165            event_type,
166            tokens,
167            tool_name: None,
168            detail: None,
169            content: Some(cap_content(text)),
170            model: None,
171            conversation_id: None,
172        });
173    }
174
175    if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
176        let tokens = prompt.len() / 4;
177        let mut full = prompt.to_string();
178        if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array()) {
179            if !attachments.is_empty() {
180                full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
181                for att in attachments {
182                    if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
183                        full.push_str(&format!("\n  - {name}"));
184                    }
185                }
186            }
187        }
188        return Some(ObserveEvent {
189            ts,
190            event_type: "user_message",
191            tokens,
192            tool_name: None,
193            detail: v
194                .get("attachments")
195                .and_then(|a| a.as_array())
196                .map(|a| format!("{} attachments", a.len())),
197            content: Some(cap_content(&full)),
198            model: None,
199            conversation_id: None,
200        });
201    }
202
203    if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
204        let tool = v
205            .get("tool_name")
206            .and_then(|t| t.as_str())
207            .unwrap_or("unknown")
208            .to_string();
209        let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
210        let input_str = v
211            .get("tool_input")
212            .map(std::string::ToString::to_string)
213            .unwrap_or_default();
214        return Some(ObserveEvent {
215            ts,
216            event_type: "native_tool",
217            tokens,
218            tool_name: Some(tool),
219            detail: None,
220            content: if input_str.is_empty() {
221                None
222            } else {
223                Some(cap_content(&input_str))
224            },
225            model: None,
226            conversation_id: None,
227        });
228    }
229
230    if v.get("session_id").is_some() {
231        return Some(ObserveEvent {
232            ts,
233            event_type: "session",
234            tokens: 0,
235            tool_name: None,
236            detail: v
237                .get("session_id")
238                .and_then(|s| s.as_str())
239                .map(String::from),
240            content: None,
241            model: None,
242            conversation_id: None,
243        });
244    }
245
246    let is_compaction = v.get("compaction").is_some()
247        || v.get("messages_count").is_some()
248        || v.get("event")
249            .and_then(|e| e.as_str())
250            .is_some_and(|e| e == "compaction" || e == "compact");
251    if is_compaction {
252        return Some(ObserveEvent {
253            ts,
254            event_type: "compaction",
255            tokens: 0,
256            tool_name: None,
257            detail: None,
258            content: None,
259            model: None,
260            conversation_id: None,
261        });
262    }
263
264    None
265}
266
267fn estimate_tokens_json(v: &serde_json::Value) -> usize {
268    match v {
269        serde_json::Value::String(s) => s.len() / 4,
270        _ => v.to_string().len() / 4,
271    }
272}
273
274fn estimate_tokens_value(v: &serde_json::Value) -> usize {
275    match v {
276        serde_json::Value::String(s) => s.len() / 4,
277        _ => v.to_string().len() / 4,
278    }
279}
280
281fn persist_detected_model(model: &str) {
282    let m = model.to_lowercase();
283    let is_bg_model = m.contains("flash")
284        || m.contains("mini")
285        || m.contains("haiku")
286        || m.contains("fast")
287        || m.contains("nano")
288        || m.contains("small");
289    if is_bg_model {
290        return;
291    }
292
293    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
294        return;
295    };
296    let path = data_dir.join("detected_model.json");
297    let ts = std::time::SystemTime::now()
298        .duration_since(std::time::UNIX_EPOCH)
299        .unwrap_or_default()
300        .as_secs();
301    let window = model_context_window(model);
302    let payload = serde_json::json!({
303        "model": model,
304        "window_size": window,
305        "detected_at": ts,
306    });
307    if let Ok(json) = serde_json::to_string_pretty(&payload) {
308        let tmp = path.with_extension("tmp");
309        if std::fs::write(&tmp, &json).is_ok() {
310            let _ = std::fs::rename(&tmp, &path);
311        }
312    }
313}
314
315pub fn model_context_window(model: &str) -> usize {
316    crate::core::model_registry::context_window_for_model(model)
317}
318
319pub fn load_detected_model() -> Option<(String, usize)> {
320    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
321    let path = data_dir.join("detected_model.json");
322    let content = std::fs::read_to_string(&path).ok()?;
323    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
324    let model = v.get("model")?.as_str()?.to_string();
325    let window = v.get("window_size")?.as_u64()? as usize;
326    let detected_at = v.get("detected_at")?.as_u64()?;
327    let now = std::time::SystemTime::now()
328        .duration_since(std::time::UNIX_EPOCH)
329        .unwrap_or_default()
330        .as_secs();
331    if now.saturating_sub(detected_at) > 7200 {
332        return None;
333    }
334    Some((model, window))
335}
336
337fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
338    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
339        return;
340    };
341    let meta_path = data_dir.join("active_transcript.json");
342    let ts = std::time::SystemTime::now()
343        .duration_since(std::time::UNIX_EPOCH)
344        .unwrap_or_default()
345        .as_secs();
346    let payload = serde_json::json!({
347        "transcript_path": path,
348        "conversation_id": conversation_id,
349        "updated_at": ts,
350    });
351    if let Ok(json) = serde_json::to_string_pretty(&payload) {
352        let tmp = meta_path.with_extension("tmp");
353        if std::fs::write(&tmp, &json).is_ok() {
354            let _ = std::fs::rename(&tmp, &meta_path);
355        }
356    }
357}
358
359pub fn load_active_transcript() -> Option<(String, Option<String>)> {
360    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
361    let path = data_dir.join("active_transcript.json");
362    let content = std::fs::read_to_string(&path).ok()?;
363    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
364    let tp = v.get("transcript_path")?.as_str()?.to_string();
365    let conv = v
366        .get("conversation_id")
367        .and_then(|c| c.as_str())
368        .map(String::from);
369    let updated = v.get("updated_at")?.as_u64()?;
370    let now = std::time::SystemTime::now()
371        .duration_since(std::time::UNIX_EPOCH)
372        .unwrap_or_default()
373        .as_secs();
374    if now.saturating_sub(updated) > 7200 {
375        return None;
376    }
377    Some((tp, conv))
378}
379
380fn cap_content(s: &str) -> String {
381    if s.len() <= MAX_CONTENT_CHARS {
382        s.to_string()
383    } else {
384        let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
385        format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
386    }
387}
388
389fn truncate_str(s: &str, max: usize) -> String {
390    if s.len() <= max {
391        s.to_string()
392    } else {
393        format!("{}...", safe_truncate(s, max))
394    }
395}
396
397/// Truncate a string at a char boundary <= max bytes. Never panics on multi-byte UTF-8.
398fn safe_truncate(s: &str, max: usize) -> &str {
399    if max >= s.len() {
400        return s;
401    }
402    let mut end = max;
403    while end > 0 && !s.is_char_boundary(end) {
404        end -= 1;
405    }
406    &s[..end]
407}
408
409fn append_radar_event(event: &ObserveEvent) {
410    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
411        return;
412    };
413    let radar_path = data_dir.join("context_radar.jsonl");
414
415    if event.event_type == "session" {
416        if let Ok(meta) = std::fs::metadata(&radar_path) {
417            const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
418            if meta.len() > MAX_RADAR_SIZE {
419                let prev = data_dir.join("context_radar.prev.jsonl");
420                let _ = std::fs::rename(&radar_path, &prev);
421            }
422        }
423    }
424
425    let Ok(line) = serde_json::to_string(event) else {
426        return;
427    };
428
429    use std::fs::OpenOptions;
430    use std::io::Write;
431    if let Ok(mut f) = OpenOptions::new()
432        .create(true)
433        .append(true)
434        .open(&radar_path)
435    {
436        let _ = writeln!(f, "{line}");
437    }
438}
439
440fn is_disabled() -> bool {
441    std::env::var("LEAN_CTX_DISABLED").is_ok()
442}
443
444fn is_harden_active() -> bool {
445    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
446}
447
448fn is_quiet() -> bool {
449    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
450}
451
452/// Mark this process as a hook child so the daemon-client never auto-starts
453/// the daemon from inside a hook (which would create zombie processes).
454pub fn mark_hook_environment() {
455    std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
456}
457
458/// Arms a watchdog that force-exits the process after the given duration.
459/// Prevents hook processes from becoming zombies when stdin pipes break or
460/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
461/// (to avoid orphan zombies), a simple exit(1) suffices.
462pub fn arm_watchdog(timeout: Duration) {
463    std::thread::spawn(move || {
464        std::thread::sleep(timeout);
465        eprintln!(
466            "[lean-ctx hook] watchdog timeout after {}s — force exit",
467            timeout.as_secs()
468        );
469        std::process::exit(1);
470    });
471}
472
473/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
474fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
475    let (tx, rx) = mpsc::channel();
476    std::thread::spawn(move || {
477        let mut buf = String::new();
478        let result = std::io::stdin().read_to_string(&mut buf);
479        let _ = tx.send(result.ok().map(|_| buf));
480    });
481    match rx.recv_timeout(timeout) {
482        Ok(Some(s)) if !s.is_empty() => Some(s),
483        _ => None,
484    }
485}
486
487fn build_dual_deny_output(reason: &str) -> String {
488    serde_json::json!({
489        "permission": "deny",
490        "reason": reason,
491        "hookSpecificOutput": {
492            "hookEventName": "PreToolUse",
493            "permissionDecision": "deny",
494        }
495    })
496    .to_string()
497}
498
499fn build_dual_allow_output() -> String {
500    serde_json::json!({
501        "permission": "allow",
502        "hookSpecificOutput": {
503            "hookEventName": "PreToolUse",
504            "permissionDecision": "allow"
505        }
506    })
507    .to_string()
508}
509
510fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
511    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
512        let mut m = obj.clone();
513        m.insert(
514            "command".to_string(),
515            serde_json::Value::String(rewritten.to_string()),
516        );
517        serde_json::Value::Object(m)
518    } else {
519        serde_json::json!({ "command": rewritten })
520    };
521
522    serde_json::json!({
523        // Cursor hook output format
524        "permission": "allow",
525        "updated_input": updated_input,
526        // Claude Code hook output format (extra fields are ignored by other hosts)
527        "hookSpecificOutput": {
528            "hookEventName": "PreToolUse",
529            "permissionDecision": "allow",
530            "updatedInput": {
531                "command": rewritten
532            }
533        }
534    })
535    .to_string()
536}
537
538pub fn handle_rewrite() {
539    if is_disabled() {
540        return;
541    }
542    let binary = resolve_binary();
543    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
544        return;
545    };
546
547    let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
548        v
549    } else {
550        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
551        return;
552    };
553
554    let tool = v.get("tool_name").and_then(|t| t.as_str());
555    let Some(tool_name) = tool else {
556        return;
557    };
558
559    // Claude Code uses Bash; Cursor uses Shell; Copilot uses runInTerminal.
560    let is_shell_tool = matches!(
561        tool_name,
562        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
563    );
564    if !is_shell_tool {
565        return;
566    }
567
568    let tool_input = v.get("tool_input");
569    let Some(cmd) = tool_input
570        .and_then(|ti| ti.get("command"))
571        .and_then(|c| c.as_str())
572        .or_else(|| v.get("command").and_then(|c| c.as_str()))
573    else {
574        return;
575    };
576
577    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
578        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
579    } else {
580        // Always return a valid allow JSON for hosts that require JSON on exit 0.
581        print!("{}", build_dual_allow_output());
582    }
583}
584
585fn is_rewritable(cmd: &str) -> bool {
586    rewrite_registry::is_rewritable_command(cmd)
587}
588
589fn wrap_single_command(cmd: &str, binary: &str) -> String {
590    if cfg!(windows) {
591        let escaped = cmd.replace('"', "\\\"");
592        format!("{binary} -c \"{escaped}\"")
593    } else {
594        let shell_escaped = cmd.replace('\'', "'\\''");
595        format!("{binary} -c '{shell_escaped}'")
596    }
597}
598
599fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
600    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
601        return None;
602    }
603
604    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
605    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
606    if cmd.contains("<<") {
607        return None;
608    }
609
610    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
611        return Some(rewritten);
612    }
613
614    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
615        return Some(rewritten);
616    }
617
618    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
619        return Some(rewritten);
620    }
621
622    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
623        return Some(rewritten);
624    }
625
626    if is_rewritable(cmd) {
627        return Some(wrap_single_command(cmd, binary));
628    }
629
630    None
631}
632
633/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
634fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
635    if !rewrite_registry::is_file_read_command(cmd) {
636        return None;
637    }
638
639    let parts = shell_tokenize(cmd);
640    if parts.len() < 2 {
641        return None;
642    }
643
644    match parts[0].as_str() {
645        "cat" => {
646            let path = parts[1..].join(" ");
647            Some(format!("{binary} read {}", shell_quote(&path)))
648        }
649        "head" => {
650            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
651            let (n, path) = parse_head_tail_args(&refs);
652            let path = path?;
653            let qp = shell_quote(path);
654            match n {
655                Some(lines) => Some(format!("{binary} read {qp} -m lines:1-{lines}")),
656                None => Some(format!("{binary} read {qp} -m lines:1-10")),
657            }
658        }
659        "tail" => {
660            let refs: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
661            let (n, path) = parse_head_tail_args(&refs);
662            let path = path?;
663            let qp = shell_quote(path);
664            let lines = n.unwrap_or(10);
665            Some(format!("{binary} read {qp} -m lines:-{lines}"))
666        }
667        _ => None,
668    }
669}
670
671/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
672fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
673    let parts = shell_tokenize(cmd);
674    if parts.first().map(String::as_str) != Some("rg") {
675        return None;
676    }
677    if parts.len() < 2 || parts.len() > 3 {
678        return None;
679    }
680    if parts[1].starts_with('-') {
681        return None;
682    }
683    let pattern = &parts[1];
684    match parts.get(2) {
685        Some(p) if p.starts_with('-') => None,
686        Some(p) => Some(format!("{binary} grep {pattern} {}", shell_quote(p))),
687        None => Some(format!("{binary} grep {pattern}")),
688    }
689}
690
691/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
692fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
693    let parts = shell_tokenize(cmd);
694    if parts.first().map(String::as_str) != Some("ls") {
695        return None;
696    }
697    match parts.len() {
698        1 => Some(format!("{binary} ls")),
699        2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", shell_quote(&parts[1]))),
700        _ => None,
701    }
702}
703
704/// Tokenize a shell command respecting single/double quotes and backslash escapes.
705pub fn shell_tokenize(input: &str) -> Vec<String> {
706    let mut tokens = Vec::new();
707    let mut current = String::new();
708    let mut chars = input.chars().peekable();
709    let mut in_single = false;
710    let mut in_double = false;
711
712    while let Some(c) = chars.next() {
713        match c {
714            '\'' if !in_double => in_single = !in_single,
715            '"' if !in_single => in_double = !in_double,
716            '\\' if !in_single => {
717                if let Some(next) = chars.next() {
718                    current.push(next);
719                }
720            }
721            c if c.is_whitespace() && !in_single && !in_double => {
722                if !current.is_empty() {
723                    tokens.push(std::mem::take(&mut current));
724                }
725            }
726            _ => current.push(c),
727        }
728    }
729    if !current.is_empty() {
730        tokens.push(current);
731    }
732    tokens
733}
734
735/// Quote a path/arg for shell if it contains spaces or special chars.
736pub fn shell_quote(s: &str) -> String {
737    if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
738        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
739    } else {
740        s.to_string()
741    }
742}
743
744fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
745    let mut n: Option<usize> = None;
746    let mut path: Option<&str> = None;
747
748    let mut i = 0;
749    while i < args.len() {
750        if args[i] == "-n" && i + 1 < args.len() {
751            n = args[i + 1].parse().ok();
752            i += 2;
753        } else if let Some(num) = args[i].strip_prefix("-n") {
754            n = num.parse().ok();
755            i += 1;
756        } else if args[i].starts_with('-') && args[i].len() > 1 {
757            if let Ok(num) = args[i][1..].parse::<usize>() {
758                n = Some(num);
759            }
760            i += 1;
761        } else {
762            path = Some(args[i]);
763            i += 1;
764        }
765    }
766
767    (n, path)
768}
769
770fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
771    compound_lexer::rewrite_compound(cmd, |segment| {
772        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
773            return None;
774        }
775        if is_rewritable(segment) {
776            Some(wrap_single_command(segment, binary))
777        } else {
778            None
779        }
780    })
781}
782
783fn emit_rewrite(rewritten: &str) {
784    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
785    print!(
786        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
787    );
788}
789
790pub fn handle_redirect() {
791    if is_disabled() {
792        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
793        print!("{}", build_dual_allow_output());
794        return;
795    }
796
797    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
798        return;
799    };
800
801    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
802        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
803        return;
804    };
805
806    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
807    let tool_input = v.get("tool_input");
808
809    match tool_name {
810        "Read" | "read" | "read_file" => redirect_read(tool_input),
811        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
812        _ => print!("{}", build_dual_allow_output()),
813    }
814}
815
816/// Redirect Read through lean-ctx for compression + caching.
817/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
818/// prevents daemon auto-start. The subprocess uses the fast local-only path.
819fn redirect_read(tool_input: Option<&serde_json::Value>) {
820    let path = tool_input
821        .and_then(|ti| ti.get("path"))
822        .and_then(|p| p.as_str())
823        .unwrap_or("");
824
825    if path.is_empty() || should_passthrough(path) {
826        print!("{}", build_dual_allow_output());
827        return;
828    }
829
830    if is_harden_active() {
831        print!(
832            "{}",
833            build_dual_deny_output(
834                "Use ctx_read instead of native Read. lean-ctx harden mode is active."
835            )
836        );
837        return;
838    }
839
840    let binary = resolve_binary();
841    let temp_path = redirect_temp_path(path);
842
843    if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
844        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
845            let temp_str = temp_path.to_str().unwrap_or("");
846            print!("{}", build_redirect_output(tool_input, "path", temp_str));
847            return;
848        }
849    }
850
851    print!("{}", build_dual_allow_output());
852}
853
854/// Redirect Grep through lean-ctx for compressed results.
855fn redirect_grep(tool_input: Option<&serde_json::Value>) {
856    let pattern = tool_input
857        .and_then(|ti| ti.get("pattern"))
858        .and_then(|p| p.as_str())
859        .unwrap_or("");
860    let search_path = tool_input
861        .and_then(|ti| ti.get("path"))
862        .and_then(|p| p.as_str())
863        .unwrap_or(".");
864
865    if pattern.is_empty() {
866        print!("{}", build_dual_allow_output());
867        return;
868    }
869
870    if is_harden_active() {
871        print!(
872            "{}",
873            build_dual_deny_output(
874                "Use ctx_search instead of native Grep. lean-ctx harden mode is active."
875            )
876        );
877        return;
878    }
879
880    let binary = resolve_binary();
881    let key = format!("grep:{pattern}:{search_path}");
882    let temp_path = redirect_temp_path(&key);
883
884    if let Some(output) = run_with_timeout(
885        &binary,
886        &["grep", pattern, search_path],
887        REDIRECT_SUBPROCESS_TIMEOUT,
888    ) {
889        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
890            let temp_str = temp_path.to_str().unwrap_or("");
891            print!("{}", build_redirect_output(tool_input, "path", temp_str));
892            return;
893        }
894    }
895
896    print!("{}", build_dual_allow_output());
897}
898
899const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
900
901/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
902/// Kills the child if it exceeds the timeout to prevent orphan processes.
903fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
904    let mut child = std::process::Command::new(binary)
905        .args(args)
906        .stdout(std::process::Stdio::piped())
907        .stderr(std::process::Stdio::null())
908        .spawn()
909        .ok()?;
910
911    let deadline = std::time::Instant::now() + timeout;
912    loop {
913        match child.try_wait() {
914            Ok(Some(status)) if status.success() => {
915                let mut stdout = Vec::new();
916                if let Some(mut out) = child.stdout.take() {
917                    let _ = out.read_to_end(&mut stdout);
918                }
919                return if stdout.is_empty() {
920                    None
921                } else {
922                    Some(stdout)
923                };
924            }
925            Ok(Some(_)) | Err(_) => return None,
926            Ok(None) => {
927                if std::time::Instant::now() > deadline {
928                    let _ = child.kill();
929                    let _ = child.wait();
930                    return None;
931                }
932                std::thread::sleep(Duration::from_millis(10));
933            }
934        }
935    }
936}
937
938fn redirect_temp_path(key: &str) -> std::path::PathBuf {
939    use std::collections::hash_map::DefaultHasher;
940    use std::hash::{Hash, Hasher};
941
942    let mut hasher = DefaultHasher::new();
943    key.hash(&mut hasher);
944    std::process::id().hash(&mut hasher);
945    let hash = hasher.finish();
946
947    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
948    let _ = std::fs::create_dir_all(&temp_dir);
949    #[cfg(unix)]
950    {
951        use std::os::unix::fs::PermissionsExt;
952        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
953    }
954    temp_dir.join(format!("{hash:016x}.lctx"))
955}
956
957fn build_redirect_output(
958    tool_input: Option<&serde_json::Value>,
959    field: &str,
960    temp_path: &str,
961) -> String {
962    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
963        let mut m = obj.clone();
964        m.insert(
965            field.to_string(),
966            serde_json::Value::String(temp_path.to_string()),
967        );
968        serde_json::Value::Object(m)
969    } else {
970        serde_json::json!({ field: temp_path })
971    };
972
973    serde_json::json!({
974        "permission": "allow",
975        "updated_input": updated_input,
976        "hookSpecificOutput": {
977            "hookEventName": "PreToolUse",
978            "permissionDecision": "allow",
979            "updatedInput": { field: temp_path }
980        }
981    })
982    .to_string()
983}
984
985const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
986    ".cursorrules",
987    ".cursor/rules",
988    ".cursor/hooks",
989    "skill.md",
990    "agents.md",
991    ".env",
992    "hooks.json",
993    "node_modules",
994];
995
996const PASSTHROUGH_EXTENSIONS: &[&str] = &[
997    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
998];
999
1000fn should_passthrough(path: &str) -> bool {
1001    let p = path.to_lowercase();
1002
1003    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
1004        return true;
1005    }
1006
1007    std::path::Path::new(&p)
1008        .extension()
1009        .and_then(|ext| ext.to_str())
1010        .is_some_and(|ext| {
1011            PASSTHROUGH_EXTENSIONS
1012                .iter()
1013                .any(|e| ext.eq_ignore_ascii_case(e))
1014        })
1015}
1016
1017fn codex_reroute_message(rewritten: &str) -> String {
1018    format!(
1019        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
1020    )
1021}
1022
1023pub fn handle_codex_pretooluse() {
1024    if is_disabled() {
1025        return;
1026    }
1027    let binary = resolve_binary();
1028    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1029        return;
1030    };
1031
1032    let tool = extract_json_field(&input, "tool_name");
1033    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1034        return;
1035    }
1036
1037    let Some(cmd) = extract_json_field(&input, "command") else {
1038        return;
1039    };
1040
1041    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1042        if is_quiet() {
1043            eprintln!("Re-run: {rewritten}");
1044        } else {
1045            eprintln!("{}", codex_reroute_message(&rewritten));
1046        }
1047        std::process::exit(2);
1048    }
1049}
1050
1051pub fn handle_codex_session_start() {
1052    if is_quiet() {
1053        return;
1054    }
1055    println!(
1056        "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."
1057    );
1058}
1059
1060/// Copilot-specific PreToolUse handler.
1061/// VS Code Copilot Chat uses the same hook format as Claude Code.
1062/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
1063pub fn handle_copilot() {
1064    if is_disabled() {
1065        return;
1066    }
1067    let binary = resolve_binary();
1068    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1069        return;
1070    };
1071
1072    let tool = extract_json_field(&input, "tool_name");
1073    let Some(tool_name) = tool.as_deref() else {
1074        return;
1075    };
1076
1077    let is_shell_tool = matches!(
1078        tool_name,
1079        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
1080    );
1081    if !is_shell_tool {
1082        return;
1083    }
1084
1085    let Some(cmd) = extract_json_field(&input, "command") else {
1086        return;
1087    };
1088
1089    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1090        emit_rewrite(&rewritten);
1091    }
1092}
1093
1094/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
1095/// Used by the OpenCode TS plugin where the command is passed as an argument,
1096/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
1097/// shell may be PowerShell or cmd on Windows.
1098pub fn handle_rewrite_inline() {
1099    if is_disabled() {
1100        return;
1101    }
1102    let binary = resolve_binary_native();
1103    let args: Vec<String> = std::env::args().collect();
1104    // args: [binary, "hook", "rewrite-inline", ...command parts]
1105    if args.len() < 4 {
1106        return;
1107    }
1108    let cmd = args[3..].join(" ");
1109
1110    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1111        print!("{rewritten}");
1112        return;
1113    }
1114
1115    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1116        print!("{cmd}");
1117        return;
1118    }
1119
1120    print!("{cmd}");
1121}
1122
1123fn resolve_binary() -> String {
1124    let path = crate::core::portable_binary::resolve_portable_binary();
1125    crate::hooks::to_bash_compatible_path(&path)
1126}
1127
1128fn resolve_binary_native() -> String {
1129    crate::core::portable_binary::resolve_portable_binary()
1130}
1131
1132fn extract_json_field(input: &str, field: &str) -> Option<String> {
1133    let key = format!("\"{field}\":");
1134    let key_pos = input.find(&key)?;
1135    let after_colon = &input[key_pos + key.len()..];
1136    let trimmed = after_colon.trim_start();
1137    if !trimmed.starts_with('"') {
1138        return None;
1139    }
1140    let rest = &trimmed[1..];
1141    let bytes = rest.as_bytes();
1142    let mut end = 0;
1143    while end < bytes.len() {
1144        if bytes[end] == b'\\' && end + 1 < bytes.len() {
1145            end += 2;
1146            continue;
1147        }
1148        if bytes[end] == b'"' {
1149            break;
1150        }
1151        end += 1;
1152    }
1153    if end >= bytes.len() {
1154        return None;
1155    }
1156    let raw = &rest[..end];
1157    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    fn expect_wrapped(cmd: &str, binary: &str) -> String {
1165        if cfg!(windows) {
1166            let escaped = cmd.replace('"', "\\\"");
1167            format!("{binary} -c \"{escaped}\"")
1168        } else {
1169            let shell_escaped = cmd.replace('\'', "'\\''");
1170            format!("{binary} -c '{shell_escaped}'")
1171        }
1172    }
1173
1174    #[test]
1175    fn is_rewritable_basic() {
1176        assert!(is_rewritable("git status"));
1177        assert!(is_rewritable("cargo test --lib"));
1178        assert!(is_rewritable("npm run build"));
1179        assert!(!is_rewritable("echo hello"));
1180        assert!(!is_rewritable("cd src"));
1181        assert!(!is_rewritable("cat file.rs"));
1182    }
1183
1184    #[test]
1185    fn file_read_rewrite_cat() {
1186        let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
1187        assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
1188    }
1189
1190    #[test]
1191    fn file_read_rewrite_head_with_n() {
1192        let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
1193        assert_eq!(
1194            r,
1195            Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
1196        );
1197    }
1198
1199    #[test]
1200    fn file_read_rewrite_head_short() {
1201        let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
1202        assert_eq!(
1203            r,
1204            Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
1205        );
1206    }
1207
1208    #[test]
1209    fn file_read_rewrite_tail() {
1210        let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
1211        assert_eq!(
1212            r,
1213            Some("lean-ctx read src/main.rs -m lines:-10".to_string())
1214        );
1215    }
1216
1217    #[test]
1218    fn file_read_rewrite_not_git() {
1219        assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
1220    }
1221
1222    #[test]
1223    fn parse_head_tail_args_basic() {
1224        let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
1225        assert_eq!(n, Some(20));
1226        assert_eq!(path, Some("file.rs"));
1227    }
1228
1229    #[test]
1230    fn parse_head_tail_args_combined() {
1231        let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
1232        assert_eq!(n, Some(20));
1233        assert_eq!(path, Some("file.rs"));
1234    }
1235
1236    #[test]
1237    fn parse_head_tail_args_short_flag() {
1238        let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
1239        assert_eq!(n, Some(50));
1240        assert_eq!(path, Some("file.rs"));
1241    }
1242
1243    #[test]
1244    fn should_passthrough_rules_files() {
1245        assert!(should_passthrough("/home/user/.cursorrules"));
1246        assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
1247        assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
1248        assert!(should_passthrough("/project/SKILL.md"));
1249        assert!(should_passthrough("/project/AGENTS.md"));
1250        assert!(should_passthrough("/project/icon.png"));
1251        assert!(!should_passthrough("/project/src/main.rs"));
1252        assert!(!should_passthrough("/project/src/lib.ts"));
1253    }
1254
1255    #[test]
1256    fn wrap_single() {
1257        let r = wrap_single_command("git status", "lean-ctx");
1258        assert_eq!(r, expect_wrapped("git status", "lean-ctx"));
1259    }
1260
1261    #[test]
1262    fn wrap_with_quotes() {
1263        let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
1264        assert_eq!(
1265            r,
1266            expect_wrapped(r#"curl -H "Auth" https://api.com"#, "lean-ctx")
1267        );
1268    }
1269
1270    #[test]
1271    fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
1272        assert_eq!(
1273            rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
1274            None
1275        );
1276    }
1277
1278    #[test]
1279    fn rewrite_candidate_wraps_single_command() {
1280        assert_eq!(
1281            rewrite_candidate("git status", "lean-ctx"),
1282            Some(expect_wrapped("git status", "lean-ctx"))
1283        );
1284    }
1285
1286    #[test]
1287    fn rewrite_candidate_passes_through_heredoc() {
1288        assert_eq!(
1289            rewrite_candidate(
1290                "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1291                "lean-ctx"
1292            ),
1293            None
1294        );
1295    }
1296
1297    #[test]
1298    fn rewrite_candidate_passes_through_heredoc_compound() {
1299        assert_eq!(
1300            rewrite_candidate(
1301                "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
1302                "lean-ctx"
1303            ),
1304            None
1305        );
1306    }
1307
1308    #[test]
1309    fn codex_reroute_message_includes_exact_rewritten_command() {
1310        let message = codex_reroute_message("lean-ctx -c 'git status'");
1311        assert_eq!(
1312            message,
1313            "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
1314        );
1315    }
1316
1317    #[test]
1318    fn compound_rewrite_and_chain() {
1319        let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
1320        let w = expect_wrapped("git status", "lean-ctx");
1321        assert_eq!(result, Some(format!("cd src && {w} && echo done")));
1322    }
1323
1324    #[test]
1325    fn compound_rewrite_pipe() {
1326        let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
1327        let w = expect_wrapped("git log --oneline", "lean-ctx");
1328        assert_eq!(result, Some(format!("{w} | head -5")));
1329    }
1330
1331    #[test]
1332    fn compound_rewrite_no_match() {
1333        let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
1334        assert_eq!(result, None);
1335    }
1336
1337    #[test]
1338    fn compound_rewrite_multiple_rewritable() {
1339        let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
1340        let w1 = expect_wrapped("git add .", "lean-ctx");
1341        let w2 = expect_wrapped("cargo test", "lean-ctx");
1342        let w3 = expect_wrapped("npm run lint", "lean-ctx");
1343        assert_eq!(result, Some(format!("{w1} && {w2} && {w3}")));
1344    }
1345
1346    #[test]
1347    fn compound_rewrite_semicolons() {
1348        let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
1349        let w1 = expect_wrapped("git add .", "lean-ctx");
1350        let w2 = expect_wrapped("git commit -m 'fix'", "lean-ctx");
1351        assert_eq!(result, Some(format!("{w1} ; {w2}")));
1352    }
1353
1354    #[test]
1355    fn compound_rewrite_or_chain() {
1356        let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
1357        let w = expect_wrapped("git pull", "lean-ctx");
1358        assert_eq!(result, Some(format!("{w} || echo failed")));
1359    }
1360
1361    #[test]
1362    fn compound_skips_already_rewritten() {
1363        let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
1364        let w = expect_wrapped("git diff", "lean-ctx");
1365        assert_eq!(result, Some(format!("lean-ctx -c git status && {w}")));
1366    }
1367
1368    #[test]
1369    fn single_command_not_compound() {
1370        let result = build_rewrite_compound("git status", "lean-ctx");
1371        assert_eq!(result, None);
1372    }
1373
1374    #[test]
1375    fn extract_field_works() {
1376        let input = r#"{"tool_name":"Bash","command":"git status"}"#;
1377        assert_eq!(
1378            extract_json_field(input, "tool_name"),
1379            Some("Bash".to_string())
1380        );
1381        assert_eq!(
1382            extract_json_field(input, "command"),
1383            Some("git status".to_string())
1384        );
1385    }
1386
1387    #[test]
1388    fn extract_field_with_spaces_after_colon() {
1389        let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
1390        assert_eq!(
1391            extract_json_field(input, "tool_name"),
1392            Some("Bash".to_string())
1393        );
1394        assert_eq!(
1395            extract_json_field(input, "command"),
1396            Some("git status".to_string())
1397        );
1398    }
1399
1400    #[test]
1401    fn extract_field_pretty_printed() {
1402        let input = "{\n  \"tool_name\": \"Bash\",\n  \"tool_input\": {\n    \"command\": \"npm test\"\n  }\n}";
1403        assert_eq!(
1404            extract_json_field(input, "tool_name"),
1405            Some("Bash".to_string())
1406        );
1407        assert_eq!(
1408            extract_json_field(input, "command"),
1409            Some("npm test".to_string())
1410        );
1411    }
1412
1413    #[test]
1414    fn extract_field_handles_escaped_quotes() {
1415        let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
1416        assert_eq!(
1417            extract_json_field(input, "command"),
1418            Some(r#"grep -r "TODO" src/"#.to_string())
1419        );
1420    }
1421
1422    #[test]
1423    fn extract_field_handles_escaped_backslash() {
1424        let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
1425        assert_eq!(
1426            extract_json_field(input, "command"),
1427            Some(r#"echo \"hello\""#.to_string())
1428        );
1429    }
1430
1431    #[test]
1432    fn extract_field_handles_complex_curl() {
1433        let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
1434        assert_eq!(
1435            extract_json_field(input, "command"),
1436            Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
1437        );
1438    }
1439
1440    #[test]
1441    fn to_bash_compatible_path_windows_drive() {
1442        let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1443        assert_eq!(p, "/e/packages/lean-ctx.exe");
1444    }
1445
1446    #[test]
1447    fn to_bash_compatible_path_backslashes() {
1448        let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
1449        assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
1450    }
1451
1452    #[test]
1453    fn to_bash_compatible_path_unix_unchanged() {
1454        let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
1455        assert_eq!(p, "/usr/local/bin/lean-ctx");
1456    }
1457
1458    #[test]
1459    fn to_bash_compatible_path_msys2_unchanged() {
1460        let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
1461        assert_eq!(p, "/e/packages/lean-ctx.exe");
1462    }
1463
1464    #[test]
1465    fn wrap_command_with_bash_path() {
1466        let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1467        let result = wrap_single_command("git status", &binary);
1468        assert!(
1469            !result.contains('\\'),
1470            "wrapped command must not contain backslashes, got: {result}"
1471        );
1472        assert!(
1473            result.starts_with("/e/packages/lean-ctx.exe"),
1474            "must use bash-compatible path, got: {result}"
1475        );
1476    }
1477
1478    #[test]
1479    fn wrap_single_command_em_dash() {
1480        let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
1481        assert_eq!(
1482            r,
1483            expect_wrapped("gh --comment \"closing — see #407\"", "lean-ctx")
1484        );
1485    }
1486
1487    #[test]
1488    fn wrap_single_command_dollar_sign() {
1489        let r = wrap_single_command("echo $HOME", "lean-ctx");
1490        assert_eq!(r, expect_wrapped("echo $HOME", "lean-ctx"));
1491    }
1492
1493    #[test]
1494    fn wrap_single_command_backticks() {
1495        let r = wrap_single_command("echo `date`", "lean-ctx");
1496        assert_eq!(r, expect_wrapped("echo `date`", "lean-ctx"));
1497    }
1498
1499    #[test]
1500    fn wrap_single_command_nested_single_quotes() {
1501        let r = wrap_single_command("echo 'hello world'", "lean-ctx");
1502        assert_eq!(r, expect_wrapped("echo 'hello world'", "lean-ctx"));
1503    }
1504
1505    #[test]
1506    fn wrap_single_command_exclamation_mark() {
1507        let r = wrap_single_command("echo hello!", "lean-ctx");
1508        assert_eq!(r, expect_wrapped("echo hello!", "lean-ctx"));
1509    }
1510
1511    #[test]
1512    fn wrap_single_command_find_with_many_excludes() {
1513        let cmd = "find . -not -path ./node_modules -not -path ./.git -not -path ./dist";
1514        let r = wrap_single_command(cmd, "lean-ctx");
1515        assert_eq!(r, expect_wrapped(cmd, "lean-ctx"));
1516    }
1517}