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
9pub 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
403fn 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; 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
482pub fn mark_hook_environment() {
485 std::env::set_var("LEAN_CTX_HOOK_CHILD", "1");
486}
487
488pub 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
503fn 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 "permission": "allow",
543 "updated_input": updated_input,
544 "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 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
654fn 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 if cmd.contains('|') || cmd.contains("&&") || cmd.contains("||") || cmd.contains(';') {
663 return None;
664 }
665
666 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
712fn is_outside_project_path(path: &str) -> bool {
716 let trimmed = path.trim();
717
718 if trimmed.starts_with('~') {
720 return true;
721 }
722
723 if trimmed.starts_with('$') {
725 return true;
726 }
727
728 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 if trimmed.starts_with('/') {
742 if trimmed.contains("/Library/") || trimmed.contains("/.config/") {
744 return true;
745 }
746 if trimmed.contains("/.lean-ctx/") || trimmed.contains("/lean-ctx/logs/") {
748 return true;
749 }
750 }
751
752 false
753}
754
755fn 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
775fn 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
788pub 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
819pub 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
903fn 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
950fn 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
1004fn 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
1163pub 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
1197pub 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 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}