1use crate::compound_lexer;
2use crate::rewrite_registry;
3use std::io::Read;
4
5pub fn handle_rewrite() {
6 let binary = resolve_binary();
7 let mut input = String::new();
8 if std::io::stdin().read_to_string(&mut input).is_err() {
9 return;
10 }
11
12 let tool = extract_json_field(&input, "tool_name");
13 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
14 return;
15 }
16
17 let cmd = match extract_json_field(&input, "command") {
18 Some(c) => c,
19 None => return,
20 };
21
22 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
23 emit_rewrite(&rewritten);
24 }
25}
26
27fn is_rewritable(cmd: &str) -> bool {
28 rewrite_registry::is_rewritable_command(cmd)
29}
30
31fn wrap_single_command(cmd: &str, binary: &str) -> String {
32 let shell_escaped = cmd.replace('\'', "'\\''");
33 format!("{binary} -c '{shell_escaped}'")
34}
35
36fn rewrite_candidate(cmd: &str, binary: &str) -> Option<String> {
37 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
38 return None;
39 }
40
41 if cmd.contains("<<") {
44 return None;
45 }
46
47 if let Some(rewritten) = build_rewrite_compound(cmd, binary) {
48 return Some(rewritten);
49 }
50
51 if is_rewritable(cmd) {
52 return Some(wrap_single_command(cmd, binary));
53 }
54
55 None
56}
57
58fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
59 compound_lexer::rewrite_compound(cmd, |segment| {
60 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
61 return None;
62 }
63 if is_rewritable(segment) {
64 Some(wrap_single_command(segment, binary))
65 } else {
66 None
67 }
68 })
69}
70
71fn emit_rewrite(rewritten: &str) {
72 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
73 print!(
74 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
75 );
76}
77
78pub fn handle_redirect() {
79 }
84
85fn codex_reroute_message(rewritten: &str) -> String {
86 format!(
87 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
88 )
89}
90
91pub fn handle_codex_pretooluse() {
92 let binary = resolve_binary();
93 let mut input = String::new();
94 if std::io::stdin().read_to_string(&mut input).is_err() {
95 return;
96 }
97
98 let tool = extract_json_field(&input, "tool_name");
99 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
100 return;
101 }
102
103 let cmd = match extract_json_field(&input, "command") {
104 Some(c) => c,
105 None => return,
106 };
107
108 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
109 eprintln!("{}", codex_reroute_message(&rewritten));
110 std::process::exit(2);
111 }
112}
113
114pub fn handle_codex_session_start() {
115 println!(
116 "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."
117 );
118}
119
120pub fn handle_copilot() {
124 let binary = resolve_binary();
125 let mut input = String::new();
126 if std::io::stdin().read_to_string(&mut input).is_err() {
127 return;
128 }
129
130 let tool = extract_json_field(&input, "tool_name");
131 let tool_name = match tool.as_deref() {
132 Some(name) => name,
133 None => return,
134 };
135
136 let is_shell_tool = matches!(
137 tool_name,
138 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
139 );
140 if !is_shell_tool {
141 return;
142 }
143
144 let cmd = match extract_json_field(&input, "command") {
145 Some(c) => c,
146 None => return,
147 };
148
149 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
150 emit_rewrite(&rewritten);
151 }
152}
153
154pub fn handle_rewrite_inline() {
158 let binary = resolve_binary();
159 let args: Vec<String> = std::env::args().collect();
160 if args.len() < 4 {
162 return;
163 }
164 let cmd = args[3..].join(" ");
165
166 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
167 print!("{rewritten}");
168 return;
169 }
170
171 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
172 print!("{cmd}");
173 return;
174 }
175
176 print!("{cmd}");
177}
178
179fn resolve_binary() -> String {
180 let path = crate::core::portable_binary::resolve_portable_binary();
181 crate::hooks::to_bash_compatible_path(&path)
182}
183
184fn extract_json_field(input: &str, field: &str) -> Option<String> {
185 let pattern = format!("\"{}\":\"", field);
186 let start = input.find(&pattern)? + pattern.len();
187 let rest = &input[start..];
188 let bytes = rest.as_bytes();
189 let mut end = 0;
190 while end < bytes.len() {
191 if bytes[end] == b'\\' && end + 1 < bytes.len() {
192 end += 2;
193 continue;
194 }
195 if bytes[end] == b'"' {
196 break;
197 }
198 end += 1;
199 }
200 if end >= bytes.len() {
201 return None;
202 }
203 let raw = &rest[..end];
204 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn is_rewritable_basic() {
213 assert!(is_rewritable("git status"));
214 assert!(is_rewritable("cargo test --lib"));
215 assert!(is_rewritable("npm run build"));
216 assert!(!is_rewritable("echo hello"));
217 assert!(!is_rewritable("cd src"));
218 }
219
220 #[test]
221 fn wrap_single() {
222 let r = wrap_single_command("git status", "lean-ctx");
223 assert_eq!(r, "lean-ctx -c 'git status'");
224 }
225
226 #[test]
227 fn wrap_with_quotes() {
228 let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
229 assert_eq!(r, r#"lean-ctx -c 'curl -H "Auth" https://api.com'"#);
230 }
231
232 #[test]
233 fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
234 assert_eq!(
235 rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
236 None
237 );
238 }
239
240 #[test]
241 fn rewrite_candidate_wraps_single_command() {
242 assert_eq!(
243 rewrite_candidate("git status", "lean-ctx"),
244 Some("lean-ctx -c 'git status'".to_string())
245 );
246 }
247
248 #[test]
249 fn rewrite_candidate_passes_through_heredoc() {
250 assert_eq!(
251 rewrite_candidate(
252 "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
253 "lean-ctx"
254 ),
255 None
256 );
257 }
258
259 #[test]
260 fn rewrite_candidate_passes_through_heredoc_compound() {
261 assert_eq!(
262 rewrite_candidate(
263 "git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
264 "lean-ctx"
265 ),
266 None
267 );
268 }
269
270 #[test]
271 fn codex_reroute_message_includes_exact_rewritten_command() {
272 let message = codex_reroute_message("lean-ctx -c 'git status'");
273 assert_eq!(
274 message,
275 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c 'git status'"
276 );
277 }
278
279 #[test]
280 fn compound_rewrite_and_chain() {
281 let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
282 assert_eq!(
283 result,
284 Some("cd src && lean-ctx -c 'git status' && echo done".into())
285 );
286 }
287
288 #[test]
289 fn compound_rewrite_pipe() {
290 let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
291 assert_eq!(
292 result,
293 Some("lean-ctx -c 'git log --oneline' | head -5".into())
294 );
295 }
296
297 #[test]
298 fn compound_rewrite_no_match() {
299 let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
300 assert_eq!(result, None);
301 }
302
303 #[test]
304 fn compound_rewrite_multiple_rewritable() {
305 let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
306 assert_eq!(
307 result,
308 Some(
309 "lean-ctx -c 'git add .' && lean-ctx -c 'cargo test' && lean-ctx -c 'npm run lint'"
310 .into()
311 )
312 );
313 }
314
315 #[test]
316 fn compound_rewrite_semicolons() {
317 let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
318 assert_eq!(
319 result,
320 Some("lean-ctx -c 'git add .' ; lean-ctx -c 'git commit -m '\\''fix'\\'''".into())
321 );
322 }
323
324 #[test]
325 fn compound_rewrite_or_chain() {
326 let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
327 assert_eq!(result, Some("lean-ctx -c 'git pull' || echo failed".into()));
328 }
329
330 #[test]
331 fn compound_skips_already_rewritten() {
332 let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
333 assert_eq!(
334 result,
335 Some("lean-ctx -c git status && lean-ctx -c 'git diff'".into())
336 );
337 }
338
339 #[test]
340 fn single_command_not_compound() {
341 let result = build_rewrite_compound("git status", "lean-ctx");
342 assert_eq!(result, None);
343 }
344
345 #[test]
346 fn extract_field_works() {
347 let input = r#"{"tool_name":"Bash","command":"git status"}"#;
348 assert_eq!(
349 extract_json_field(input, "tool_name"),
350 Some("Bash".to_string())
351 );
352 assert_eq!(
353 extract_json_field(input, "command"),
354 Some("git status".to_string())
355 );
356 }
357
358 #[test]
359 fn extract_field_handles_escaped_quotes() {
360 let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
361 assert_eq!(
362 extract_json_field(input, "command"),
363 Some(r#"grep -r "TODO" src/"#.to_string())
364 );
365 }
366
367 #[test]
368 fn extract_field_handles_escaped_backslash() {
369 let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
370 assert_eq!(
371 extract_json_field(input, "command"),
372 Some(r#"echo \"hello\""#.to_string())
373 );
374 }
375
376 #[test]
377 fn extract_field_handles_complex_curl() {
378 let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
379 assert_eq!(
380 extract_json_field(input, "command"),
381 Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
382 );
383 }
384
385 #[test]
386 fn to_bash_compatible_path_windows_drive() {
387 let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
388 assert_eq!(p, "/e/packages/lean-ctx.exe");
389 }
390
391 #[test]
392 fn to_bash_compatible_path_backslashes() {
393 let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
394 assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
395 }
396
397 #[test]
398 fn to_bash_compatible_path_unix_unchanged() {
399 let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
400 assert_eq!(p, "/usr/local/bin/lean-ctx");
401 }
402
403 #[test]
404 fn to_bash_compatible_path_msys2_unchanged() {
405 let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
406 assert_eq!(p, "/e/packages/lean-ctx.exe");
407 }
408
409 #[test]
410 fn wrap_command_with_bash_path() {
411 let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
412 let result = wrap_single_command("git status", &binary);
413 assert!(
414 !result.contains('\\'),
415 "wrapped command must not contain backslashes, got: {result}"
416 );
417 assert!(
418 result.starts_with("/e/packages/lean-ctx.exe"),
419 "must use bash-compatible path, got: {result}"
420 );
421 }
422
423 #[test]
424 fn wrap_single_command_em_dash() {
425 let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
426 assert_eq!(r, "lean-ctx -c 'gh --comment \"closing — see #407\"'");
427 }
428
429 #[test]
430 fn wrap_single_command_dollar_sign() {
431 let r = wrap_single_command("echo $HOME", "lean-ctx");
432 assert_eq!(r, "lean-ctx -c 'echo $HOME'");
433 }
434
435 #[test]
436 fn wrap_single_command_backticks() {
437 let r = wrap_single_command("echo `date`", "lean-ctx");
438 assert_eq!(r, "lean-ctx -c 'echo `date`'");
439 }
440
441 #[test]
442 fn wrap_single_command_nested_single_quotes() {
443 let r = wrap_single_command("echo 'hello world'", "lean-ctx");
444 assert_eq!(r, r"lean-ctx -c 'echo '\''hello world'\'''");
445 }
446
447 #[test]
448 fn wrap_single_command_exclamation_mark() {
449 let r = wrap_single_command("echo hello!", "lean-ctx");
450 assert_eq!(r, "lean-ctx -c 'echo hello!'");
451 }
452
453 #[test]
454 fn wrap_single_command_find_with_many_excludes() {
455 let r = wrap_single_command(
456 "find . -not -path ./node_modules -not -path ./.git -not -path ./dist",
457 "lean-ctx",
458 );
459 assert_eq!(
460 r,
461 "lean-ctx -c 'find . -not -path ./node_modules -not -path ./.git -not -path ./dist'"
462 );
463 }
464}