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_basic_regex_has_dialect_conflict(pattern: &str) -> bool {
776 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 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' => {}
997 'a' => include_hidden = true,
1001 'A' => return None,
1005 _ => return None,
1011 }
1012 }
1013 } else if path.is_none() {
1014 path = Some(arg.clone());
1015 } else {
1016 return None;
1017 }
1018 }
1019
1020 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 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 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}