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('\\', "\\\\").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 let Some(rewritten) = build_rewrite_compound(cmd, binary) {
42 return Some(rewritten);
43 }
44
45 if is_rewritable(cmd) {
46 return Some(wrap_single_command(cmd, binary));
47 }
48
49 None
50}
51
52fn build_rewrite_compound(cmd: &str, binary: &str) -> Option<String> {
53 compound_lexer::rewrite_compound(cmd, |segment| {
54 if segment.starts_with("lean-ctx ") || segment.starts_with(&format!("{binary} ")) {
55 return None;
56 }
57 if is_rewritable(segment) {
58 Some(wrap_single_command(segment, binary))
59 } else {
60 None
61 }
62 })
63}
64
65fn emit_rewrite(rewritten: &str) {
66 let json_escaped = rewritten.replace('\\', "\\\\").replace('"', "\\\"");
67 print!(
68 "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"{json_escaped}\"}}}}}}"
69 );
70}
71
72pub fn handle_redirect() {
73 }
78
79fn codex_reroute_message(rewritten: &str) -> String {
80 format!(
81 "Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: {rewritten}"
82 )
83}
84
85pub fn handle_codex_pretooluse() {
86 let binary = resolve_binary();
87 let mut input = String::new();
88 if std::io::stdin().read_to_string(&mut input).is_err() {
89 return;
90 }
91
92 let tool = extract_json_field(&input, "tool_name");
93 if !matches!(tool.as_deref(), Some("Bash" | "bash")) {
94 return;
95 }
96
97 let cmd = match extract_json_field(&input, "command") {
98 Some(c) => c,
99 None => return,
100 };
101
102 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
103 eprintln!("{}", codex_reroute_message(&rewritten));
104 std::process::exit(2);
105 }
106}
107
108pub fn handle_codex_session_start() {
109 println!(
110 "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."
111 );
112}
113
114pub fn handle_copilot() {
118 let binary = resolve_binary();
119 let mut input = String::new();
120 if std::io::stdin().read_to_string(&mut input).is_err() {
121 return;
122 }
123
124 let tool = extract_json_field(&input, "tool_name");
125 let tool_name = match tool.as_deref() {
126 Some(name) => name,
127 None => return,
128 };
129
130 let is_shell_tool = matches!(
131 tool_name,
132 "Bash" | "bash" | "runInTerminal" | "run_in_terminal" | "terminal" | "shell"
133 );
134 if !is_shell_tool {
135 return;
136 }
137
138 let cmd = match extract_json_field(&input, "command") {
139 Some(c) => c,
140 None => return,
141 };
142
143 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
144 emit_rewrite(&rewritten);
145 }
146}
147
148pub fn handle_rewrite_inline() {
152 let binary = resolve_binary();
153 let args: Vec<String> = std::env::args().collect();
154 if args.len() < 4 {
156 return;
157 }
158 let cmd = args[3..].join(" ");
159
160 if let Some(rewritten) = rewrite_candidate(&cmd, &binary) {
161 print!("{rewritten}");
162 return;
163 }
164
165 if cmd.starts_with("lean-ctx ") || cmd.starts_with(&format!("{binary} ")) {
166 print!("{cmd}");
167 return;
168 }
169
170 print!("{cmd}");
171}
172
173fn resolve_binary() -> String {
174 let path = crate::core::portable_binary::resolve_portable_binary();
175 crate::hooks::to_bash_compatible_path(&path)
176}
177
178fn extract_json_field(input: &str, field: &str) -> Option<String> {
179 let pattern = format!("\"{}\":\"", field);
180 let start = input.find(&pattern)? + pattern.len();
181 let rest = &input[start..];
182 let bytes = rest.as_bytes();
183 let mut end = 0;
184 while end < bytes.len() {
185 if bytes[end] == b'\\' && end + 1 < bytes.len() {
186 end += 2;
187 continue;
188 }
189 if bytes[end] == b'"' {
190 break;
191 }
192 end += 1;
193 }
194 if end >= bytes.len() {
195 return None;
196 }
197 let raw = &rest[..end];
198 Some(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn is_rewritable_basic() {
207 assert!(is_rewritable("git status"));
208 assert!(is_rewritable("cargo test --lib"));
209 assert!(is_rewritable("npm run build"));
210 assert!(!is_rewritable("echo hello"));
211 assert!(!is_rewritable("cd src"));
212 }
213
214 #[test]
215 fn wrap_single() {
216 let r = wrap_single_command("git status", "lean-ctx");
217 assert_eq!(r, r#"lean-ctx -c "git status""#);
218 }
219
220 #[test]
221 fn wrap_with_quotes() {
222 let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
223 assert_eq!(r, r#"lean-ctx -c "curl -H \"Auth\" https://api.com""#);
224 }
225
226 #[test]
227 fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
228 assert_eq!(
229 rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
230 None
231 );
232 }
233
234 #[test]
235 fn rewrite_candidate_wraps_single_command() {
236 assert_eq!(
237 rewrite_candidate("git status", "lean-ctx"),
238 Some(r#"lean-ctx -c "git status""#.to_string())
239 );
240 }
241
242 #[test]
243 fn codex_reroute_message_includes_exact_rewritten_command() {
244 let message = codex_reroute_message(r#"lean-ctx -c "git status""#);
245 assert_eq!(
246 message,
247 r#"Command should run via lean-ctx for compact output. Do not retry the original command. Re-run with: lean-ctx -c "git status""#
248 );
249 }
250
251 #[test]
252 fn compound_rewrite_and_chain() {
253 let result = build_rewrite_compound("cd src && git status && echo done", "lean-ctx");
254 assert_eq!(
255 result,
256 Some(r#"cd src && lean-ctx -c "git status" && echo done"#.into())
257 );
258 }
259
260 #[test]
261 fn compound_rewrite_pipe() {
262 let result = build_rewrite_compound("git log --oneline | head -5", "lean-ctx");
263 assert_eq!(
264 result,
265 Some(r#"lean-ctx -c "git log --oneline" | head -5"#.into())
266 );
267 }
268
269 #[test]
270 fn compound_rewrite_no_match() {
271 let result = build_rewrite_compound("cd src && echo done", "lean-ctx");
272 assert_eq!(result, None);
273 }
274
275 #[test]
276 fn compound_rewrite_multiple_rewritable() {
277 let result = build_rewrite_compound("git add . && cargo test && npm run lint", "lean-ctx");
278 assert_eq!(
279 result,
280 Some(
281 r#"lean-ctx -c "git add ." && lean-ctx -c "cargo test" && lean-ctx -c "npm run lint""#
282 .into()
283 )
284 );
285 }
286
287 #[test]
288 fn compound_rewrite_semicolons() {
289 let result = build_rewrite_compound("git add .; git commit -m 'fix'", "lean-ctx");
290 assert_eq!(
291 result,
292 Some(r#"lean-ctx -c "git add ." ; lean-ctx -c "git commit -m 'fix'""#.into())
293 );
294 }
295
296 #[test]
297 fn compound_rewrite_or_chain() {
298 let result = build_rewrite_compound("git pull || echo failed", "lean-ctx");
299 assert_eq!(
300 result,
301 Some(r#"lean-ctx -c "git pull" || echo failed"#.into())
302 );
303 }
304
305 #[test]
306 fn compound_skips_already_rewritten() {
307 let result = build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx");
308 assert_eq!(
309 result,
310 Some(r#"lean-ctx -c git status && lean-ctx -c "git diff""#.into())
311 );
312 }
313
314 #[test]
315 fn single_command_not_compound() {
316 let result = build_rewrite_compound("git status", "lean-ctx");
317 assert_eq!(result, None);
318 }
319
320 #[test]
321 fn extract_field_works() {
322 let input = r#"{"tool_name":"Bash","command":"git status"}"#;
323 assert_eq!(
324 extract_json_field(input, "tool_name"),
325 Some("Bash".to_string())
326 );
327 assert_eq!(
328 extract_json_field(input, "command"),
329 Some("git status".to_string())
330 );
331 }
332
333 #[test]
334 fn extract_field_handles_escaped_quotes() {
335 let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
336 assert_eq!(
337 extract_json_field(input, "command"),
338 Some(r#"grep -r "TODO" src/"#.to_string())
339 );
340 }
341
342 #[test]
343 fn extract_field_handles_escaped_backslash() {
344 let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
345 assert_eq!(
346 extract_json_field(input, "command"),
347 Some(r#"echo \"hello\""#.to_string())
348 );
349 }
350
351 #[test]
352 fn extract_field_handles_complex_curl() {
353 let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
354 assert_eq!(
355 extract_json_field(input, "command"),
356 Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
357 );
358 }
359
360 #[test]
361 fn to_bash_compatible_path_windows_drive() {
362 let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
363 assert_eq!(p, "/e/packages/lean-ctx.exe");
364 }
365
366 #[test]
367 fn to_bash_compatible_path_backslashes() {
368 let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
369 assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
370 }
371
372 #[test]
373 fn to_bash_compatible_path_unix_unchanged() {
374 let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
375 assert_eq!(p, "/usr/local/bin/lean-ctx");
376 }
377
378 #[test]
379 fn to_bash_compatible_path_msys2_unchanged() {
380 let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
381 assert_eq!(p, "/e/packages/lean-ctx.exe");
382 }
383
384 #[test]
385 fn wrap_command_with_bash_path() {
386 let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
387 let result = wrap_single_command("git status", &binary);
388 assert!(
389 !result.contains('\\'),
390 "wrapped command must not contain backslashes, got: {result}"
391 );
392 assert!(
393 result.starts_with("/e/packages/lean-ctx.exe"),
394 "must use bash-compatible path, got: {result}"
395 );
396 }
397}