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::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
206/// Run an underlying tool through the rewrite path. If the tool returned
207/// `success: false`, propagate as Err so dispatch falls through to actual bash
208/// — the agent's intent was bash, the rewrite is a transparent optimization.
209/// Returning a wrapped error response would surprise the agent (e.g. read's
210/// `outside project root` rejecting a sed that bash would have allowed).
211fn 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
218/// Grep/rg variant: same decline handling, but the footer is the enforced
219/// code-search redirect, steering to `aft_search` when it's registered.
220fn 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    // Canonicalize the root here rather than trusting callers: the target
251    // below is canonicalized, and comparing a canonical target against a
252    // non-canonical root breaks on alias spellings (macOS /var vs
253    // /private/var), misreading in-root paths as external.
254    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            // Filesystem root (`find / ...`, `find // ...`): trimming the slash
437            // yields "" which downstream resolves as the PROJECT ROOT — silently
438            // searching the project instead of the whole filesystem. Don't
439            // rewrite; fall through to native `find`, which does what was asked.
440            // (A non-empty absolute path like `/tmp/foo` is preserved as-is.)
441            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: recursive listing — `read` of a directory is
523                    // single-level only, but the result is still a useful
524                    // approximation of "what's in this tree".
525                    'R' => {}
526                    // Plain `ls` hides dotfiles, while `read` normally includes
527                    // them. Preserve the caller's explicit request to show all
528                    // entries without changing direct `read` behavior.
529                    'a' => include_hidden = true,
530                    // `ls -A` shows hidden entries except `.` and `..`. Keep this
531                    // distinct spelling unsupported so native bash preserves its
532                    // exact contract.
533                    'A' => return None,
534                    // -l: long format. Shows size, mtime, permissions, owner.
535                    // `read` returns directory entries (no metadata) or file
536                    // contents (not metadata at all). Rewriting drops the
537                    // info the user asked for, so fall through to real bash.
538                    // Reported by user dogfooding the v0.18 bash experimentals.
539                    _ => return None,
540                }
541            }
542        } else if path.is_none() {
543            path = Some(arg.clone());
544        } else {
545            return None;
546        }
547    }
548
549    // Even without -l, `ls FILE` and `read FILE` have entirely different
550    // semantics: `ls FILE` echoes the filename, `read FILE` dumps the file
551    // contents. The rewrite is only safe when the path resolves to a
552    // directory (or is missing/cwd, where `read` of cwd also makes sense).
553    // Stat the path and fall through to bash for files.
554    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    // Path doesn't exist (yet)? Let bash handle the error itself — its
561    // wording is well-known to agents and we don't gain anything by
562    // rewriting a guaranteed-failing rewrite call.
563    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        // `find /` must NOT rewrite — trimming the slash would yield "" which
686        // resolves as the project root, silently searching the wrong scope.
687        assert_eq!(find_request(r#"find / -name "*.rs""#), None);
688        assert_eq!(find_request(r#"find // -name "*.rs""#), None);
689    }
690}