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