Skip to main content

aft/bash_rewrite/
rules.rs

1use serde_json::{json, Value};
2use std::path::{Path, PathBuf};
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 HeadRule;
19pub struct TailRule;
20pub struct CatAppendRule;
21pub struct SedRule;
22pub struct LsRule;
23
24impl RewriteRule for GrepRule {
25    fn name(&self) -> &'static str {
26        "grep"
27    }
28
29    fn decide(
30        &self,
31        command: &str,
32        request_id: &str,
33        session_id: Option<&str>,
34        ctx: &AppContext,
35    ) -> crate::bash_rewrite::RewriteDecision {
36        let Some(params) = grep_request(command, "grep") else {
37            return decline("grep", "grep.decline", "unsupported grep shape");
38        };
39        if let Some(path) = params.get("path").and_then(Value::as_str) {
40            if !path_is_safe(ctx, path, true) {
41                return decline(
42                    "grep",
43                    "grep.decline",
44                    "grep path is outside the project root or missing",
45                );
46            }
47        }
48        accept(
49            "grep",
50            "grep.accept",
51            "dc.grep.accept.v1",
52            command,
53            request_id,
54            session_id,
55            params,
56        )
57    }
58
59    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
60        let path = request.params.get("path").and_then(Value::as_str);
61        let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
62        grep_footer_response(response, ctx, path)
63    }
64}
65
66impl RewriteRule for RgRule {
67    fn name(&self) -> &'static str {
68        "rg"
69    }
70
71    fn decide(
72        &self,
73        command: &str,
74        request_id: &str,
75        session_id: Option<&str>,
76        ctx: &AppContext,
77    ) -> crate::bash_rewrite::RewriteDecision {
78        let Some(params) = grep_request(command, "rg") else {
79            return decline("rg", "rg.decline", "unsupported rg shape");
80        };
81        if let Some(path) = params.get("path").and_then(Value::as_str) {
82            if !path_is_safe(ctx, path, true) {
83                return decline(
84                    "rg",
85                    "rg.decline",
86                    "rg path is outside the project root or missing",
87                );
88            }
89        }
90        accept(
91            "rg",
92            "rg.accept",
93            "dc.rg.accept.v1",
94            command,
95            request_id,
96            session_id,
97            params,
98        )
99    }
100
101    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
102        let path = request.params.get("path").and_then(Value::as_str);
103        let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
104        grep_footer_response(response, ctx, path)
105    }
106}
107
108impl RewriteRule for FindRule {
109    fn name(&self) -> &'static str {
110        "find"
111    }
112
113    fn decide(
114        &self,
115        command: &str,
116        request_id: &str,
117        session_id: Option<&str>,
118        ctx: &AppContext,
119    ) -> crate::bash_rewrite::RewriteDecision {
120        let Some(params) = find_request(command) else {
121            return decline("find", "find.decline", "unsupported find shape");
122        };
123        if let Some(path) = params.get("path").and_then(Value::as_str) {
124            if !path_is_safe(ctx, path, true) {
125                return decline(
126                    "find",
127                    "find.decline",
128                    "find path is outside the project root or missing",
129                );
130            }
131        }
132        accept(
133            "find",
134            "find.accept",
135            "dc.find.accept.v1",
136            command,
137            request_id,
138            session_id,
139            params,
140        )
141    }
142
143    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
144        call_and_footer(
145            crate::commands::glob::handle_glob(&tool_request("glob", request, ctx), ctx),
146            "glob",
147        )
148    }
149}
150
151impl RewriteRule for CatRule {
152    fn name(&self) -> &'static str {
153        "cat"
154    }
155
156    fn decide(
157        &self,
158        command: &str,
159        request_id: &str,
160        session_id: Option<&str>,
161        ctx: &AppContext,
162    ) -> crate::bash_rewrite::RewriteDecision {
163        let Some(params) = cat_read_request(command) else {
164            return decline("cat", "cat.decline", "unsupported cat shape");
165        };
166        let path = params
167            .get("file")
168            .and_then(Value::as_str)
169            .unwrap_or_default();
170        if !path_is_safe(ctx, path, true) {
171            return decline_cat_read(ctx, path, "path_is_safe");
172        }
173        if !read_shape_is_faithful(ctx, path) {
174            return decline_cat_read(ctx, path, "read_shape_is_faithful");
175        }
176        accept(
177            "cat",
178            "cat.accept",
179            "dc.cat.accept.v1",
180            command,
181            request_id,
182            session_id,
183            params,
184        )
185    }
186
187    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
188        call_and_footer(
189            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
190            "read",
191        )
192    }
193}
194
195impl RewriteRule for HeadRule {
196    fn name(&self) -> &'static str {
197        "head"
198    }
199
200    fn decide(
201        &self,
202        command: &str,
203        request_id: &str,
204        session_id: Option<&str>,
205        ctx: &AppContext,
206    ) -> crate::bash_rewrite::RewriteDecision {
207        let Some(params) = head_tail_read_request(command, "head") else {
208            return decline("head", "head.decline", "unsupported head shape");
209        };
210        let path = params
211            .get("file")
212            .and_then(Value::as_str)
213            .unwrap_or_default();
214        let lines = params.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
215        if !effective_hashline_session(ctx, session_id)
216            || !path_is_safe(ctx, path, true)
217            || !head_tail_shape_is_faithful(ctx, path, lines, false)
218        {
219            return decline(
220                "head",
221                "head.decline",
222                "head requires an effective hashline session and a faithful text-read shape",
223            );
224        }
225        accept(
226            "head",
227            "head.accept",
228            "dc.head.accept.v1",
229            command,
230            request_id,
231            session_id,
232            params,
233        )
234    }
235
236    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
237        call_and_footer(
238            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
239            "read",
240        )
241    }
242}
243
244impl RewriteRule for TailRule {
245    fn name(&self) -> &'static str {
246        "tail"
247    }
248
249    fn decide(
250        &self,
251        command: &str,
252        request_id: &str,
253        session_id: Option<&str>,
254        ctx: &AppContext,
255    ) -> crate::bash_rewrite::RewriteDecision {
256        let Some(params) = head_tail_read_request(command, "tail") else {
257            return decline("tail", "tail.decline", "unsupported tail shape");
258        };
259        let path = params
260            .get("file")
261            .and_then(Value::as_str)
262            .unwrap_or_default();
263        let lines = params.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
264        if !effective_hashline_session(ctx, session_id)
265            || !path_is_safe(ctx, path, true)
266            || !head_tail_shape_is_faithful(ctx, path, lines, true)
267        {
268            return decline(
269                "tail",
270                "tail.decline",
271                "tail requires an effective hashline session and a faithful text-read shape",
272            );
273        }
274        accept(
275            "tail",
276            "tail.accept",
277            "dc.tail.accept.v1",
278            command,
279            request_id,
280            session_id,
281            params,
282        )
283    }
284
285    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
286        call_and_footer(
287            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
288            "read",
289        )
290    }
291}
292
293impl RewriteRule for CatAppendRule {
294    fn name(&self) -> &'static str {
295        "cat_append"
296    }
297
298    fn decide(
299        &self,
300        command: &str,
301        request_id: &str,
302        session_id: Option<&str>,
303        ctx: &AppContext,
304    ) -> crate::bash_rewrite::RewriteDecision {
305        let Some(params) = append_request(command) else {
306            return decline(
307                "cat_append",
308                "cat_append.decline",
309                "unsupported append shape",
310            );
311        };
312        let path = params
313            .get("file")
314            .and_then(Value::as_str)
315            .unwrap_or_default();
316        if !append_path_is_safe(ctx, path) {
317            return decline(
318                "cat_append",
319                "cat_append.decline",
320                "append path is outside the project root or has no existing parent",
321            );
322        }
323        accept(
324            "cat_append",
325            "cat_append.accept",
326            "dc.cat_append.accept.v1",
327            command,
328            request_id,
329            session_id,
330            params,
331        )
332    }
333
334    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
335        call_and_footer(
336            crate::commands::edit_match::handle_edit_match(
337                &tool_request("edit_match", request, ctx),
338                ctx,
339            ),
340            "edit",
341        )
342    }
343}
344
345impl RewriteRule for SedRule {
346    fn name(&self) -> &'static str {
347        "sed"
348    }
349
350    fn decide(
351        &self,
352        command: &str,
353        request_id: &str,
354        session_id: Option<&str>,
355        ctx: &AppContext,
356    ) -> crate::bash_rewrite::RewriteDecision {
357        let Some(params) = sed_request(command) else {
358            return decline("sed", "sed.decline", "unsupported sed shape");
359        };
360        let path = params
361            .get("file")
362            .and_then(Value::as_str)
363            .unwrap_or_default();
364        if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
365            return decline(
366                "sed",
367                "sed.decline",
368                "sed path is outside the project root or exceeds the read contract",
369            );
370        }
371        accept(
372            "sed",
373            "sed.accept",
374            "dc.sed.accept.v1",
375            command,
376            request_id,
377            session_id,
378            params,
379        )
380    }
381
382    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
383        call_and_footer(
384            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
385            "read",
386        )
387    }
388}
389
390impl RewriteRule for LsRule {
391    fn name(&self) -> &'static str {
392        "ls"
393    }
394
395    fn decide(
396        &self,
397        command: &str,
398        request_id: &str,
399        session_id: Option<&str>,
400        ctx: &AppContext,
401    ) -> crate::bash_rewrite::RewriteDecision {
402        let Some(params) = ls_request(command, ctx) else {
403            return decline("ls", "ls.decline", "unsupported ls shape or target");
404        };
405        accept(
406            "ls",
407            "ls.accept",
408            "dc.ls.accept.v1",
409            command,
410            request_id,
411            session_id,
412            params,
413        )
414    }
415
416    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
417        call_and_footer(
418            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
419            "read",
420        )
421    }
422}
423
424fn accept(
425    rule_id: &'static str,
426    branch_id: &'static str,
427    decision_class_id: &'static str,
428    command: &str,
429    request_id: &str,
430    session_id: Option<&str>,
431    params: Value,
432) -> crate::bash_rewrite::RewriteDecision {
433    crate::bash_rewrite::RewriteDecision::Accept(RewriteRequest {
434        request_id: request_id.to_string(),
435        command: command.to_string(),
436        session_id: session_id.map(str::to_owned),
437        rule_id,
438        branch_id,
439        decision_class_id,
440        params,
441    })
442}
443
444fn decline(
445    rule_id: &'static str,
446    branch_id: &'static str,
447    reason: &str,
448) -> crate::bash_rewrite::RewriteDecision {
449    let decision_class_id = match rule_id {
450        "grep" => "dc.grep.decline.v1",
451        "rg" => "dc.rg.decline.v1",
452        "find" => "dc.find.decline.v1",
453        "cat" => "dc.cat.decline.v1",
454        "head" => "dc.head.decline.v1",
455        "tail" => "dc.tail.decline.v1",
456        "cat_append" => "dc.cat_append.decline.v1",
457        "sed" => "dc.sed.decline.v1",
458        "ls" => "dc.ls.decline.v1",
459        _ => "dc.native.decline.v1",
460    };
461    crate::bash_rewrite::RewriteDecision::Decline(crate::bash_rewrite::DeclineReason {
462        rule_id: Some(rule_id),
463        branch_id,
464        decision_class_id,
465        reason: reason.to_string(),
466    })
467}
468
469fn tool_request(tool: &str, request: &RewriteRequest, ctx: &AppContext) -> RawRequest {
470    let mut params = request.params.clone();
471    let root = grep_project_root(ctx);
472    if matches!(tool, "read" | "edit_match") {
473        if let Some(file) = params
474            .get("file")
475            .and_then(Value::as_str)
476            .map(str::to_owned)
477        {
478            if tool == "read" && matches!(request.rule_id, "cat" | "head" | "tail") {
479                params["_hashline_requested_path"] = Value::String(file.clone());
480                params["_hashline_bash_read_kind"] = Value::String(request.rule_id.to_string());
481                if let Some(lines) = request.params.get("limit").and_then(Value::as_u64) {
482                    params["_hashline_bash_read_lines"] = Value::from(lines);
483                }
484            }
485            let path = Path::new(&file);
486            if path.is_relative() {
487                params["file"] = Value::String(root.join(path).display().to_string());
488            }
489            if tool == "read" && request.rule_id == "tail" {
490                if let Some(path) = params.get("file").and_then(Value::as_str) {
491                    if let Some(total_lines) = text_file_line_count(Path::new(path)) {
492                        if total_lines > 0 {
493                            let lines = request
494                                .params
495                                .get("limit")
496                                .and_then(Value::as_u64)
497                                .unwrap_or(10) as usize;
498                            params["start_line"] =
499                                Value::from(total_lines.saturating_sub(lines).saturating_add(1));
500                            params["end_line"] = Value::from(total_lines);
501                        }
502                    }
503                }
504            }
505        }
506    }
507    if tool == "glob" && params.get("path").is_none() {
508        params["path"] = Value::String(root.display().to_string());
509    }
510    RawRequest {
511        id: request.request_id.clone(),
512        command: tool.to_string(),
513        lsp_hints: None,
514        session_id: request.session_id.clone(),
515        params,
516    }
517}
518
519/// Add the normal tool footer while preserving a handler error as the final
520/// response. A handler error is not a permission to execute native bash: the
521/// request has already entered the internal handler.
522fn call_and_footer(response: Response, replacement_tool: &str) -> Response {
523    let output = response_output(&response.data);
524    let footered = add_footer(&output, replacement_tool);
525    apply_footer(response, footered)
526}
527
528fn grep_footer_response(response: Response, ctx: &AppContext, path: Option<&str>) -> Response {
529    let output = response_output(&response.data);
530    let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) {
531        output
532    } else {
533        add_grep_footer(&output, ctx.config().aft_search_registered)
534    };
535    apply_footer(response, footered)
536}
537
538fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf {
539    let configured = ctx
540        .config()
541        .project_root
542        .clone()
543        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
544    std::fs::canonicalize(&configured).unwrap_or(configured)
545}
546
547fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool {
548    let Some(path) = path else {
549        return false;
550    };
551    // Canonicalize the root here rather than trusting callers: the target
552    // below is canonicalized, and comparing a canonical target against a
553    // non-canonical root breaks on alias spellings (macOS /var vs
554    // /private/var), misreading in-root paths as external.
555    let project_root =
556        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
557    let project_root = project_root.as_path();
558    let target = Path::new(path);
559    let target = if target.is_absolute() {
560        target.to_path_buf()
561    } else {
562        project_root.join(target)
563    };
564    let Ok(target) = std::fs::canonicalize(target) else {
565        return false;
566    };
567    if !target.starts_with(project_root) {
568        return true;
569    }
570    let Ok(metadata) = std::fs::metadata(&target) else {
571        return false;
572    };
573    if metadata.is_file() {
574        return true;
575    }
576    let Ok(modified) = metadata.modified() else {
577        return false;
578    };
579    SystemTime::now()
580        .duration_since(modified)
581        .is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW)
582}
583
584fn apply_footer(mut response: Response, output: String) -> Response {
585    if let Some(object) = response.data.as_object_mut() {
586        object.insert("output".to_string(), Value::String(output.clone()));
587
588        for key in ["text", "content", "message"] {
589            if object.get(key).is_some_and(Value::is_string) {
590                object.insert(key.to_string(), Value::String(output.clone()));
591                break;
592            }
593        }
594    } else {
595        response.data = json!({ "output": output });
596    }
597
598    response
599}
600
601fn response_output(data: &Value) -> String {
602    if let Some(output) = data.get("output").and_then(Value::as_str) {
603        return output.to_string();
604    }
605    if let Some(text) = data.get("text").and_then(Value::as_str) {
606        return text.to_string();
607    }
608    if let Some(content) = data.get("content").and_then(Value::as_str) {
609        return content.to_string();
610    }
611    if let Some(message) = data.get("message").and_then(Value::as_str) {
612        return message.to_string();
613    }
614    if let Some(entries) = data.get("entries").and_then(Value::as_array) {
615        return entries
616            .iter()
617            .filter_map(Value::as_str)
618            .collect::<Vec<_>>()
619            .join("\n");
620    }
621    serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
622}
623
624fn path_candidate(ctx: &AppContext, path: &str) -> PathBuf {
625    let candidate = Path::new(path);
626    if candidate.is_absolute() {
627        candidate.to_path_buf()
628    } else {
629        grep_project_root(ctx).join(candidate)
630    }
631}
632
633fn resolved_path_candidate(ctx: &AppContext, path: &str) -> PathBuf {
634    let candidate = path_candidate(ctx, path);
635    std::fs::canonicalize(&candidate).unwrap_or(candidate)
636}
637
638fn decline_cat_read(
639    ctx: &AppContext,
640    path: &str,
641    predicate: &'static str,
642) -> crate::bash_rewrite::RewriteDecision {
643    let candidate = resolved_path_candidate(ctx, path);
644    crate::slog_warn!(
645        "bash rewrite rule cat declined: read declined: predicate={} resolved_candidate={:?}",
646        predicate,
647        candidate
648    );
649    decline(
650        "cat",
651        "cat.decline",
652        &format!(
653            "read declined: {predicate} failed for {}",
654            candidate.display()
655        ),
656    )
657}
658
659fn path_is_safe(ctx: &AppContext, path: &str, require_existing: bool) -> bool {
660    let root = grep_project_root(ctx);
661    let candidate = path_candidate(ctx, path);
662    if require_existing && !candidate.exists() {
663        return false;
664    }
665    let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
666    resolved.starts_with(&root)
667}
668
669fn read_shape_is_faithful(ctx: &AppContext, path: &str) -> bool {
670    let candidate = path_candidate(ctx, path);
671    let Ok(metadata) = std::fs::metadata(&candidate) else {
672        return false;
673    };
674    if !metadata.is_file() || metadata.len() > 50 * 1024 {
675        return false;
676    }
677    let Ok(bytes) = std::fs::read(candidate) else {
678        return false;
679    };
680    let Ok(text) = std::str::from_utf8(&bytes) else {
681        return false;
682    };
683    text.lines().all(|line| line.len() <= 2_000)
684}
685
686fn effective_hashline_session(ctx: &AppContext, session_id: Option<&str>) -> bool {
687    let root = ctx
688        .canonical_cache_root_opt()
689        .unwrap_or_else(|| grep_project_root(ctx));
690    ctx.hashline_bindings()
691        .capture(
692            root,
693            session_id.unwrap_or(crate::protocol::DEFAULT_SESSION_ID),
694        )
695        .is_some_and(|binding| binding.effective())
696}
697
698fn head_tail_shape_is_faithful(ctx: &AppContext, path: &str, lines: usize, tail: bool) -> bool {
699    let root = grep_project_root(ctx);
700    let candidate = if Path::new(path).is_absolute() {
701        Path::new(path).to_path_buf()
702    } else {
703        root.join(path)
704    };
705    let Ok(metadata) = std::fs::metadata(&candidate) else {
706        return false;
707    };
708    if !metadata.is_file() {
709        return false;
710    }
711    let Ok(bytes) = std::fs::read(candidate) else {
712        return false;
713    };
714    let Ok(text) = std::str::from_utf8(&bytes) else {
715        return false;
716    };
717    let records = text.lines().collect::<Vec<_>>();
718    let selected: Box<dyn Iterator<Item = &&str>> = if tail {
719        Box::new(records.iter().skip(records.len().saturating_sub(lines)))
720    } else {
721        Box::new(records.iter().take(lines))
722    };
723    let mut displayed_bytes = 0_usize;
724    for record in selected {
725        if record.len() > 2_000 {
726            return false;
727        }
728        displayed_bytes = displayed_bytes
729            .saturating_add(record.len())
730            .saturating_add(16);
731        if displayed_bytes > 50 * 1024 {
732            return false;
733        }
734    }
735    true
736}
737
738fn text_file_line_count(path: &Path) -> Option<usize> {
739    let bytes = std::fs::read(path).ok()?;
740    std::str::from_utf8(&bytes).ok()?;
741    if bytes.is_empty() {
742        return Some(0);
743    }
744    Some(
745        bytes.iter().filter(|byte| **byte == b'\n').count()
746            + usize::from(bytes.last() != Some(&b'\n')),
747    )
748}
749
750fn append_path_is_safe(ctx: &AppContext, path: &str) -> bool {
751    let root = grep_project_root(ctx);
752    let candidate = if Path::new(path).is_absolute() {
753        Path::new(path).to_path_buf()
754    } else {
755        root.join(path)
756    };
757    let parent = candidate.parent().unwrap_or(&root);
758    if !parent.exists() {
759        return false;
760    }
761    let resolved_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
762    if !resolved_parent.starts_with(&root) {
763        return false;
764    }
765    if candidate.exists() {
766        let Ok(resolved) = std::fs::canonicalize(&candidate) else {
767            return false;
768        };
769        resolved.starts_with(&root)
770    } else {
771        true
772    }
773}
774
775fn grep_basic_regex_has_dialect_conflict(pattern: &str) -> bool {
776    // Basic grep treats these characters as literals unless escaped, while the
777    // AFT regex engine gives them extended-regex meanings. Native grep is the
778    // only faithful route until the rewrite can translate the complete BRE.
779    pattern
780        .chars()
781        .any(|ch| matches!(ch, '+' | '?' | '|' | '(' | ')' | '{' | '}'))
782}
783
784fn grep_request(command: &str, binary: &str) -> Option<Value> {
785    let parsed = parse(command)?;
786    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
787        return None;
788    }
789
790    let mut case_sensitive = true;
791    let mut word_match = false;
792    let mut index = 1;
793
794    while let Some(arg) = parsed.args.get(index) {
795        if !arg.starts_with('-') || arg == "-" {
796            break;
797        }
798        for flag in arg[1..].chars() {
799            match flag {
800                'n' | 'r' => {}
801                'i' => case_sensitive = false,
802                'w' => word_match = true,
803                _ => return None,
804            }
805        }
806        index += 1;
807    }
808
809    let pattern = parsed.args.get(index)?.clone();
810    if binary == "grep" && grep_basic_regex_has_dialect_conflict(&pattern) {
811        return None;
812    }
813    let path = parsed.args.get(index + 1).cloned();
814    if parsed.args.len() > index + 2 {
815        return None;
816    }
817
818    let pattern = if word_match {
819        format!(r"\b(?:{})\b", pattern)
820    } else {
821        pattern
822    };
823
824    if regex::RegexBuilder::new(&pattern)
825        .size_limit(REGEX_SIZE_LIMIT)
826        .build()
827        .is_err()
828    {
829        return None;
830    }
831
832    let mut params = json!({
833        "pattern": pattern,
834        "case_sensitive": case_sensitive,
835        "max_results": 100,
836    });
837    if let Some(path) = path {
838        params["path"] = json!(path);
839    }
840    Some(params)
841}
842
843fn find_request(command: &str) -> Option<Value> {
844    let parsed = parse(command)?;
845    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
846        return None;
847    }
848    if parsed.args.len() != 4 && parsed.args.len() != 6 {
849        return None;
850    }
851
852    let path = parsed.args.get(1)?.clone();
853    let mut name = None;
854    let mut saw_type_file = false;
855    let mut index = 2;
856
857    while index < parsed.args.len() {
858        match parsed.args[index].as_str() {
859            "-name" if name.is_none() && index + 1 < parsed.args.len() => {
860                name = Some(parsed.args[index + 1].clone());
861                index += 2;
862            }
863            "-type" if !saw_type_file && index + 1 < parsed.args.len() => {
864                if parsed.args[index + 1] != "f" {
865                    return None;
866                }
867                saw_type_file = true;
868                index += 2;
869            }
870            _ => return None,
871        }
872    }
873
874    let name = name?;
875    let pattern = format!("**/{name}");
876    if path == "." {
877        Some(json!({ "pattern": pattern }))
878    } else {
879        let trimmed = path.trim_end_matches('/');
880        if trimmed.is_empty() {
881            // Filesystem root (`find / ...`, `find // ...`): trimming the slash
882            // yields "" which downstream resolves as the PROJECT ROOT — silently
883            // searching the project instead of the whole filesystem. Don't
884            // rewrite; fall through to native `find`, which does what was asked.
885            // (A non-empty absolute path like `/tmp/foo` is preserved as-is.)
886            None
887        } else {
888            Some(json!({ "path": trimmed, "pattern": pattern }))
889        }
890    }
891}
892
893fn cat_read_request(command: &str) -> Option<Value> {
894    let parsed = parse(command)?;
895    if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
896        return None;
897    }
898    if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
899        return None;
900    }
901    Some(json!({ "file": parsed.args[1] }))
902}
903
904fn head_tail_read_request(command: &str, command_name: &str) -> Option<Value> {
905    let parsed = parse(command)?;
906    if parsed.appends_to.is_some()
907        || parsed.heredoc.is_some()
908        || parsed.args.first()? != command_name
909    {
910        return None;
911    }
912
913    let (lines, file) = match parsed.args.as_slice() {
914        [_command, file] => (10_u64, file.as_str()),
915        [_command, flag, count, file] if flag == "-n" => {
916            (count.parse::<u64>().ok()?, file.as_str())
917        }
918        [_command, compact, file] => {
919            let count = compact.strip_prefix('-')?.parse::<u64>().ok()?;
920            (count, file.as_str())
921        }
922        _ => return None,
923    };
924    if lines == 0 {
925        return None;
926    }
927    Some(json!({ "file": file, "limit": lines }))
928}
929
930fn append_request(command: &str) -> Option<Value> {
931    let parsed = parse(command)?;
932    let file = parsed.appends_to.clone()?;
933
934    let append_content = if parsed.args == ["cat"] {
935        parsed.heredoc?
936    } else if parsed.heredoc.is_none()
937        && parsed.args.first().is_some_and(|arg| arg == "echo")
938        && parsed.args.len() >= 2
939        && !parsed.args[1].starts_with('-')
940    {
941        format!("{}\n", parsed.args[1..].join(" "))
942    } else {
943        return None;
944    };
945
946    Some(json!({
947        "op": "append",
948        "file": file,
949        "append_content": append_content,
950        "create_dirs": true,
951    }))
952}
953
954fn sed_request(command: &str) -> Option<Value> {
955    let parsed = parse(command)?;
956    if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
957        return None;
958    }
959    if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
960        return None;
961    }
962
963    let range = parsed.args[2].strip_suffix('p')?;
964    let (start, end) = range.split_once(',')?;
965    let start_line = start.parse::<u32>().ok()?;
966    let end_line = end.parse::<u32>().ok()?;
967    if start_line == 0 || end_line < start_line {
968        return None;
969    }
970
971    Some(json!({
972        "file": parsed.args[3],
973        "start_line": start_line,
974        "end_line": end_line,
975    }))
976}
977
978fn ls_request(command: &str, ctx: &AppContext) -> Option<Value> {
979    let parsed = parse(command)?;
980    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
981        return None;
982    }
983
984    let mut path = None;
985    let mut include_hidden = false;
986    for arg in parsed.args.iter().skip(1) {
987        if let Some(flags) = arg.strip_prefix('-') {
988            if flags.is_empty() {
989                return None;
990            }
991            for flag in flags.chars() {
992                match flag {
993                    // -R: recursive listing — `read` of a directory is
994                    // single-level only, but the result is still a useful
995                    // approximation of "what's in this tree".
996                    'R' => {}
997                    // Plain `ls` hides dotfiles, while `read` normally includes
998                    // them. Preserve the caller's explicit request to show all
999                    // entries without changing direct `read` behavior.
1000                    'a' => include_hidden = true,
1001                    // `ls -A` shows hidden entries except `.` and `..`. Keep this
1002                    // distinct spelling unsupported so native bash preserves its
1003                    // exact contract.
1004                    'A' => return None,
1005                    // -l: long format. Shows size, mtime, permissions, owner.
1006                    // `read` returns directory entries (no metadata) or file
1007                    // contents (not metadata at all). Rewriting drops the
1008                    // info the user asked for, so fall through to real bash.
1009                    // Reported by user dogfooding the v0.18 bash experimentals.
1010                    _ => return None,
1011                }
1012            }
1013        } else if path.is_none() {
1014            path = Some(arg.clone());
1015        } else {
1016            return None;
1017        }
1018    }
1019
1020    // Even without -l, `ls FILE` and `read FILE` have entirely different
1021    // semantics: `ls FILE` echoes the filename, `read FILE` dumps the file
1022    // contents. The rewrite is only safe when the path resolves to a
1023    // directory (or is missing/cwd, where `read` of cwd also makes sense).
1024    // Stat the path and fall through to bash for files.
1025    let target = path.clone().unwrap_or_else(|| ".".to_string());
1026    let root = grep_project_root(ctx);
1027    let target_for_metadata = if Path::new(&target).is_absolute() {
1028        Path::new(&target).to_path_buf()
1029    } else {
1030        root.join(&target)
1031    };
1032    if let Ok(metadata) = std::fs::metadata(&target_for_metadata) {
1033        if !metadata.is_dir() || !path_is_safe(ctx, &target, true) {
1034            return None;
1035        }
1036    } else {
1037        // Path doesn't exist (yet)? Let bash handle the error itself — its
1038        // wording is well-known to agents, and rewriting a guaranteed failure
1039        // would change the native error outcome.
1040        return None;
1041    }
1042
1043    Some(json!({ "file": target, "include_hidden": include_hidden }))
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use std::fs;
1049    use std::time::{Duration, SystemTime};
1050
1051    use serde_json::json;
1052
1053    use super::{find_request, grep_request, should_suppress_grep_footer, HeadRule, TailRule};
1054    use crate::bash_rewrite::{RewriteDecision, RewriteRule};
1055    use crate::config::Config;
1056    use crate::context::{default_language_provider_factory, AppContext};
1057    use crate::hashline::integration::RegistrationRequest;
1058    use crate::protocol::DEFAULT_SESSION_ID;
1059
1060    fn fixture() -> tempfile::TempDir {
1061        let dir = tempfile::tempdir().unwrap();
1062        fs::create_dir(dir.path().join("src")).unwrap();
1063        fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
1064        dir
1065    }
1066
1067    #[test]
1068    fn single_named_file_suppresses_grep_footer() {
1069        let dir = fixture();
1070        assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1071    }
1072
1073    #[test]
1074    fn directory_path_keeps_grep_footer() {
1075        let dir = fixture();
1076        filetime::set_file_mtime(
1077            dir.path().join("src"),
1078            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1079        )
1080        .unwrap();
1081        assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1082    }
1083
1084    #[test]
1085    fn no_path_keeps_grep_footer() {
1086        let dir = fixture();
1087        assert!(!should_suppress_grep_footer(None, dir.path()));
1088    }
1089
1090    #[test]
1091    fn external_file_suppresses_grep_footer() {
1092        let dir = fixture();
1093        let external = tempfile::NamedTempFile::new().unwrap();
1094        assert!(should_suppress_grep_footer(
1095            external.path().to_str(),
1096            dir.path()
1097        ));
1098    }
1099
1100    #[test]
1101    fn freshly_modified_file_suppresses_grep_footer() {
1102        let dir = fixture();
1103        let file = dir.path().join("src/app.ts");
1104        fs::write(&file, "foo\nbar\n").unwrap();
1105        assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
1106    }
1107
1108    #[test]
1109    fn old_directory_inside_project_root_keeps_grep_footer() {
1110        let dir = fixture();
1111        let directory = dir.path().join("src");
1112        filetime::set_file_mtime(
1113            &directory,
1114            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1115        )
1116        .unwrap();
1117        assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1118    }
1119
1120    #[test]
1121    fn old_file_inside_project_root_suppresses_grep_footer() {
1122        let dir = fixture();
1123        let file = dir.path().join("src/app.ts");
1124        filetime::set_file_mtime(
1125            &file,
1126            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1127        )
1128        .unwrap();
1129        assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1130    }
1131
1132    #[test]
1133    fn grep_declines_basic_regex_tokens_with_extended_meanings() {
1134        assert_eq!(grep_request("grep -rn 'a+b' src", "grep"), None);
1135        assert_eq!(
1136            grep_request("rg -n 'a+b' src", "rg").unwrap()["pattern"],
1137            "a+b"
1138        );
1139        assert_eq!(
1140            grep_request("grep -rn 'a.b' src", "grep").unwrap()["pattern"],
1141            "a.b"
1142        );
1143    }
1144
1145    #[test]
1146    fn find_absolute_path_uses_glob_path_arg() {
1147        assert_eq!(
1148            find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
1149            Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1150        );
1151    }
1152
1153    #[test]
1154    fn find_dot_keeps_project_root_relative_pattern() {
1155        assert_eq!(
1156            find_request(r#"find . -name "*.ts" -type f"#),
1157            Some(json!({ "pattern": "**/*.ts" }))
1158        );
1159    }
1160
1161    #[test]
1162    fn find_relative_path_uses_glob_path_arg() {
1163        assert_eq!(
1164            find_request(r#"find ./src -name "*.go""#),
1165            Some(json!({ "path": "./src", "pattern": "**/*.go" }))
1166        );
1167    }
1168
1169    #[test]
1170    fn find_trims_trailing_slash_from_path_arg() {
1171        assert_eq!(
1172            find_request(r#"find /tmp/foo/ -name "*.ts""#),
1173            Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1174        );
1175    }
1176
1177    #[test]
1178    fn find_filesystem_root_is_not_rewritten() {
1179        // `find /` must NOT rewrite — trimming the slash would yield "" which
1180        // resolves as the project root, silently searching the wrong scope.
1181        assert_eq!(find_request(r#"find / -name "*.rs""#), None);
1182        assert_eq!(find_request(r#"find // -name "*.rs""#), None);
1183    }
1184
1185    #[test]
1186    fn head_and_tail_rewrites_are_hashline_only_and_tail_keeps_absolute_numbering() {
1187        let dir = fixture();
1188        let root = fs::canonicalize(dir.path()).unwrap();
1189        fs::write(root.join("src/app.ts"), "one\ntwo\nthree\nfour\n").unwrap();
1190        let ctx = AppContext::new(
1191            default_language_provider_factory(),
1192            Config {
1193                project_root: Some(root.clone()),
1194                experimental_bash_rewrite: true,
1195                ..Default::default()
1196            },
1197        );
1198
1199        assert!(matches!(
1200            HeadRule.decide("head -2 src/app.ts", "head-off", None, &ctx),
1201            RewriteDecision::Decline(_)
1202        ));
1203        assert!(matches!(
1204            TailRule.decide("tail -2 src/app.ts", "tail-off", None, &ctx),
1205            RewriteDecision::Decline(_)
1206        ));
1207
1208        ctx.hashline_bindings().register(
1209            &root,
1210            DEFAULT_SESSION_ID.to_string(),
1211            RegistrationRequest {
1212                configured_enabled: true,
1213                edit_slot_survives: true,
1214            },
1215        );
1216        let RewriteDecision::Accept(request) =
1217            TailRule.decide("tail -2 src/app.ts", "tail-on", None, &ctx)
1218        else {
1219            panic!("effective hashline tail should rewrite");
1220        };
1221        let response = TailRule.execute(&request, &ctx);
1222        assert!(response.success, "{}", response.data);
1223        let content = response.data["content"].as_str().unwrap();
1224        assert!(content.starts_with("[src/app.ts#"), "{content}");
1225        assert!(content.contains("3:three\n4:four\n"), "{content}");
1226    }
1227}