Skip to main content

aft/bash_rewrite/
rules.rs

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
394/// Add the normal tool footer while preserving a handler error as the final
395/// response. A handler error is not a permission to execute native bash: the
396/// request has already entered the internal handler.
397fn 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    // Canonicalize the root here rather than trusting callers: the target
427    // below is canonicalized, and comparing a canonical target against a
428    // non-canonical root breaks on alias spellings (macOS /var vs
429    // /private/var), misreading in-root paths as external.
430    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            // Filesystem root (`find / ...`, `find // ...`): trimming the slash
656            // yields "" which downstream resolves as the PROJECT ROOT — silently
657            // searching the project instead of the whole filesystem. Don't
658            // rewrite; fall through to native `find`, which does what was asked.
659            // (A non-empty absolute path like `/tmp/foo` is preserved as-is.)
660            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: recursive listing — `read` of a directory is
742                    // single-level only, but the result is still a useful
743                    // approximation of "what's in this tree".
744                    'R' => {}
745                    // Plain `ls` hides dotfiles, while `read` normally includes
746                    // them. Preserve the caller's explicit request to show all
747                    // entries without changing direct `read` behavior.
748                    'a' => include_hidden = true,
749                    // `ls -A` shows hidden entries except `.` and `..`. Keep this
750                    // distinct spelling unsupported so native bash preserves its
751                    // exact contract.
752                    'A' => return None,
753                    // -l: long format. Shows size, mtime, permissions, owner.
754                    // `read` returns directory entries (no metadata) or file
755                    // contents (not metadata at all). Rewriting drops the
756                    // info the user asked for, so fall through to real bash.
757                    // Reported by user dogfooding the v0.18 bash experimentals.
758                    _ => return None,
759                }
760            }
761        } else if path.is_none() {
762            path = Some(arg.clone());
763        } else {
764            return None;
765        }
766    }
767
768    // Even without -l, `ls FILE` and `read FILE` have entirely different
769    // semantics: `ls FILE` echoes the filename, `read FILE` dumps the file
770    // contents. The rewrite is only safe when the path resolves to a
771    // directory (or is missing/cwd, where `read` of cwd also makes sense).
772    // Stat the path and fall through to bash for files.
773    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        // Path doesn't exist (yet)? Let bash handle the error itself — its
786        // wording is well-known to agents, and rewriting a guaranteed failure
787        // would change the native error outcome.
788        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        // `find /` must NOT rewrite — trimming the slash would yield "" which
910        // resolves as the project root, silently searching the wrong scope.
911        assert_eq!(find_request(r#"find / -name "*.rs""#), None);
912        assert_eq!(find_request(r#"find // -name "*.rs""#), None);
913    }
914}