1use serde_json::{json, Value};
2use std::path::Path;
3use std::time::{Duration, SystemTime};
4
5const REGEX_SIZE_LIMIT: usize = 10 * 1024 * 1024;
6const GREP_FOOTER_FRESHNESS_WINDOW: Duration = Duration::from_secs(60);
7
8use crate::bash_rewrite::footer::{add_footer, add_grep_footer};
9use crate::bash_rewrite::parser::parse;
10use crate::bash_rewrite::RewriteRule;
11use crate::context::AppContext;
12use crate::protocol::{RawRequest, Response};
13
14pub struct GrepRule;
15pub struct RgRule;
16pub struct FindRule;
17pub struct CatRule;
18pub struct CatAppendRule;
19pub struct SedRule;
20pub struct LsRule;
21
22impl RewriteRule for GrepRule {
23 fn name(&self) -> &'static str {
24 "grep"
25 }
26
27 fn matches(&self, command: &str) -> bool {
28 grep_request(command, "grep").is_some()
29 }
30
31 fn rewrite(
32 &self,
33 command: &str,
34 session_id: Option<&str>,
35 ctx: &AppContext,
36 ) -> Result<Response, String> {
37 let params = grep_request(command, "grep").ok_or("not a grep rewrite")?;
38 let path = params
39 .get("path")
40 .and_then(Value::as_str)
41 .map(str::to_owned);
42 try_call_and_grep_footer(
43 crate::commands::grep::handle_grep(&request("grep", params, session_id), ctx),
44 ctx,
45 path.as_deref(),
46 )
47 }
48}
49
50impl RewriteRule for RgRule {
51 fn name(&self) -> &'static str {
52 "rg"
53 }
54
55 fn matches(&self, command: &str) -> bool {
56 grep_request(command, "rg").is_some()
57 }
58
59 fn rewrite(
60 &self,
61 command: &str,
62 session_id: Option<&str>,
63 ctx: &AppContext,
64 ) -> Result<Response, String> {
65 let params = grep_request(command, "rg").ok_or("not an rg rewrite")?;
66 let path = params
67 .get("path")
68 .and_then(Value::as_str)
69 .map(str::to_owned);
70 try_call_and_grep_footer(
71 crate::commands::grep::handle_grep(&request("grep", params, session_id), ctx),
72 ctx,
73 path.as_deref(),
74 )
75 }
76}
77
78impl RewriteRule for FindRule {
79 fn name(&self) -> &'static str {
80 "find"
81 }
82
83 fn matches(&self, command: &str) -> bool {
84 find_request(command).is_some()
85 }
86
87 fn rewrite(
88 &self,
89 command: &str,
90 session_id: Option<&str>,
91 ctx: &AppContext,
92 ) -> Result<Response, String> {
93 let params = find_request(command).ok_or("not a find rewrite")?;
94 try_call_and_footer(
95 crate::commands::glob::handle_glob(&request("glob", params, session_id), ctx),
96 "glob",
97 )
98 }
99}
100
101impl RewriteRule for CatRule {
102 fn name(&self) -> &'static str {
103 "cat"
104 }
105
106 fn matches(&self, command: &str) -> bool {
107 cat_read_request(command).is_some()
108 }
109
110 fn rewrite(
111 &self,
112 command: &str,
113 session_id: Option<&str>,
114 ctx: &AppContext,
115 ) -> Result<Response, String> {
116 let params = cat_read_request(command).ok_or("not a cat rewrite")?;
117 try_call_and_footer(
118 crate::commands::read::handle_read(&request("read", params, session_id), ctx),
119 "read",
120 )
121 }
122}
123
124impl RewriteRule for CatAppendRule {
125 fn name(&self) -> &'static str {
126 "cat_append"
127 }
128
129 fn matches(&self, command: &str) -> bool {
130 append_request(command).is_some()
131 }
132
133 fn rewrite(
134 &self,
135 command: &str,
136 session_id: Option<&str>,
137 ctx: &AppContext,
138 ) -> Result<Response, String> {
139 let params = append_request(command).ok_or("not an append rewrite")?;
140 try_call_and_footer(
141 crate::commands::edit_match::handle_edit_match(
142 &request("edit_match", params, session_id),
143 ctx,
144 ),
145 "edit",
146 )
147 }
148}
149
150impl RewriteRule for SedRule {
151 fn name(&self) -> &'static str {
152 "sed"
153 }
154
155 fn matches(&self, command: &str) -> bool {
156 sed_request(command).is_some()
157 }
158
159 fn rewrite(
160 &self,
161 command: &str,
162 session_id: Option<&str>,
163 ctx: &AppContext,
164 ) -> Result<Response, String> {
165 let params = sed_request(command).ok_or("not a sed rewrite")?;
166 try_call_and_footer(
167 crate::commands::read::handle_read(&request("read", params, session_id), ctx),
168 "read",
169 )
170 }
171}
172
173impl RewriteRule for LsRule {
174 fn name(&self) -> &'static str {
175 "ls"
176 }
177
178 fn matches(&self, command: &str) -> bool {
179 ls_request(command).is_some()
180 }
181
182 fn rewrite(
183 &self,
184 command: &str,
185 session_id: Option<&str>,
186 ctx: &AppContext,
187 ) -> Result<Response, String> {
188 let params = ls_request(command).ok_or("not an ls rewrite")?;
189 try_call_and_footer(
190 crate::commands::read::handle_read(&request("read", params, session_id), ctx),
191 "read",
192 )
193 }
194}
195
196fn request(command: &str, params: Value, session_id: Option<&str>) -> RawRequest {
197 RawRequest {
198 id: "bash_rewrite".to_string(),
199 command: command.to_string(),
200 lsp_hints: None,
201 session_id: session_id.map(str::to_string),
202 params,
203 }
204}
205
206fn try_call_and_footer(response: Response, replacement_tool: &str) -> Result<Response, String> {
212 if let Some(err) = declined_error(&response, replacement_tool) {
213 return Err(err);
214 }
215 Ok(call_and_footer(response, replacement_tool))
216}
217
218fn try_call_and_grep_footer(
221 response: Response,
222 ctx: &AppContext,
223 path: Option<&str>,
224) -> Result<Response, String> {
225 if let Some(err) = declined_error(&response, "grep") {
226 return Err(err);
227 }
228 let output = response_output(&response.data);
229 let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) {
230 output
231 } else {
232 add_grep_footer(&output, ctx.config().aft_search_registered)
233 };
234 Ok(apply_footer(response, footered))
235}
236
237fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf {
238 let configured = ctx
239 .config()
240 .project_root
241 .clone()
242 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
243 std::fs::canonicalize(&configured).unwrap_or(configured)
244}
245
246fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool {
247 let Some(path) = path else {
248 return false;
249 };
250 let project_root =
255 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
256 let project_root = project_root.as_path();
257 let target = Path::new(path);
258 let target = if target.is_absolute() {
259 target.to_path_buf()
260 } else {
261 project_root.join(target)
262 };
263 let Ok(target) = std::fs::canonicalize(target) else {
264 return false;
265 };
266 if !target.starts_with(project_root) {
267 return true;
268 }
269 let Ok(metadata) = std::fs::metadata(&target) else {
270 return false;
271 };
272 if metadata.is_file() {
273 return true;
274 }
275 let Ok(modified) = metadata.modified() else {
276 return false;
277 };
278 SystemTime::now()
279 .duration_since(modified)
280 .is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW)
281}
282
283fn declined_error(response: &Response, replacement_tool: &str) -> Option<String> {
284 if response.success {
285 return None;
286 }
287 let message = response
288 .data
289 .get("message")
290 .and_then(Value::as_str)
291 .or_else(|| response.data.get("code").and_then(Value::as_str))
292 .unwrap_or("error");
293 Some(format!("{replacement_tool} declined: {message}"))
294}
295
296fn call_and_footer(response: Response, replacement_tool: &str) -> Response {
297 let output = response_output(&response.data);
298 let footered = add_footer(&output, replacement_tool);
299 apply_footer(response, footered)
300}
301
302fn apply_footer(mut response: Response, output: String) -> Response {
303 if let Some(object) = response.data.as_object_mut() {
304 object.insert("output".to_string(), Value::String(output.clone()));
305
306 for key in ["text", "content", "message"] {
307 if object.get(key).is_some_and(Value::is_string) {
308 object.insert(key.to_string(), Value::String(output.clone()));
309 break;
310 }
311 }
312 } else {
313 response.data = json!({ "output": output });
314 }
315
316 response
317}
318
319fn response_output(data: &Value) -> String {
320 if let Some(output) = data.get("output").and_then(Value::as_str) {
321 return output.to_string();
322 }
323 if let Some(text) = data.get("text").and_then(Value::as_str) {
324 return text.to_string();
325 }
326 if let Some(content) = data.get("content").and_then(Value::as_str) {
327 return content.to_string();
328 }
329 if let Some(message) = data.get("message").and_then(Value::as_str) {
330 return message.to_string();
331 }
332 if let Some(entries) = data.get("entries").and_then(Value::as_array) {
333 return entries
334 .iter()
335 .filter_map(Value::as_str)
336 .collect::<Vec<_>>()
337 .join("\n");
338 }
339 serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
340}
341
342fn grep_request(command: &str, binary: &str) -> Option<Value> {
343 let parsed = parse(command)?;
344 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
345 return None;
346 }
347
348 let mut case_sensitive = true;
349 let mut word_match = false;
350 let mut index = 1;
351
352 while let Some(arg) = parsed.args.get(index) {
353 if !arg.starts_with('-') || arg == "-" {
354 break;
355 }
356 for flag in arg[1..].chars() {
357 match flag {
358 'n' | 'r' => {}
359 'i' => case_sensitive = false,
360 'w' => word_match = true,
361 _ => return None,
362 }
363 }
364 index += 1;
365 }
366
367 let pattern = parsed.args.get(index)?.clone();
368 let path = parsed.args.get(index + 1).cloned();
369 if parsed.args.len() > index + 2 {
370 return None;
371 }
372
373 let pattern = if word_match {
374 format!(r"\b(?:{})\b", pattern)
375 } else {
376 pattern
377 };
378
379 if regex::RegexBuilder::new(&pattern)
380 .size_limit(REGEX_SIZE_LIMIT)
381 .build()
382 .is_err()
383 {
384 return None;
385 }
386
387 let mut params = json!({
388 "pattern": pattern,
389 "case_sensitive": case_sensitive,
390 "max_results": 100,
391 });
392 if let Some(path) = path {
393 params["path"] = json!(path);
394 }
395 Some(params)
396}
397
398fn find_request(command: &str) -> Option<Value> {
399 let parsed = parse(command)?;
400 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
401 return None;
402 }
403 if parsed.args.len() != 4 && parsed.args.len() != 6 {
404 return None;
405 }
406
407 let path = parsed.args.get(1)?.clone();
408 let mut name = None;
409 let mut saw_type_file = false;
410 let mut index = 2;
411
412 while index < parsed.args.len() {
413 match parsed.args[index].as_str() {
414 "-name" if name.is_none() && index + 1 < parsed.args.len() => {
415 name = Some(parsed.args[index + 1].clone());
416 index += 2;
417 }
418 "-type" if !saw_type_file && index + 1 < parsed.args.len() => {
419 if parsed.args[index + 1] != "f" {
420 return None;
421 }
422 saw_type_file = true;
423 index += 2;
424 }
425 _ => return None,
426 }
427 }
428
429 let name = name?;
430 let pattern = format!("**/{name}");
431 if path == "." {
432 Some(json!({ "pattern": pattern }))
433 } else {
434 let trimmed = path.trim_end_matches('/');
435 if trimmed.is_empty() {
436 None
442 } else {
443 Some(json!({ "path": trimmed, "pattern": pattern }))
444 }
445 }
446}
447
448fn cat_read_request(command: &str) -> Option<Value> {
449 let parsed = parse(command)?;
450 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
451 return None;
452 }
453 if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
454 return None;
455 }
456 Some(json!({ "file": parsed.args[1] }))
457}
458
459fn append_request(command: &str) -> Option<Value> {
460 let parsed = parse(command)?;
461 let file = parsed.appends_to.clone()?;
462
463 let append_content = if parsed.args == ["cat"] {
464 parsed.heredoc?
465 } else if parsed.heredoc.is_none()
466 && parsed.args.first().is_some_and(|arg| arg == "echo")
467 && parsed.args.len() >= 2
468 && !parsed.args[1].starts_with('-')
469 {
470 format!("{}\n", parsed.args[1..].join(" "))
471 } else {
472 return None;
473 };
474
475 Some(json!({
476 "op": "append",
477 "file": file,
478 "append_content": append_content,
479 "create_dirs": true,
480 }))
481}
482
483fn sed_request(command: &str) -> Option<Value> {
484 let parsed = parse(command)?;
485 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
486 return None;
487 }
488 if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
489 return None;
490 }
491
492 let range = parsed.args[2].strip_suffix('p')?;
493 let (start, end) = range.split_once(',')?;
494 let start_line = start.parse::<u32>().ok()?;
495 let end_line = end.parse::<u32>().ok()?;
496 if start_line == 0 || end_line < start_line {
497 return None;
498 }
499
500 Some(json!({
501 "file": parsed.args[3],
502 "start_line": start_line,
503 "end_line": end_line,
504 }))
505}
506
507fn ls_request(command: &str) -> Option<Value> {
508 let parsed = parse(command)?;
509 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
510 return None;
511 }
512
513 let mut path = None;
514 let mut include_hidden = false;
515 for arg in parsed.args.iter().skip(1) {
516 if let Some(flags) = arg.strip_prefix('-') {
517 if flags.is_empty() {
518 return None;
519 }
520 for flag in flags.chars() {
521 match flag {
522 'R' => {}
526 'a' => include_hidden = true,
530 'A' => return None,
534 _ => return None,
540 }
541 }
542 } else if path.is_none() {
543 path = Some(arg.clone());
544 } else {
545 return None;
546 }
547 }
548
549 let target = path.clone().unwrap_or_else(|| ".".to_string());
555 if let Ok(metadata) = std::fs::metadata(&target) {
556 if !metadata.is_dir() {
557 return None;
558 }
559 }
560 else if path.is_some() {
564 return None;
565 }
566
567 Some(json!({ "file": target, "include_hidden": include_hidden }))
568}
569
570#[cfg(test)]
571mod tests {
572 use std::fs;
573 use std::time::{Duration, SystemTime};
574
575 use serde_json::json;
576
577 use super::{find_request, should_suppress_grep_footer};
578
579 fn fixture() -> tempfile::TempDir {
580 let dir = tempfile::tempdir().unwrap();
581 fs::create_dir(dir.path().join("src")).unwrap();
582 fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
583 dir
584 }
585
586 #[test]
587 fn single_named_file_suppresses_grep_footer() {
588 let dir = fixture();
589 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
590 }
591
592 #[test]
593 fn directory_path_keeps_grep_footer() {
594 let dir = fixture();
595 filetime::set_file_mtime(
596 dir.path().join("src"),
597 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
598 )
599 .unwrap();
600 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
601 }
602
603 #[test]
604 fn no_path_keeps_grep_footer() {
605 let dir = fixture();
606 assert!(!should_suppress_grep_footer(None, dir.path()));
607 }
608
609 #[test]
610 fn external_file_suppresses_grep_footer() {
611 let dir = fixture();
612 let external = tempfile::NamedTempFile::new().unwrap();
613 assert!(should_suppress_grep_footer(
614 external.path().to_str(),
615 dir.path()
616 ));
617 }
618
619 #[test]
620 fn freshly_modified_file_suppresses_grep_footer() {
621 let dir = fixture();
622 let file = dir.path().join("src/app.ts");
623 fs::write(&file, "foo\nbar\n").unwrap();
624 assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
625 }
626
627 #[test]
628 fn old_directory_inside_project_root_keeps_grep_footer() {
629 let dir = fixture();
630 let directory = dir.path().join("src");
631 filetime::set_file_mtime(
632 &directory,
633 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
634 )
635 .unwrap();
636 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
637 }
638
639 #[test]
640 fn old_file_inside_project_root_suppresses_grep_footer() {
641 let dir = fixture();
642 let file = dir.path().join("src/app.ts");
643 filetime::set_file_mtime(
644 &file,
645 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
646 )
647 .unwrap();
648 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
649 }
650
651 #[test]
652 fn find_absolute_path_uses_glob_path_arg() {
653 assert_eq!(
654 find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
655 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
656 );
657 }
658
659 #[test]
660 fn find_dot_keeps_project_root_relative_pattern() {
661 assert_eq!(
662 find_request(r#"find . -name "*.ts" -type f"#),
663 Some(json!({ "pattern": "**/*.ts" }))
664 );
665 }
666
667 #[test]
668 fn find_relative_path_uses_glob_path_arg() {
669 assert_eq!(
670 find_request(r#"find ./src -name "*.go""#),
671 Some(json!({ "path": "./src", "pattern": "**/*.go" }))
672 );
673 }
674
675 #[test]
676 fn find_trims_trailing_slash_from_path_arg() {
677 assert_eq!(
678 find_request(r#"find /tmp/foo/ -name "*.ts""#),
679 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
680 );
681 }
682
683 #[test]
684 fn find_filesystem_root_is_not_rewritten() {
685 assert_eq!(find_request(r#"find / -name "*.rs""#), None);
688 assert_eq!(find_request(r#"find // -name "*.rs""#), None);
689 }
690}