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
519fn 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 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_request(command: &str, binary: &str) -> Option<Value> {
776 let parsed = parse(command)?;
777 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
778 return None;
779 }
780
781 let mut case_sensitive = true;
782 let mut word_match = false;
783 let mut index = 1;
784
785 while let Some(arg) = parsed.args.get(index) {
786 if !arg.starts_with('-') || arg == "-" {
787 break;
788 }
789 for flag in arg[1..].chars() {
790 match flag {
791 'n' | 'r' => {}
792 'i' => case_sensitive = false,
793 'w' => word_match = true,
794 _ => return None,
795 }
796 }
797 index += 1;
798 }
799
800 let pattern = parsed.args.get(index)?.clone();
801 let path = parsed.args.get(index + 1).cloned();
802 if parsed.args.len() > index + 2 {
803 return None;
804 }
805
806 let pattern = if word_match {
807 format!(r"\b(?:{})\b", pattern)
808 } else {
809 pattern
810 };
811
812 if regex::RegexBuilder::new(&pattern)
813 .size_limit(REGEX_SIZE_LIMIT)
814 .build()
815 .is_err()
816 {
817 return None;
818 }
819
820 let mut params = json!({
821 "pattern": pattern,
822 "case_sensitive": case_sensitive,
823 "max_results": 100,
824 });
825 if let Some(path) = path {
826 params["path"] = json!(path);
827 }
828 Some(params)
829}
830
831fn find_request(command: &str) -> Option<Value> {
832 let parsed = parse(command)?;
833 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
834 return None;
835 }
836 if parsed.args.len() != 4 && parsed.args.len() != 6 {
837 return None;
838 }
839
840 let path = parsed.args.get(1)?.clone();
841 let mut name = None;
842 let mut saw_type_file = false;
843 let mut index = 2;
844
845 while index < parsed.args.len() {
846 match parsed.args[index].as_str() {
847 "-name" if name.is_none() && index + 1 < parsed.args.len() => {
848 name = Some(parsed.args[index + 1].clone());
849 index += 2;
850 }
851 "-type" if !saw_type_file && index + 1 < parsed.args.len() => {
852 if parsed.args[index + 1] != "f" {
853 return None;
854 }
855 saw_type_file = true;
856 index += 2;
857 }
858 _ => return None,
859 }
860 }
861
862 let name = name?;
863 let pattern = format!("**/{name}");
864 if path == "." {
865 Some(json!({ "pattern": pattern }))
866 } else {
867 let trimmed = path.trim_end_matches('/');
868 if trimmed.is_empty() {
869 None
875 } else {
876 Some(json!({ "path": trimmed, "pattern": pattern }))
877 }
878 }
879}
880
881fn cat_read_request(command: &str) -> Option<Value> {
882 let parsed = parse(command)?;
883 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
884 return None;
885 }
886 if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
887 return None;
888 }
889 Some(json!({ "file": parsed.args[1] }))
890}
891
892fn head_tail_read_request(command: &str, command_name: &str) -> Option<Value> {
893 let parsed = parse(command)?;
894 if parsed.appends_to.is_some()
895 || parsed.heredoc.is_some()
896 || parsed.args.first()? != command_name
897 {
898 return None;
899 }
900
901 let (lines, file) = match parsed.args.as_slice() {
902 [_command, file] => (10_u64, file.as_str()),
903 [_command, flag, count, file] if flag == "-n" => {
904 (count.parse::<u64>().ok()?, file.as_str())
905 }
906 [_command, compact, file] => {
907 let count = compact.strip_prefix('-')?.parse::<u64>().ok()?;
908 (count, file.as_str())
909 }
910 _ => return None,
911 };
912 if lines == 0 {
913 return None;
914 }
915 Some(json!({ "file": file, "limit": lines }))
916}
917
918fn append_request(command: &str) -> Option<Value> {
919 let parsed = parse(command)?;
920 let file = parsed.appends_to.clone()?;
921
922 let append_content = if parsed.args == ["cat"] {
923 parsed.heredoc?
924 } else if parsed.heredoc.is_none()
925 && parsed.args.first().is_some_and(|arg| arg == "echo")
926 && parsed.args.len() >= 2
927 && !parsed.args[1].starts_with('-')
928 {
929 format!("{}\n", parsed.args[1..].join(" "))
930 } else {
931 return None;
932 };
933
934 Some(json!({
935 "op": "append",
936 "file": file,
937 "append_content": append_content,
938 "create_dirs": true,
939 }))
940}
941
942fn sed_request(command: &str) -> Option<Value> {
943 let parsed = parse(command)?;
944 if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
945 return None;
946 }
947 if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
948 return None;
949 }
950
951 let range = parsed.args[2].strip_suffix('p')?;
952 let (start, end) = range.split_once(',')?;
953 let start_line = start.parse::<u32>().ok()?;
954 let end_line = end.parse::<u32>().ok()?;
955 if start_line == 0 || end_line < start_line {
956 return None;
957 }
958
959 Some(json!({
960 "file": parsed.args[3],
961 "start_line": start_line,
962 "end_line": end_line,
963 }))
964}
965
966fn ls_request(command: &str, ctx: &AppContext) -> Option<Value> {
967 let parsed = parse(command)?;
968 if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
969 return None;
970 }
971
972 let mut path = None;
973 let mut include_hidden = false;
974 for arg in parsed.args.iter().skip(1) {
975 if let Some(flags) = arg.strip_prefix('-') {
976 if flags.is_empty() {
977 return None;
978 }
979 for flag in flags.chars() {
980 match flag {
981 'R' => {}
985 'a' => include_hidden = true,
989 'A' => return None,
993 _ => return None,
999 }
1000 }
1001 } else if path.is_none() {
1002 path = Some(arg.clone());
1003 } else {
1004 return None;
1005 }
1006 }
1007
1008 let target = path.clone().unwrap_or_else(|| ".".to_string());
1014 let root = grep_project_root(ctx);
1015 let target_for_metadata = if Path::new(&target).is_absolute() {
1016 Path::new(&target).to_path_buf()
1017 } else {
1018 root.join(&target)
1019 };
1020 if let Ok(metadata) = std::fs::metadata(&target_for_metadata) {
1021 if !metadata.is_dir() || !path_is_safe(ctx, &target, true) {
1022 return None;
1023 }
1024 } else {
1025 return None;
1029 }
1030
1031 Some(json!({ "file": target, "include_hidden": include_hidden }))
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use std::fs;
1037 use std::time::{Duration, SystemTime};
1038
1039 use serde_json::json;
1040
1041 use super::{find_request, should_suppress_grep_footer, HeadRule, TailRule};
1042 use crate::bash_rewrite::{RewriteDecision, RewriteRule};
1043 use crate::config::Config;
1044 use crate::context::{default_language_provider_factory, AppContext};
1045 use crate::hashline::integration::RegistrationRequest;
1046 use crate::protocol::DEFAULT_SESSION_ID;
1047
1048 fn fixture() -> tempfile::TempDir {
1049 let dir = tempfile::tempdir().unwrap();
1050 fs::create_dir(dir.path().join("src")).unwrap();
1051 fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
1052 dir
1053 }
1054
1055 #[test]
1056 fn single_named_file_suppresses_grep_footer() {
1057 let dir = fixture();
1058 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1059 }
1060
1061 #[test]
1062 fn directory_path_keeps_grep_footer() {
1063 let dir = fixture();
1064 filetime::set_file_mtime(
1065 dir.path().join("src"),
1066 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1067 )
1068 .unwrap();
1069 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1070 }
1071
1072 #[test]
1073 fn no_path_keeps_grep_footer() {
1074 let dir = fixture();
1075 assert!(!should_suppress_grep_footer(None, dir.path()));
1076 }
1077
1078 #[test]
1079 fn external_file_suppresses_grep_footer() {
1080 let dir = fixture();
1081 let external = tempfile::NamedTempFile::new().unwrap();
1082 assert!(should_suppress_grep_footer(
1083 external.path().to_str(),
1084 dir.path()
1085 ));
1086 }
1087
1088 #[test]
1089 fn freshly_modified_file_suppresses_grep_footer() {
1090 let dir = fixture();
1091 let file = dir.path().join("src/app.ts");
1092 fs::write(&file, "foo\nbar\n").unwrap();
1093 assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
1094 }
1095
1096 #[test]
1097 fn old_directory_inside_project_root_keeps_grep_footer() {
1098 let dir = fixture();
1099 let directory = dir.path().join("src");
1100 filetime::set_file_mtime(
1101 &directory,
1102 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1103 )
1104 .unwrap();
1105 assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1106 }
1107
1108 #[test]
1109 fn old_file_inside_project_root_suppresses_grep_footer() {
1110 let dir = fixture();
1111 let file = dir.path().join("src/app.ts");
1112 filetime::set_file_mtime(
1113 &file,
1114 filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1115 )
1116 .unwrap();
1117 assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1118 }
1119
1120 #[test]
1121 fn find_absolute_path_uses_glob_path_arg() {
1122 assert_eq!(
1123 find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
1124 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1125 );
1126 }
1127
1128 #[test]
1129 fn find_dot_keeps_project_root_relative_pattern() {
1130 assert_eq!(
1131 find_request(r#"find . -name "*.ts" -type f"#),
1132 Some(json!({ "pattern": "**/*.ts" }))
1133 );
1134 }
1135
1136 #[test]
1137 fn find_relative_path_uses_glob_path_arg() {
1138 assert_eq!(
1139 find_request(r#"find ./src -name "*.go""#),
1140 Some(json!({ "path": "./src", "pattern": "**/*.go" }))
1141 );
1142 }
1143
1144 #[test]
1145 fn find_trims_trailing_slash_from_path_arg() {
1146 assert_eq!(
1147 find_request(r#"find /tmp/foo/ -name "*.ts""#),
1148 Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1149 );
1150 }
1151
1152 #[test]
1153 fn find_filesystem_root_is_not_rewritten() {
1154 assert_eq!(find_request(r#"find / -name "*.rs""#), None);
1157 assert_eq!(find_request(r#"find // -name "*.rs""#), None);
1158 }
1159
1160 #[test]
1161 fn head_and_tail_rewrites_are_hashline_only_and_tail_keeps_absolute_numbering() {
1162 let dir = fixture();
1163 let root = fs::canonicalize(dir.path()).unwrap();
1164 fs::write(root.join("src/app.ts"), "one\ntwo\nthree\nfour\n").unwrap();
1165 let ctx = AppContext::new(
1166 default_language_provider_factory(),
1167 Config {
1168 project_root: Some(root.clone()),
1169 experimental_bash_rewrite: true,
1170 ..Default::default()
1171 },
1172 );
1173
1174 assert!(matches!(
1175 HeadRule.decide("head -2 src/app.ts", "head-off", None, &ctx),
1176 RewriteDecision::Decline(_)
1177 ));
1178 assert!(matches!(
1179 TailRule.decide("tail -2 src/app.ts", "tail-off", None, &ctx),
1180 RewriteDecision::Decline(_)
1181 ));
1182
1183 ctx.hashline_bindings().register(
1184 &root,
1185 DEFAULT_SESSION_ID.to_string(),
1186 RegistrationRequest {
1187 configured_enabled: true,
1188 edit_slot_survives: true,
1189 },
1190 );
1191 let RewriteDecision::Accept(request) =
1192 TailRule.decide("tail -2 src/app.ts", "tail-on", None, &ctx)
1193 else {
1194 panic!("effective hashline tail should rewrite");
1195 };
1196 let response = TailRule.execute(&request, &ctx);
1197 assert!(response.success, "{}", response.data);
1198 let content = response.data["content"].as_str().unwrap();
1199 assert!(content.starts_with("[src/app.ts#"), "{content}");
1200 assert!(content.contains("3:three\n4:four\n"), "{content}");
1201 }
1202}