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    let m = model.to_lowercase();
317    if m.contains("claude")
318        || m.contains("opus-4")
319        || m.contains("o1")
320        || m.contains("o3")
321        || m.contains("o4")
322    {
323        200_000
324    } else if m.contains("gpt-4")
325        || m.contains("gpt-5")
326        || m.contains("codex")
327        || m.contains("deepseek")
328    {
329        128_000
330    } else if m.contains("gemini") {
331        1_000_000
332    } else if m.contains("mistral") || m.contains("codestral") {
333        256_000
334    } else {
335        200_000
336    }
337}
338
339pub fn load_detected_model() -> Option<(String, usize)> {
340    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
341    let path = data_dir.join("detected_model.json");
342    let content = std::fs::read_to_string(&path).ok()?;
343    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
344    let model = v.get("model")?.as_str()?.to_string();
345    let window = v.get("window_size")?.as_u64()? as usize;
346    let detected_at = v.get("detected_at")?.as_u64()?;
347    let now = std::time::SystemTime::now()
348        .duration_since(std::time::UNIX_EPOCH)
349        .unwrap_or_default()
350        .as_secs();
351    if now.saturating_sub(detected_at) > 7200 {
352        return None;
353    }
354    Some((model, window))
355}
356
357fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
358    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
359        return;
360    };
361    let meta_path = data_dir.join("active_transcript.json");
362    let ts = std::time::SystemTime::now()
363        .duration_since(std::time::UNIX_EPOCH)
364        .unwrap_or_default()
365        .as_secs();
366    let payload = serde_json::json!({
367        "transcript_path": path,
368        "conversation_id": conversation_id,
369        "updated_at": ts,
370    });
371    if let Ok(json) = serde_json::to_string_pretty(&payload) {
372        let tmp = meta_path.with_extension("tmp");
373        if std::fs::write(&tmp, &json).is_ok() {
374            let _ = std::fs::rename(&tmp, &meta_path);
375        }
376    }
377}
378
379pub fn load_active_transcript() -> Option<(String, Option<String>)> {
380    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
381    let path = data_dir.join("active_transcript.json");
382    let content = std::fs::read_to_string(&path).ok()?;
383    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
384    let tp = v.get("transcript_path")?.as_str()?.to_string();
385    let conv = v
386        .get("conversation_id")
387        .and_then(|c| c.as_str())
388        .map(String::from);
389    let updated = v.get("updated_at")?.as_u64()?;
390    let now = std::time::SystemTime::now()
391        .duration_since(std::time::UNIX_EPOCH)
392        .unwrap_or_default()
393        .as_secs();
394    if now.saturating_sub(updated) > 7200 {
395        return None;
396    }
397    Some((tp, conv))
398}
399
400fn cap_content(s: &str) -> String {
401    if s.len() <= MAX_CONTENT_CHARS {
402        s.to_string()
403    } else {
404        format!(
405            "{}…\n\n[truncated: {} total chars]",
406            &s[..MAX_CONTENT_CHARS],
407            s.len()
408        )
409    }
410}
411
412fn truncate_str(s: &str, max: usize) -> String {
413    if s.len() <= max {
414        s.to_string()
415    } else {
416        format!("{}...", &s[..max])
417    }
418}
419
420fn append_radar_event(event: &ObserveEvent) {
421    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
422        return;
423    };
424    let radar_path = data_dir.join("context_radar.jsonl");
425
426    if event.event_type == "session" {
427        if let Ok(meta) = std::fs::metadata(&radar_path) {
428            const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
429            if meta.len() > MAX_RADAR_SIZE {
430                let prev = data_dir.join("context_radar.prev.jsonl");
431                let _ = std::fs::rename(&radar_path, &prev);
432            }
433        }
434    }
435
436    let Ok(line) = serde_json::to_string(event) else {
437        return;
438    };
439
440    use std::fs::OpenOptions;
441    use std::io::Write;
442    if let Ok(mut f) = OpenOptions::new()
443        .create(true)
444        .append(true)
445        .open(&radar_path)
446    {
447        let _ = writeln!(f, "{line}");
448    }
449}
450
451fn is_disabled() -> bool {
452    std::env::var("LEAN_CTX_DISABLED").is_ok()
453}
454
455fn is_harden_active() -> bool {
456    matches!(std::env::var("LEAN_CTX_HARDEN"), Ok(v) if v.trim() == "1")
457}
458
459fn is_quiet() -> bool {
460    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
461}
462
463/// Mark this process as a hook child so the daemon-client never auto-starts
464/// the daemon from inside a hook (which would create zombie processes).
465pub fn mark_hook_environment() {
466    std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
467}
468
469/// Arms a watchdog that force-exits the process after the given duration.
470/// Prevents hook processes from becoming zombies when stdin pipes break or
471/// the IDE cancels the call. Since hooks MUST NOT spawn child processes
472/// (to avoid orphan zombies), a simple exit(1) suffices.
473pub fn arm_watchdog(timeout: Duration) {
474    std::thread::spawn(move || {
475        std::thread::sleep(timeout);
476        eprintln!(
477            "[lean-ctx hook] watchdog timeout after {}s — force exit",
478            timeout.as_secs()
479        );
480        std::process::exit(1);
481    });
482}
483
484/// Reads all of stdin with a timeout. Returns None if stdin is empty, broken, or times out.
485fn read_stdin_with_timeout(timeout: Duration) -> Option<String> {
486    let (tx, rx) = mpsc::channel();
487    std::thread::spawn(move || {
488        let mut buf = String::new();
489        let result = std::io::stdin().read_to_string(&mut buf);
490        let _ = tx.send(result.ok().map(|_| buf));
491    });
492    match rx.recv_timeout(timeout) {
493        Ok(Some(s)) if !s.is_empty() => Some(s),
494        _ => None,
495    }
496}
497
498fn build_dual_deny_output(reason: &str) -> String {
499    serde_json::json!({
500        "permission": "deny",
501        "reason": reason,
502        "hookSpecificOutput": {
503            "hookEventName": "PreToolUse",
504            "permissionDecision": "deny",
505        }
506    })
507    .to_string()
508}
509
510fn build_dual_allow_output() -> String {
511    serde_json::json!({
512        "permission": "allow",
513        "hookSpecificOutput": {
514            "hookEventName": "PreToolUse",
515            "permissionDecision": "allow"
516        }
517    })
518    .to_string()
519}
520
521fn build_dual_rewrite_output(tool_input: Option<&serde_json::Value>, rewritten: &str) -> String {
522    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
523        let mut m = obj.clone();
524        m.insert(
525            "command".to_string(),
526            serde_json::Value::String(rewritten.to_string()),
527        );
528        serde_json::Value::Object(m)
529    } else {
530        serde_json::json!({ "command": rewritten })
531    };
532
533    serde_json::json!({
534        // Cursor hook output format
535        "permission": "allow",
536        "updated_input": updated_input,
537        // Claude Code hook output format (extra fields are ignored by other hosts)
538        "hookSpecificOutput": {
539            "hookEventName": "PreToolUse",
540            "permissionDecision": "allow",
541            "updatedInput": {
542                "command": rewritten
543            }
544        }
545    })
546    .to_string()
547}
548
549pub fn handle_rewrite() {
550    if is_disabled() {
551        return;
552    }
553    let binary = resolve_binary();
554    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
555        return;
556    };
557
558    let v: serde_json::Value = if let Ok(v) = serde_json::from_str(&input) {
559        v
560    } else {
561        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
562        return;
563    };
564
565    let tool = v.get("tool_name").and_then(|t| t.as_str());
566    let Some(tool_name) = tool else {
567        return;
568    };
569
570    // Claude Code uses Bash; Cursor uses Shell; Copilot uses runInTerminal.
571    let is_shell_tool = matches!(
572        tool_name,
573        "Bash" | "bash" | "Shell" | "shell" | "runInTerminal" | "run_in_terminal" | "terminal"
574    );
575    if !is_shell_tool {
576        return;
577    }
578
579    let tool_input = v.get("tool_input");
580    let Some(cmd) = tool_input
581        .and_then(|ti| ti.get("command"))
582        .and_then(|c| c.as_str())
583        .or_else(|| v.get("command").and_then(|c| c.as_str()))
584    else {
585        return;
586    };
587
588    if let Some(rewritten) = rewrite_candidate(cmd, &binary) {
589        print!("{}", build_dual_rewrite_output(tool_input, &rewritten));
590    } else {
591        // Always return a valid allow JSON for hosts that require JSON on exit 0.
592        print!("{}", build_dual_allow_output());
593    }
594}
595
596fn is_rewritable(cmd: &str) -> bool {
597    rewrite_registry::is_rewritable_command(cmd)
598}
599
600fn wrap_single_command(cmd: &str, binary: &str) -> String {
601    let shell_escaped = cmd.replace('\'', "'\\''");
602    format!("{binary} -c '{shell_escaped}'")
603}
604
605fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
606    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
607        return None;
608    }
609
610    // Heredocs cannot survive the quoting round-trip through `lean-ctx -c '...'`.
611    // Newlines get escaped, breaking the heredoc syntax entirely (GitHub #140).
612    if cmd.contains("<<") {
613        return None;
614    }
615
616    if let Some(rewritten) = rewrite_file_read_command(cmd, binary) {
617        return Some(rewritten);
618    }
619
620    if let Some(rewritten) = rewrite_search_command(cmd, binary) {
621        return Some(rewritten);
622    }
623
624    if let Some(rewritten) = rewrite_dir_list_command(cmd, binary) {
625        return Some(rewritten);
626    }
627
628    if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
629        return Some(rewritten);
630    }
631
632    if is_rewritable(cmd) {
633        return Some(wrap_single_command(cmd, binary));
634    }
635
636    None
637}
638
639/// Rewrites cat/head/tail to lean-ctx read with appropriate arguments.
640fn rewrite_file_read_command(cmd: &str, binary: &str) -> Option<String> {
641    if !rewrite_registry::is_file_read_command(cmd) {
642        return None;
643    }
644
645    let parts: Vec<&str> = cmd.split_whitespace().collect();
646    if parts.len() < 2 {
647        return None;
648    }
649
650    match parts[0] {
651        "cat" => {
652            let path = parts[1..].join(" ");
653            Some(format!("{binary} read {path}"))
654        }
655        "head" => {
656            let (n, path) = parse_head_tail_args(&parts[1..]);
657            let path = path?;
658            match n {
659                Some(lines) => Some(format!("{binary} read {path} -m lines:1-{lines}")),
660                None => Some(format!("{binary} read {path} -m lines:1-10")),
661            }
662        }
663        "tail" => {
664            let (n, path) = parse_head_tail_args(&parts[1..]);
665            let path = path?;
666            let lines = n.unwrap_or(10);
667            Some(format!("{binary} read {path} -m lines:-{lines}"))
668        }
669        _ => None,
670    }
671}
672
673/// Rewrites `rg <pattern> [path]` to `lean-ctx grep <pattern> [path]` for simple forms.
674///
675/// Falls back to `lean-ctx -c 'rg ...'` for flags/complex quoting (handled elsewhere).
676fn rewrite_search_command(cmd: &str, binary: &str) -> Option<String> {
677    let parts: Vec<&str> = cmd.split_whitespace().collect();
678    if parts.first().copied() != Some("rg") {
679        return None;
680    }
681    if parts.len() < 2 {
682        return None;
683    }
684    if parts[1].starts_with('-') {
685        return None;
686    }
687    if parts.len() > 3 {
688        return None;
689    }
690    let pattern = parts[1];
691    let path = parts.get(2).copied();
692    match path {
693        Some(p) if p.starts_with('-') => None,
694        Some(p) => Some(format!("{binary} grep {pattern} {p}")),
695        None => Some(format!("{binary} grep {pattern}")),
696    }
697}
698
699/// Rewrites simple `ls [path]` to `lean-ctx ls [path]`.
700///
701/// Falls back to `lean-ctx -c 'ls ...'` for flags (handled elsewhere).
702fn rewrite_dir_list_command(cmd: &str, binary: &str) -> Option<String> {
703    let parts: Vec<&str> = cmd.split_whitespace().collect();
704    if parts.first().copied() != Some("ls") {
705        return None;
706    }
707    match parts.len() {
708        1 => Some(format!("{binary} ls")),
709        2 if !parts[1].starts_with('-') => Some(format!("{binary} ls {}", parts[1])),
710        _ => None,
711    }
712}
713
714fn parse_head_tail_args<'a>(args: &[&'a str]) -> (Option<usize>, Option<&'a str>) {
715    let mut n: Option<usize> = None;
716    let mut path: Option<&str> = None;
717
718    let mut i = 0;
719    while i < args.len() {
720        if args[i] == "-n" && i + 1 < args.len() {
721            n = args[i + 1].parse().ok();
722            i += 2;
723        } else if let Some(num) = args[i].strip_prefix("-n") {
724            n = num.parse().ok();
725            i += 1;
726        } else if args[i].starts_with('-') && args[i].len() > 1 {
727            if let Ok(num) = args[i][1..].parse::<usize>() {
728                n = Some(num);
729            }
730            i += 1;
731        } else {
732            path = Some(args[i]);
733            i += 1;
734        }
735    }
736
737    (n, path)
738}
739
740fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
741    compound_lexer::rewrite_compound(cmd, |segment| {
742        if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
743            return None;
744        }
745        if is_rewritable(segment) {
746            Some(wrap_single_command(segment, binary))
747        } else {
748            None
749        }
750    })
751}
752
753fn emit_rewrite(rewritten: &str) {
754    let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
755    print!(
756        "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
757    );
758}
759
760pub fn handle_redirect() {
761    if is_disabled() {
762        let _ = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT);
763        print!("{}", build_dual_allow_output());
764        return;
765    }
766
767    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
768        return;
769    };
770
771    let Ok(v) = serde_json::from_str::<serde_json::Value>(&input) else {
772        print!("{}", build_dual_deny_output("invalid JSON hook payload"));
773        return;
774    };
775
776    let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
777    let tool_input = v.get("tool_input");
778
779    match tool_name {
780        "Read" | "read" | "read_file" => redirect_read(tool_input),
781        "Grep" | "grep" | "search" | "ripgrep" => redirect_grep(tool_input),
782        _ => print!("{}", build_dual_allow_output()),
783    }
784}
785
786/// Redirect Read through lean-ctx for compression + caching.
787/// Safe because `mark_hook_environment()` sets LEAN_CTX_HOOK_CHILD=1 which
788/// prevents daemon auto-start. The subprocess uses the fast local-only path.
789fn redirect_read(tool_input: Option<&serde_json::Value>) {
790    let path = tool_input
791        .and_then(|ti| ti.get("path"))
792        .and_then(|p| p.as_str())
793        .unwrap_or("");
794
795    if path.is_empty() || should_passthrough(path) {
796        print!("{}", build_dual_allow_output());
797        return;
798    }
799
800    if is_harden_active() {
801        print!(
802            "{}",
803            build_dual_deny_output(
804                "Use ctx_read instead of native Read. lean-ctx harden mode is active."
805            )
806        );
807        return;
808    }
809
810    let binary = resolve_binary();
811    let temp_path = redirect_temp_path(path);
812
813    if let Some(output) = run_with_timeout(&binary, &["read", path], REDIRECT_SUBPROCESS_TIMEOUT) {
814        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
815            let temp_str = temp_path.to_str().unwrap_or("");
816            print!("{}", build_redirect_output(tool_input, "path", temp_str));
817            return;
818        }
819    }
820
821    print!("{}", build_dual_allow_output());
822}
823
824/// Redirect Grep through lean-ctx for compressed results.
825fn redirect_grep(tool_input: Option<&serde_json::Value>) {
826    let pattern = tool_input
827        .and_then(|ti| ti.get("pattern"))
828        .and_then(|p| p.as_str())
829        .unwrap_or("");
830    let search_path = tool_input
831        .and_then(|ti| ti.get("path"))
832        .and_then(|p| p.as_str())
833        .unwrap_or(".");
834
835    if pattern.is_empty() {
836        print!("{}", build_dual_allow_output());
837        return;
838    }
839
840    if is_harden_active() {
841        print!(
842            "{}",
843            build_dual_deny_output(
844                "Use ctx_search instead of native Grep. lean-ctx harden mode is active."
845            )
846        );
847        return;
848    }
849
850    let binary = resolve_binary();
851    let key = format!("grep:{pattern}:{search_path}");
852    let temp_path = redirect_temp_path(&key);
853
854    if let Some(output) = run_with_timeout(
855        &binary,
856        &["grep", pattern, search_path],
857        REDIRECT_SUBPROCESS_TIMEOUT,
858    ) {
859        if !output.is_empty() && std::fs::write(&temp_path, &output).is_ok() {
860            let temp_str = temp_path.to_str().unwrap_or("");
861            print!("{}", build_redirect_output(tool_input, "path", temp_str));
862            return;
863        }
864    }
865
866    print!("{}", build_dual_allow_output());
867}
868
869const REDIRECT_SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
870
871/// Run a lean-ctx subprocess with a hard timeout. Returns stdout on success.
872/// Kills the child if it exceeds the timeout to prevent orphan processes.
873fn run_with_timeout(binary: &str, args: &[&str], timeout: Duration) -> Option<Vec<u8>> {
874    let mut child = std::process::Command::new(binary)
875        .args(args)
876        .stdout(std::process::Stdio::piped())
877        .stderr(std::process::Stdio::null())
878        .spawn()
879        .ok()?;
880
881    let deadline = std::time::Instant::now() + timeout;
882    loop {
883        match child.try_wait() {
884            Ok(Some(status)) if status.success() => {
885                let mut stdout = Vec::new();
886                if let Some(mut out) = child.stdout.take() {
887                    let _ = out.read_to_end(&mut stdout);
888                }
889                return if stdout.is_empty() {
890                    None
891                } else {
892                    Some(stdout)
893                };
894            }
895            Ok(Some(_)) | Err(_) => return None,
896            Ok(None) => {
897                if std::time::Instant::now() > deadline {
898                    let _ = child.kill();
899                    let _ = child.wait();
900                    return None;
901                }
902                std::thread::sleep(Duration::from_millis(10));
903            }
904        }
905    }
906}
907
908fn redirect_temp_path(key: &str) -> std::path::PathBuf {
909    use std::collections::hash_map::DefaultHasher;
910    use std::hash::{Hash, Hasher};
911
912    let mut hasher = DefaultHasher::new();
913    key.hash(&mut hasher);
914    std::process::id().hash(&mut hasher);
915    let hash = hasher.finish();
916
917    let temp_dir = std::env::temp_dir().join("lean-ctx-hook");
918    let _ = std::fs::create_dir_all(&temp_dir);
919    #[cfg(unix)]
920    {
921        use std::os::unix::fs::PermissionsExt;
922        let _ = std::fs::set_permissions(&temp_dir, std::fs::Permissions::from_mode(0o700));
923    }
924    temp_dir.join(format!("{hash:016x}.lctx"))
925}
926
927fn build_redirect_output(
928    tool_input: Option<&serde_json::Value>,
929    field: &str,
930    temp_path: &str,
931) -> String {
932    let updated_input = if let Some(obj) = tool_input.and_then(|v| v.as_object()) {
933        let mut m = obj.clone();
934        m.insert(
935            field.to_string(),
936            serde_json::Value::String(temp_path.to_string()),
937        );
938        serde_json::Value::Object(m)
939    } else {
940        serde_json::json!({ field: temp_path })
941    };
942
943    serde_json::json!({
944        "permission": "allow",
945        "updated_input": updated_input,
946        "hookSpecificOutput": {
947            "hookEventName": "PreToolUse",
948            "permissionDecision": "allow",
949            "updatedInput": { field: temp_path }
950        }
951    })
952    .to_string()
953}
954
955const PASSTHROUGH_SUBSTRINGS: &[&str] = &[
956    ".cursorrules",
957    ".cursor/rules",
958    ".cursor/hooks",
959    "skill.md",
960    "agents.md",
961    ".env",
962    "hooks.json",
963    "node_modules",
964];
965
966const PASSTHROUGH_EXTENSIONS: &[&str] = &[
967    "lock", "png", "jpg", "jpeg", "gif", "webp", "pdf", "ico", "svg", "woff", "woff2", "ttf", "eot",
968];
969
970fn should_passthrough(path: &str) -> bool {
971    let p = path.to_lowercase();
972
973    if PASSTHROUGH_SUBSTRINGS.iter().any(|s| p.contains(s)) {
974        return true;
975    }
976
977    std::path::Path::new(&p)
978        .extension()
979        .and_then(|ext| ext.to_str())
980        .is_some_and(|ext| {
981            PASSTHROUGH_EXTENSIONS
982                .iter()
983                .any(|e| ext.eq_ignore_ascii_case(e))
984        })
985}
986
987fn codex_reroute_message(rewritten: &str) -> String {
988    format!(
989        "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
990    )
991}
992
993pub fn handle_codex_pretooluse() {
994    if is_disabled() {
995        return;
996    }
997    let binary = resolve_binary();
998    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
999        return;
1000    };
1001
1002    let tool = extract_json_field(&input, "tool_name");
1003    if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
1004        return;
1005    }
1006
1007    let Some(cmd) = extract_json_field(&input, "command") else {
1008        return;
1009    };
1010
1011    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1012        if is_quiet() {
1013            eprintln!("Re-run: {rewritten}");
1014        } else {
1015            eprintln!("{}", codex_reroute_message(&rewritten));
1016        }
1017        std::process::exit(2);
1018    }
1019}
1020
1021pub fn handle_codex_session_start() {
1022    if is_quiet() {
1023        return;
1024    }
1025    println!(
1026        "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."
1027    );
1028}
1029
1030/// Copilot-specific PreToolUse handler.
1031/// VS Code Copilot Chat uses the same hook format as Claude Code.
1032/// Tool names differ: "runInTerminal" / "editFile" instead of "Bash" / "Read".
1033pub fn handle_copilot() {
1034    if is_disabled() {
1035        return;
1036    }
1037    let binary = resolve_binary();
1038    let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
1039        return;
1040    };
1041
1042    let tool = extract_json_field(&input, "tool_name");
1043    let Some(tool_name) = tool.as_deref() else {
1044        return;
1045    };
1046
1047    let is_shell_tool = matches!(
1048        tool_name,
1049        "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
1050    );
1051    if !is_shell_tool {
1052        return;
1053    }
1054
1055    let Some(cmd) = extract_json_field(&input, "command") else {
1056        return;
1057    };
1058
1059    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1060        emit_rewrite(&rewritten);
1061    }
1062}
1063
1064/// Inline rewrite: takes a command as CLI args, prints the rewritten command to stdout.
1065/// Used by the OpenCode TS plugin where the command is passed as an argument,
1066/// not via stdin JSON. Uses native OS paths (not MSYS) because the calling
1067/// shell may be PowerShell or cmd on Windows.
1068pub fn handle_rewrite_inline() {
1069    if is_disabled() {
1070        return;
1071    }
1072    let binary = resolve_binary_native();
1073    let args: Vec<String> = std::env::args().collect();
1074    // args: [binary, "hook", "rewrite-inline", ...command parts]
1075    if args.len() < 4 {
1076        return;
1077    }
1078    let cmd = args[3..].join(" ");
1079
1080    if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
1081        print!("{rewritten}");
1082        return;
1083    }
1084
1085    if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
1086        print!("{cmd}");
1087        return;
1088    }
1089
1090    print!("{cmd}");
1091}
1092
1093fn resolve_binary() -> String {
1094    let path = crate::core::portable_binary::resolve_portable_binary();
1095    crate::hooks::to_bash_compatible_path(&path)
1096}
1097
1098fn resolve_binary_native() -> String {
1099    crate::core::portable_binary::resolve_portable_binary()
1100}
1101
1102fn extract_json_field(input: &str, field: &str) -> Option<String> {
1103    let key = format!("\"{field}\":");
1104    let key_pos = input.find(&key)?;
1105    let after_colon = &input[key_pos + key.len()..];
1106    let trimmed = after_colon.trim_start();
1107    if !trimmed.starts_with('"') {
1108        return None;
1109    }
1110    let rest = &trimmed[1..];
1111    let bytes = rest.as_bytes();
1112    let mut end = 0;
1113    while end < bytes.len() {
1114        if bytes[end] == b'\\' && end + 1 < bytes.len() {
1115            end += 2;
1116            continue;
1117        }
1118        if bytes[end] == b'"' {
1119            break;
1120        }
1121        end += 1;
1122    }
1123    if end >= bytes.len() {
1124        return None;
1125    }
1126    let raw = &rest[..end];
1127    Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    #[test]
1135    fn is_rewritable_basic() {
1136        assert!(is_rewritable("git status"));
1137        assert!(is_rewritable("cargo test --lib"));
1138        assert!(is_rewritable("npm run build"));
1139        assert!(!is_rewritable("echo hello"));
1140        assert!(!is_rewritable("cd src"));
1141        assert!(!is_rewritable("cat file.rs"));
1142    }
1143
1144    #[test]
1145    fn file_read_rewrite_cat() {
1146        let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
1147        assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
1148    }
1149
1150    #[test]
1151    fn file_read_rewrite_head_with_n() {
1152        let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
1153        assert_eq!(
1154            r,
1155            Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
1156        );
1157    }
1158
1159    #[test]
1160    fn file_read_rewrite_head_short() {
1161        let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
1162        assert_eq!(
1163            r,
1164            Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
1165        );
1166    }
1167
1168    #[test]
1169    fn file_read_rewrite_tail() {
1170        let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
1171        assert_eq!(
1172            r,
1173            Some("lean-ctx read src/main.rs -m lines:-10".to_string())
1174        );
1175    }
1176
1177    #[test]
1178    fn file_read_rewrite_not_git() {
1179        assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
1180    }
1181
1182    #[test]
1183    fn parse_head_tail_args_basic() {
1184        let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
1185        assert_eq!(n, Some(20));
1186        assert_eq!(path, Some("file.rs"));
1187    }
1188
1189    #[test]
1190    fn parse_head_tail_args_combined() {
1191        let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
1192        assert_eq!(n, Some(20));
1193        assert_eq!(path, Some("file.rs"));
1194    }
1195
1196    #[test]
1197    fn parse_head_tail_args_short_flag() {
1198        let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
1199        assert_eq!(n, Some(50));
1200        assert_eq!(path, Some("file.rs"));
1201    }
1202
1203    #[test]
1204    fn should_passthrough_rules_files() {
1205        assert!(should_passthrough("/home/user/.cursorrules"));
1206        assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
1207        assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
1208        assert!(should_passthrough("/project/SKILL.md"));
1209        assert!(should_passthrough("/project/AGENTS.md"));
1210        assert!(should_passthrough("/project/icon.png"));
1211        assert!(!should_passthrough("/project/src/main.rs"));
1212        assert!(!should_passthrough("/project/src/lib.ts"));
1213    }
1214
1215    #[test]
1216    fn wrap_single() {
1217        let r = wrap_single_command("git status", "lean-ctx");
1218        assert_eq!(r, "lean-ctx -c 'git status'");
1219    }
1220
1221    #[test]
1222    fn wrap_with_quotes() {
1223        let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
1224        assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
1225    }
1226
1227    #[test]
1228    fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
1229        assert_eq!(
1230            rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
1231            None
1232        );
1233    }
1234
1235    #[test]
1236    fn rewrite_candidate_wraps_single_command() {
1237        assert_eq!(
1238            rewrite_candidate("git status", "lean-ctx"),
1239            Some("lean-ctx -c 'git status'".to_string())
1240        );
1241    }
1242
1243    #[test]
1244    fn rewrite_candidate_passes_through_heredoc() {
1245        assert_eq!(
1246            rewrite_candidate(
1247                "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
1248                "lean-ctx"
1249            ),
1250            None
1251        );
1252    }
1253
1254    #[test]
1255    fn rewrite_candidate_passes_through_heredoc_compound() {
1256        assert_eq!(
1257            rewrite_candidate(
1258                "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
1259                "lean-ctx"
1260            ),
1261            None
1262        );
1263    }
1264
1265    #[test]
1266    fn codex_reroute_message_includes_exact_rewritten_command() {
1267        let message = codex_reroute_message("lean-ctx -c 'git status'");
1268        assert_eq!(
1269            message,
1270            "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
1271        );
1272    }
1273
1274    #[test]
1275    fn compound_rewrite_and_chain() {
1276        let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
1277        assert_eq!(
1278            result,
1279            Some("cd src && lean-ctx -c 'git status' && echo done".into())
1280        );
1281    }
1282
1283    #[test]
1284    fn compound_rewrite_pipe() {
1285        let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
1286        assert_eq!(
1287            result,
1288            Some("lean-ctx -c 'git log --oneline' | head -5".into())
1289        );
1290    }
1291
1292    #[test]
1293    fn compound_rewrite_no_match() {
1294        let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
1295        assert_eq!(result, None);
1296    }
1297
1298    #[test]
1299    fn compound_rewrite_multiple_rewritable() {
1300        let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
1301        assert_eq!(
1302            result,
1303            Some(
1304                "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
1305                    .into()
1306            )
1307        );
1308    }
1309
1310    #[test]
1311    fn compound_rewrite_semicolons() {
1312        let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
1313        assert_eq!(
1314            result,
1315            Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
1316        );
1317    }
1318
1319    #[test]
1320    fn compound_rewrite_or_chain() {
1321        let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
1322        assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
1323    }
1324
1325    #[test]
1326    fn compound_skips_already_rewritten() {
1327        let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
1328        assert_eq!(
1329            result,
1330            Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
1331        );
1332    }
1333
1334    #[test]
1335    fn single_command_not_compound() {
1336        let result = build_rewrite_compound("git status", "lean-ctx");
1337        assert_eq!(result, None);
1338    }
1339
1340    #[test]
1341    fn extract_field_works() {
1342        let input = r#"{"tool_name":"Bash","command":"git status"}"#;
1343        assert_eq!(
1344            extract_json_field(input, "tool_name"),
1345            Some("Bash".to_string())
1346        );
1347        assert_eq!(
1348            extract_json_field(input, "command"),
1349            Some("git status".to_string())
1350        );
1351    }
1352
1353    #[test]
1354    fn extract_field_with_spaces_after_colon() {
1355        let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
1356        assert_eq!(
1357            extract_json_field(input, "tool_name"),
1358            Some("Bash".to_string())
1359        );
1360        assert_eq!(
1361            extract_json_field(input, "command"),
1362            Some("git status".to_string())
1363        );
1364    }
1365
1366    #[test]
1367    fn extract_field_pretty_printed() {
1368        let input = "{\n  \"tool_name\": \"Bash\",\n  \"tool_input\": {\n    \"command\": \"npm test\"\n  }\n}";
1369        assert_eq!(
1370            extract_json_field(input, "tool_name"),
1371            Some("Bash".to_string())
1372        );
1373        assert_eq!(
1374            extract_json_field(input, "command"),
1375            Some("npm test".to_string())
1376        );
1377    }
1378
1379    #[test]
1380    fn extract_field_handles_escaped_quotes() {
1381        let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
1382        assert_eq!(
1383            extract_json_field(input, "command"),
1384            Some(r#"grep -r "TODO" src/"#.to_string())
1385        );
1386    }
1387
1388    #[test]
1389    fn extract_field_handles_escaped_backslash() {
1390        let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
1391        assert_eq!(
1392            extract_json_field(input, "command"),
1393            Some(r#"echo \"hello\""#.to_string())
1394        );
1395    }
1396
1397    #[test]
1398    fn extract_field_handles_complex_curl() {
1399        let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
1400        assert_eq!(
1401            extract_json_field(input, "command"),
1402            Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
1403        );
1404    }
1405
1406    #[test]
1407    fn to_bash_compatible_path_windows_drive() {
1408        let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1409        assert_eq!(p, "/e/packages/lean-ctx.exe");
1410    }
1411
1412    #[test]
1413    fn to_bash_compatible_path_backslashes() {
1414        let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
1415        assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
1416    }
1417
1418    #[test]
1419    fn to_bash_compatible_path_unix_unchanged() {
1420        let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
1421        assert_eq!(p, "/usr/local/bin/lean-ctx");
1422    }
1423
1424    #[test]
1425    fn to_bash_compatible_path_msys2_unchanged() {
1426        let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
1427        assert_eq!(p, "/e/packages/lean-ctx.exe");
1428    }
1429
1430    #[test]
1431    fn wrap_command_with_bash_path() {
1432        let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
1433        let result = wrap_single_command("git status", &binary);
1434        assert!(
1435            !result.contains('\\'),
1436            "wrapped command must not contain backslashes, got: {result}"
1437        );
1438        assert!(
1439            result.starts_with("/e/packages/lean-ctx.exe"),
1440            "must use bash-compatible path, got: {result}"
1441        );
1442    }
1443
1444    #[test]
1445    fn wrap_single_command_em_dash() {
1446        let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
1447        assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
1448    }
1449
1450    #[test]
1451    fn wrap_single_command_dollar_sign() {
1452        let r = wrap_single_command("echo $HOME", "lean-ctx");
1453        assert_eq!(r, "lean-ctx -c 'echo $HOME'");
1454    }
1455
1456    #[test]
1457    fn wrap_single_command_backticks() {
1458        let r = wrap_single_command("echo `date`", "lean-ctx");
1459        assert_eq!(r, "lean-ctx -c 'echo `date`'");
1460    }
1461
1462    #[test]
1463    fn wrap_single_command_nested_single_quotes() {
1464        let r = wrap_single_command("echo 'hello world'", "lean-ctx");
1465        assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
1466    }
1467
1468    #[test]
1469    fn wrap_single_command_exclamation_mark() {
1470        let r = wrap_single_command("echo hello!", "lean-ctx");
1471        assert_eq!(r, "lean-ctx -c 'echo hello!'");
1472    }
1473
1474    #[test]
1475    fn wrap_single_command_find_with_many_excludes() {
1476        let r = wrap_single_command(
1477            "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
1478            "lean-ctx",
1479        );
1480        assert_eq!(
1481            r,
1482            "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
1483        );
1484    }
1485}