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::{RewriteRequest, 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 decide(
28 &self,
29 command: &str,
30 request_id: &str,
31 session_id: Option<&str>,
32 ctx: &AppContext,
33 ) -> crate::bash_rewrite::RewriteDecision {
34 let Some(params) = grep_request(command, "grep") else {
35 return decline("grep", "grep.decline", "unsupported grep shape");
36 };
37 if let Some(path) = params.get("path").and_then(Value::as_str) {
38 if !path_is_safe(ctx, path, true) {
39 return decline(
40 "grep",
41 "grep.decline",
42 "grep path is outside the project root or missing",
43 );
44 }
45 }
46 accept(
47 "grep",
48 "grep.accept",
49 "dc.grep.accept.v1",
50 command,
51 request_id,
52 session_id,
53 params,
54 )
55 }
56
57 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
58 let path = request.params.get("path").and_then(Value::as_str);
59 let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
60 grep_footer_response(response, ctx, path)
61 }
62}
63
64impl RewriteRule for RgRule {
65 fn name(&self) -> &'static str {
66 "rg"
67 }
68
69 fn decide(
70 &self,
71 command: &str,
72 request_id: &str,
73 session_id: Option<&str>,
74 ctx: &AppContext,
75 ) -> crate::bash_rewrite::RewriteDecision {
76 let Some(params) = grep_request(command, "rg") else {
77 return decline("rg", "rg.decline", "unsupported rg shape");
78 };
79 if let Some(path) = params.get("path").and_then(Value::as_str) {
80 if !path_is_safe(ctx, path, true) {
81 return decline(
82 "rg",
83 "rg.decline",
84 "rg path is outside the project root or missing",
85 );
86 }
87 }
88 accept(
89 "rg",
90 "rg.accept",
91 "dc.rg.accept.v1",
92 command,
93 request_id,
94 session_id,
95 params,
96 )
97 }
98
99 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
100 let path = request.params.get("path").and_then(Value::as_str);
101 let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
102 grep_footer_response(response, ctx, path)
103 }
104}
105
106impl RewriteRule for FindRule {
107 fn name(&self) -> &'static str {
108 "find"
109 }
110
111 fn decide(
112 &self,
113 command: &str,
114 request_id: &str,
115 session_id: Option<&str>,
116 ctx: &AppContext,
117 ) -> crate::bash_rewrite::RewriteDecision {
118 let Some(params) = find_request(command) else {
119 return decline("find", "find.decline", "unsupported find shape");
120 };
121 if let Some(path) = params.get("path").and_then(Value::as_str) {
122 if !path_is_safe(ctx, path, true) {
123 return decline(
124 "find",
125 "find.decline",
126 "find path is outside the project root or missing",
127 );
128 }
129 }
130 accept(
131 "find",
132 "find.accept",
133 "dc.find.accept.v1",
134 command,
135 request_id,
136 session_id,
137 params,
138 )
139 }
140
141 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
142 call_and_footer(
143 crate::commands::glob::handle_glob(&tool_request("glob", request, ctx), ctx),
144 "glob",
145 )
146 }
147}
148
149impl RewriteRule for CatRule {
150 fn name(&self) -> &'static str {
151 "cat"
152 }
153
154 fn decide(
155 &self,
156 command: &str,
157 request_id: &str,
158 session_id: Option<&str>,
159 ctx: &AppContext,
160 ) -> crate::bash_rewrite::RewriteDecision {
161 let Some(params) = cat_read_request(command) else {
162 return decline("cat", "cat.decline", "unsupported cat shape");
163 };
164 let path = params
165 .get("file")
166 .and_then(Value::as_str)
167 .unwrap_or_default();
168 if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
169 crate::slog_warn!(
170 "bash rewrite rule cat declined: read declined: path is outside the project root or exceeds the read contract"
171 );
172 return decline(
173 "cat",
174 "cat.decline",
175 "read path is outside the project root or exceeds the read contract",
176 );
177 }
178 accept(
179 "cat",
180 "cat.accept",
181 "dc.cat.accept.v1",
182 command,
183 request_id,
184 session_id,
185 params,
186 )
187 }
188
189 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
190 call_and_footer(
191 crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
192 "read",
193 )
194 }
195}
196
197impl RewriteRule for CatAppendRule {
198 fn name(&self) -> &'static str {
199 "cat_append"
200 }
201
202 fn decide(
203 &self,
204 command: &str,
205 request_id: &str,
206 session_id: Option<&str>,
207 ctx: &AppContext,
208 ) -> crate::bash_rewrite::RewriteDecision {
209 let Some(params) = append_request(command) else {
210 return decline(
211 "cat_append",
212 "cat_append.decline",
213 "unsupported append shape",
214 );
215 };
216 let path = params
217 .get("file")
218 .and_then(Value::as_str)
219 .unwrap_or_default();
220 if !append_path_is_safe(ctx, path) {
221 return decline(
222 "cat_append",
223 "cat_append.decline",
224 "append path is outside the project root or has no existing parent",
225 );
226 }
227 accept(
228 "cat_append",
229 "cat_append.accept",
230 "dc.cat_append.accept.v1",
231 command,
232 request_id,
233 session_id,
234 params,
235 )
236 }
237
238 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
239 call_and_footer(
240 crate::commands::edit_match::handle_edit_match(
241 &tool_request("edit_match", request, ctx),
242 ctx,
243 ),
244 "edit",
245 )
246 }
247}
248
249impl RewriteRule for SedRule {
250 fn name(&self) -> &'static str {
251 "sed"
252 }
253
254 fn decide(
255 &self,
256 command: &str,
257 request_id: &str,
258 session_id: Option<&str>,
259 ctx: &AppContext,
260 ) -> crate::bash_rewrite::RewriteDecision {
261 let Some(params) = sed_request(command) else {
262 return decline("sed", "sed.decline", "unsupported sed shape");
263 };
264 let path = params
265 .get("file")
266 .and_then(Value::as_str)
267 .unwrap_or_default();
268 if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
269 return decline(
270 "sed",
271 "sed.decline",
272 "sed path is outside the project root or exceeds the read contract",
273 );
274 }
275 accept(
276 "sed",
277 "sed.accept",
278 "dc.sed.accept.v1",
279 command,
280 request_id,
281 session_id,
282 params,
283 )
284 }
285
286 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
287 call_and_footer(
288 crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
289 "read",
290 )
291 }
292}
293
294impl RewriteRule for LsRule {
295 fn name(&self) -> &'static str {
296 "ls"
297 }
298
299 fn decide(
300 &self,
301 command: &str,
302 request_id: &str,
303 session_id: Option<&str>,
304 ctx: &AppContext,
305 ) -> crate::bash_rewrite::RewriteDecision {
306 let Some(params) = ls_request(command, ctx) else {
307 return decline("ls", "ls.decline", "unsupported ls shape or target");
308 };
309 accept(
310 "ls",
311 "ls.accept",
312 "dc.ls.accept.v1",
313 command,
314 request_id,
315 session_id,
316 params,
317 )
318 }
319
320 fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
321 call_and_footer(
322 crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
323 "read",
324 )
325 }
326}
327
328fn accept(
329 rule_id: &'static str,
330 branch_id: &'static str,
331 decision_class_id: &'static str,
332 command: &str,
333 request_id: &str,
334 session_id: Option<&str>,
335 params: Value,
336) -> crate::bash_rewrite::RewriteDecision {
337 crate::bash_rewrite::RewriteDecision::Accept(RewriteRequest {
338 request_id: request_id.to_string(),
339 command: command.to_string(),
340 session_id: session_id.map(str::to_owned),
341 rule_id,
342 branch_id,
343 decision_class_id,
344 params,
345 })
346}
347
348fn decline(
349 rule_id: &'static str,
350 branch_id: &'static str,
351 reason: &str,
352) -> crate::bash_rewrite::RewriteDecision {
353 let decision_class_id = match rule_id {
354 "grep" => "dc.grep.decline.v1",
355 "rg" => "dc.rg.decline.v1",
356 "find" => "dc.find.decline.v1",
357 "cat" => "dc.cat.decline.v1",
358 "cat_append" => "dc.cat_append.decline.v1",
359 "sed" => "dc.sed.decline.v1",
360 "ls" => "dc.ls.decline.v1",
361 _ => "dc.native.decline.v1",
362 };
363 crate::bash_rewrite::RewriteDecision::Decline(crate::bash_rewrite::DeclineReason {
364 rule_id: Some(rule_id),
365 branch_id,
366 decision_class_id,
367 reason: reason.to_string(),
368 })
369}
370
371fn tool_request(tool: &str, request: &RewriteRequest, ctx: &AppContext) -> RawRequest {
372 let mut params = request.params.clone();
373 let root = grep_project_root(ctx);
374 if matches!(tool, "read" | "edit_match") {
375 if let Some(file) = params.get("file").and_then(Value::as_str) {
376 let path = Path::new(file);
377 if path.is_relative() {
378 params["file"] = Value::String(root.join(path).display().to_string());
379 }
380 }
381 }
382 if tool == "glob" && params.get("path").is_none() {
383 params["path"] = Value::String(root.display().to_string());
384 }
385 RawRequest {
386 id: request.request_id.clone(),
387 command: tool.to_string(),
388 lsp_hints: None,
389 session_id: request.session_id.clone(),
390 params,
391 }
392}
393
394fn call_and_footer(response: Response, replacement_tool: &str) -> Response {
398 let output = response_output(&response.data);
399 let footered = add_footer(&output, replacement_tool);
400 apply_footer(response, footered)
401}
402
403fn grep_footer_response(response: Response, ctx: &AppContext, path: Option<&str>) -> Response {
404 let output = response_output(&response.data);
405 let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) {
406 output
407 } else {
408 add_grep_footer(&output, ctx.config().aft_search_registered)
409 };
410 apply_footer(response, footered)
411}
412
413fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf {
414 let configured = ctx
415 .config()
416 .project_root
417 .clone()
418 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
419 std::fs::canonicalize(&configured).unwrap_or(configured)
420}
421
422fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool {
423 let Some(path) = path else {
424 return false;
425 };
426 let project_root =
431 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
432 let project_root = project_root.as_path();
433 let target = Path::new(path);
434 let target = if target.is_absolute() {
435 target.to_path_buf()
436 } else {
437 project_root.join(target)
438 };
439 let Ok(target) = std::fs::canonicalize(target) else {
440 return false;
441 };
442 if !target.starts_with(project_root) {
443 return true;
444 }
445 let Ok(metadata) = std::fs::metadata(&target) else {
446 return false;
447 };
448 if metadata.is_file() {
449 return true;
450 }
451 let Ok(modified) = metadata.modified() else {
452 return false;
453 };
454 SystemTime::now()
455 .duration_since(modified)
456 .is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW)
457}
458
459fn apply_footer(mut response: Response, output: String) -> Response {
460 if let Some(object) = response.data.as_object_mut() {
461 object.insert("output".to_string(), Value::String(output.clone()));
462
463 for key in ["text", "content", "message"] {
464 if object.get(key).is_some_and(Value::is_string) {
465 object.insert(key.to_string(), Value::String(output.clone()));
466 break;
467 }
468 }
469 } else {
470 response.data = json!({ "output": output });
471 }
472
473 response
474}
475
476fn response_output(data: &Value) -> String {
477 if let Some(output) = data.get("output").and_then(Value::as_str) {
478 return output.to_string();
479 }
480 if let Some(text) = data.get("text").and_then(Value::as_str) {
481 return text.to_string();
482 }
483 if let Some(content) = data.get("content").and_then(Value::as_str) {
484 return content.to_string();
485 }
486 if let Some(message) = data.get("message").and_then(Value::as_str) {
487 return message.to_string();
488 }
489 if let Some(entries) = data.get("entries").and_then(Value::as_array) {
490 return entries
491 .iter()
492 .filter_map(Value::as_str)
493 .collect::<Vec<_>>()
494 .join("\n");
495 }
496 serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
497}
498
499fn path_is_safe(ctx: &AppContext, path: &str, require_existing: bool) -> bool {
500 let root = grep_project_root(ctx);
501 let candidate = Path::new(path);
502 let candidate = if candidate.is_absolute() {
503 candidate.to_path_buf()
504 } else {
505 root.join(candidate)
506 };
507 if require_existing && !candidate.exists() {
508 return false;
509 }
510 let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
511 resolved.starts_with(&root)
512}
513
514fn read_shape_is_faithful(ctx: &AppContext, path: &str) -> bool {
515 let root = grep_project_root(ctx);
516 let candidate = if Path::new(path).is_absolute() {
517 Path::new(path).to_path_buf()
518 } else {
519 root.join(path)
520 };
521 let Ok(metadata) = std::fs::metadata(&candidate) else {
522 return false;
523 };
524 if !metadata.is_file() || metadata.len() > 50 * 1024 {
525 return false;
526 }
527 let Ok(bytes) = std::fs::read(candidate) else {
528 return false;
529 };
530 let Ok(text) = std::str::from_utf8(&bytes) else {
531 return false;
532 };
533 text.lines().all(|line| line.len() <= 2_000)
534}
535
536fn append_path_is_safe(ctx: &AppContext, path: &str) -> bool {
537 let root = grep_project_root(ctx);
538 let candidate = if Path::new(path).is_absolute() {
539 Path::new(path).to_path_buf()
540 } else {
541 root.join(path)
542 };
543 let parent = candidate.parent().unwrap_or(&root);
544 if !parent.exists() {
545 return false;
546 }
547 let resolved_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
548 if !resolved_parent.starts_with(&root) {
549 return false;
550 }
551 if candidate.exists() {
552 let Ok(resolved) = std::fs::canonicalize(&candidate) else {
553 return false;
554 };
555 resolved.starts_with(&root)
556 } else {
557 true
558 }
559}
560
561fn grep_request(command: &str, binary: &str) -> Option<Value> {
562 let parsed = parse(command)?;
563 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
564 return None;
565 }
566
567 let mut case_sensitive = true;
568 let mut word_match = false;
569 let mut index = 1;
570
571 while let Some(arg) = parsed.args.get(index) {
572 if !arg.starts_with('-') || arg == "-" {
573 break;
574 }
575 for flag in arg[1..].chars() {
576 match flag {
577 'n' | 'r' => {}
578 'i' => case_sensitive = false,
579 'w' => word_match = true,
580 _ => return None,
581 }
582 }
583 index += 1;
584 }
585
586 let pattern = parsed.args.get(index)?.clone();
587 let path = parsed.args.get(index + 1).cloned();
588 if parsed.args.len() > index + 2 {
589 return None;
590 }
591
592 let pattern = if word_match {
593 format!(r"\b(?:{})\b", pattern)
594 } else {
595 pattern
596 };
597
598 if regex::RegexBuilder::new(&pattern)
599 .size_limit(REGEX_SIZE_LIMIT)
600 .build()
601 .is_err()
602 {
603 return None;
604 }
605
606 let mut params = json!({
607 "pattern": pattern,
608 "case_sensitive": case_sensitive,
609 "max_results": 100,
610 });
611 if let Some(path) = path {
612 params["path"] = json!(path);
613 }
614 Some(params)
615}
616
617fn find_request(command: &str) -> Option<Value> {
618 let parsed = parse(command)?;
619 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
620 return None;
621 }
622 if parsed.args.len() != 4 && parsed.args.len() != 6 {
623 return None;
624 }
625
626 let path = parsed.args.get(1)?.clone();
627 let mut name = None;
628 let mut saw_type_file = false;
629 let mut index = 2;
630
631 while index < parsed.args.len() {
632 match parsed.args[index].as_str() {
633 "-name" if name.is_none() && index + 1 < parsed.args.len() => {
634 name = Some(parsed.args[index + 1].clone());
635 index += 2;
636 }
637 "-type" if !saw_type_file && index + 1 < parsed.args.len() => {
638 if parsed.args[index + 1] != "f" {
639 return None;
640 }
641 saw_type_file = true;
642 index += 2;
643 }
644 _ => return None,
645 }
646 }
647
648 let name = name?;
649 let pattern = format!("**/{name}");
650 if path == "." {
651 Some(json!({ "pattern": pattern }))
652 } else {
653 let trimmed = path.trim_end_matches('/');
654 if trimmed.is_empty() {
655 None
661 } else {
662 Some(json!({ "path": trimmed, "pattern": pattern }))
663 }
664 }
665}
666
667fn cat_read_request(command: &str) -> Option<Value> {
668 let parsed = parse(command)?;
669 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
670 return None;
671 }
672 if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
673 return None;
674 }
675 Some(json!({ "file": parsed.args[1] }))
676}
677
678fn append_request(command: &str) -> Option<Value> {
679 let parsed = parse(command)?;
680 let file = parsed.appends_to.clone()?;
681
682 let append_content = if parsed.args == ["cat"] {
683 parsed.heredoc?
684 } else if parsed.heredoc.is_none()
685 && parsed.args.first().is_some_and(|arg| arg == "echo")
686 && parsed.args.len() >= 2
687 && !parsed.args[1].starts_with('-')
688 {
689 format!("{}\n", parsed.args[1..].join(" "))
690 } else {
691 return None;
692 };
693
694 Some(json!({
695 "op": "append",
696 "file": file,
697 "append_content": append_content,
698 "create_dirs": true,
699 }))
700}
701
702fn sed_request(command: &str) -> Option<Value> {
703 let parsed = parse(command)?;
704 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
705 return None;
706 }
707 if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
708 return None;
709 }
710
711 let range = parsed.args[2].strip_suffix('p')?;
712 let (start, end) = range.split_once(',')?;
713 let start_line = start.parse::<u32>().ok()?;
714 let end_line = end.parse::<u32>().ok()?;
715 if start_line == 0 || end_line < start_line {
716 return None;
717 }
718
719 Some(json!({
720 "file": parsed.args[3],
721 "start_line": start_line,
722 "end_line": end_line,
723 }))
724}
725
726fn ls_request(command: &str, ctx: &AppContext) -> Option<Value> {
727 let parsed = parse(command)?;
728 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
729 return None;
730 }
731
732 let mut path = None;
733 let mut include_hidden = false;
734 for arg in parsed.args.iter().skip(1) {
735 if let Some(flags) = arg.strip_prefix('-') {
736 if flags.is_empty() {
737 return None;
738 }
739 for flag in flags.chars() {
740 match flag {
741 'R' => {}
745 'a' => include_hidden = true,
749 'A' => return None,
753 _ => return None,
759 }
760 }
761 } else if path.is_none() {
762 path = Some(arg.clone());
763 } else {
764 return None;
765 }
766 }
767
768 let target = path.clone().unwrap_or_else(|| ".".to_string());
774 let root = grep_project_root(ctx);
775 let target_for_metadata = if Path::new(&target).is_absolute() {
776 Path::new(&target).to_path_buf()
777 } else {
778 root.join(&target)
779 };
780 if let Ok(metadata) = std::fs::metadata(&target_for_metadata) {
781 if !metadata.is_dir() || !path_is_safe(ctx, &target, true) {
782 return None;
783 }
784 } else {
785 return None;
789 }
790
791 Some(json!({ "file": target, "include_hidden": include_hidden }))
792}
793
794#[cfg(test)]
795mod tests {
796 use std::fs;
797 use std::time::{Duration, SystemTime};
798
799 use serde_json::json;
800
801 use super::{find_request, should_suppress_grep_footer};
802
803 fn fixture() -> tempfile::TempDir {
804 let dir = tempfile::tempdir().unwrap();
805 fs::create_dir(dir.path().join("src")).unwrap();
806 fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
807 dir
808 }
809
810 #[test]
811 fn single_named_file_suppresses_grep_footer() {
812 let dir = fixture();
813 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
814 }
815
816 #[test]
817 fn directory_path_keeps_grep_footer() {
818 let dir = fixture();
819 filetime::set_file_mtime(
820 dir.path().join("src"),
821 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
822 )
823 .unwrap();
824 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
825 }
826
827 #[test]
828 fn no_path_keeps_grep_footer() {
829 let dir = fixture();
830 assert!(!should_suppress_grep_footer(None, dir.path()));
831 }
832
833 #[test]
834 fn external_file_suppresses_grep_footer() {
835 let dir = fixture();
836 let external = tempfile::NamedTempFile::new().unwrap();
837 assert!(should_suppress_grep_footer(
838 external.path().to_str(),
839 dir.path()
840 ));
841 }
842
843 #[test]
844 fn freshly_modified_file_suppresses_grep_footer() {
845 let dir = fixture();
846 let file = dir.path().join("src/app.ts");
847 fs::write(&file, "foo\nbar\n").unwrap();
848 assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
849 }
850
851 #[test]
852 fn old_directory_inside_project_root_keeps_grep_footer() {
853 let dir = fixture();
854 let directory = dir.path().join("src");
855 filetime::set_file_mtime(
856 &directory,
857 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
858 )
859 .unwrap();
860 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
861 }
862
863 #[test]
864 fn old_file_inside_project_root_suppresses_grep_footer() {
865 let dir = fixture();
866 let file = dir.path().join("src/app.ts");
867 filetime::set_file_mtime(
868 &file,
869 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
870 )
871 .unwrap();
872 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
873 }
874
875 #[test]
876 fn find_absolute_path_uses_glob_path_arg() {
877 assert_eq!(
878 find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
879 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
880 );
881 }
882
883 #[test]
884 fn find_dot_keeps_project_root_relative_pattern() {
885 assert_eq!(
886 find_request(r#"find . -name "*.ts" -type f"#),
887 Some(json!({ "pattern": "**/*.ts" }))
888 );
889 }
890
891 #[test]
892 fn find_relative_path_uses_glob_path_arg() {
893 assert_eq!(
894 find_request(r#"find ./src -name "*.go""#),
895 Some(json!({ "path": "./src", "pattern": "**/*.go" }))
896 );
897 }
898
899 #[test]
900 fn find_trims_trailing_slash_from_path_arg() {
901 assert_eq!(
902 find_request(r#"find /tmp/foo/ -name "*.ts""#),
903 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
904 );
905 }
906
907 #[test]
908 fn find_filesystem_root_is_not_rewritten() {
909 assert_eq!(find_request(r#"find / -name "*.rs""#), None);
912 assert_eq!(find_request(r#"find // -name "*.rs""#), None);
913 }
914}