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