1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::process::Stdio;
6use std::sync::{Arc, OnceLock};
7
8const MAX_RESULTS: usize = 1000;
9const MAX_OUTPUT_BYTES: usize = 1_048_576; const TIMEOUT_SECS: u64 = 30;
11
12pub struct ContentSearchTool {
17 security: Arc<SecurityPolicy>,
18 has_rg: bool,
19}
20
21impl ContentSearchTool {
22 pub fn new(security: Arc<SecurityPolicy>) -> Self {
23 let has_rg = which::which("rg").is_ok();
24 Self { security, has_rg }
25 }
26
27 #[cfg(test)]
28 fn new_with_backend(security: Arc<SecurityPolicy>, has_rg: bool) -> Self {
29 Self { security, has_rg }
30 }
31}
32
33#[async_trait]
34impl Tool for ContentSearchTool {
35 fn name(&self) -> &str {
36 "content_search"
37 }
38
39 fn description(&self) -> &str {
40 "Search file contents by regex pattern within the workspace. \
41 Supports ripgrep (rg) with grep fallback. \
42 Output modes: 'content' (matching lines with context), \
43 'files_with_matches' (file paths only), 'count' (match counts per file). \
44 Example: pattern='fn main', include='*.rs', output_mode='content'."
45 }
46
47 fn parameters_schema(&self) -> serde_json::Value {
48 json!({
49 "type": "object",
50 "properties": {
51 "pattern": {
52 "type": "string",
53 "description": "Regular expression pattern to search for"
54 },
55 "path": {
56 "type": "string",
57 "description": "Directory to search in, relative to workspace root. Defaults to '.'",
58 "default": "."
59 },
60 "output_mode": {
61 "type": "string",
62 "description": "Output format: 'content' (matching lines), 'files_with_matches' (paths only), 'count' (match counts)",
63 "enum": ["content", "files_with_matches", "count"],
64 "default": "content"
65 },
66 "include": {
67 "type": "string",
68 "description": "File glob filter, e.g. '*.rs', '*.{ts,tsx}'"
69 },
70 "case_sensitive": {
71 "type": "boolean",
72 "description": "Case-sensitive matching. Defaults to true",
73 "default": true
74 },
75 "context_before": {
76 "type": "integer",
77 "description": "Lines of context before each match (content mode only)",
78 "default": 0
79 },
80 "context_after": {
81 "type": "integer",
82 "description": "Lines of context after each match (content mode only)",
83 "default": 0
84 },
85 "multiline": {
86 "type": "boolean",
87 "description": "Enable multiline matching (ripgrep only, errors on grep fallback)",
88 "default": false
89 },
90 "max_results": {
91 "type": "integer",
92 "description": "Maximum number of results to return. Defaults to 1000",
93 "default": 1000
94 }
95 },
96 "required": ["pattern"]
97 })
98 }
99
100 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
101 let pattern = args
103 .get("pattern")
104 .and_then(|v| v.as_str())
105 .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?;
106
107 if pattern.is_empty() {
108 return Ok(ToolResult {
109 success: false,
110 output: String::new(),
111 error: Some("Empty pattern is not allowed.".into()),
112 });
113 }
114
115 let search_path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
116
117 let output_mode = args
118 .get("output_mode")
119 .and_then(|v| v.as_str())
120 .unwrap_or("content");
121
122 if !matches!(output_mode, "content" | "files_with_matches" | "count") {
123 return Ok(ToolResult {
124 success: false,
125 output: String::new(),
126 error: Some(format!(
127 "Invalid output_mode '{output_mode}'. Allowed values: content, files_with_matches, count."
128 )),
129 });
130 }
131
132 let include = args.get("include").and_then(|v| v.as_str());
133
134 let case_sensitive = args
135 .get("case_sensitive")
136 .and_then(|v| v.as_bool())
137 .unwrap_or(true);
138
139 #[allow(clippy::cast_possible_truncation)]
140 let context_before = args
141 .get("context_before")
142 .and_then(|v| v.as_u64())
143 .unwrap_or(0) as usize;
144
145 #[allow(clippy::cast_possible_truncation)]
146 let context_after = args
147 .get("context_after")
148 .and_then(|v| v.as_u64())
149 .unwrap_or(0) as usize;
150
151 let multiline = args
152 .get("multiline")
153 .and_then(|v| v.as_bool())
154 .unwrap_or(false);
155
156 #[allow(clippy::cast_possible_truncation)]
157 let max_results = args
158 .get("max_results")
159 .and_then(|v| v.as_u64())
160 .map(|v| v as usize)
161 .unwrap_or(MAX_RESULTS)
162 .min(MAX_RESULTS);
163
164 if self.security.is_rate_limited() {
166 return Ok(ToolResult {
167 success: false,
168 output: String::new(),
169 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
170 });
171 }
172
173 if std::path::Path::new(search_path).is_absolute()
176 && !self.security.is_under_allowed_root(search_path)
177 {
178 return Ok(ToolResult {
179 success: false,
180 output: String::new(),
181 error: Some("Absolute paths are not allowed. Use a relative path.".into()),
182 });
183 }
184
185 if search_path.contains("../") || search_path.contains("..\\") || search_path == ".." {
186 return Ok(ToolResult {
187 success: false,
188 output: String::new(),
189 error: Some("Path traversal ('..') is not allowed.".into()),
190 });
191 }
192
193 if !self.security.is_path_allowed(search_path) {
194 return Ok(ToolResult {
195 success: false,
196 output: String::new(),
197 error: Some(format!(
198 "Path '{search_path}' is not allowed by security policy."
199 )),
200 });
201 }
202
203 if !self.security.record_action() {
205 return Ok(ToolResult {
206 success: false,
207 output: String::new(),
208 error: Some("Rate limit exceeded: action budget exhausted".into()),
209 });
210 }
211
212 let resolved_path = self.security.resolve_tool_path(search_path);
214
215 let resolved_canon = match std::fs::canonicalize(&resolved_path) {
216 Ok(p) => p,
217 Err(e) => {
218 return Ok(ToolResult {
219 success: false,
220 output: String::new(),
221 error: Some(format!("Cannot resolve path '{search_path}': {e}")),
222 });
223 }
224 };
225
226 if !self.security.is_resolved_path_allowed(&resolved_canon) {
227 return Ok(ToolResult {
228 success: false,
229 output: String::new(),
230 error: Some(format!(
231 "Resolved path for '{search_path}' is outside the allowed workspace."
232 )),
233 });
234 }
235
236 if multiline && !self.has_rg {
238 return Ok(ToolResult {
239 success: false,
240 output: String::new(),
241 error: Some(
242 "Multiline matching requires ripgrep (rg), which is not available.".into(),
243 ),
244 });
245 }
246
247 let mut cmd = if self.has_rg {
249 build_rg_command(
250 pattern,
251 &resolved_canon,
252 output_mode,
253 include,
254 case_sensitive,
255 context_before,
256 context_after,
257 multiline,
258 )
259 } else {
260 build_grep_command(
261 pattern,
262 &resolved_canon,
263 output_mode,
264 include,
265 case_sensitive,
266 context_before,
267 context_after,
268 )
269 };
270
271 cmd.env_clear();
273 for key in &["PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE"] {
274 if let Ok(val) = std::env::var(key) {
275 cmd.env(key, val);
276 }
277 }
278
279 cmd.stdout(Stdio::piped());
280 cmd.stderr(Stdio::piped());
281
282 let output = match tokio::time::timeout(
283 std::time::Duration::from_secs(TIMEOUT_SECS),
284 tokio::process::Command::from(cmd).output(),
285 )
286 .await
287 {
288 Ok(Ok(out)) => out,
289 Ok(Err(e)) => {
290 return Ok(ToolResult {
291 success: false,
292 output: String::new(),
293 error: Some(format!("Failed to execute search command: {e}")),
294 });
295 }
296 Err(_) => {
297 return Ok(ToolResult {
298 success: false,
299 output: String::new(),
300 error: Some(format!("Search timed out after {TIMEOUT_SECS} seconds.")),
301 });
302 }
303 };
304
305 let exit_code = output.status.code().unwrap_or(-1);
307 if exit_code >= 2 {
308 let stderr = String::from_utf8_lossy(&output.stderr);
309 return Ok(ToolResult {
310 success: false,
311 output: String::new(),
312 error: Some(format!("Search error: {}", stderr.trim())),
313 });
314 }
315
316 let raw_stdout = String::from_utf8_lossy(&output.stdout);
317
318 let workspace = &self.security.workspace_dir;
320 let workspace_canon =
321 std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.clone());
322
323 let formatted = if self.has_rg {
324 format_rg_output(&raw_stdout, &workspace_canon, output_mode, max_results)
325 } else {
326 format_grep_output(&raw_stdout, &workspace_canon, output_mode, max_results)
327 };
328
329 let final_output = if formatted.len() > MAX_OUTPUT_BYTES {
331 let mut truncated = truncate_utf8(&formatted, MAX_OUTPUT_BYTES).to_string();
332 truncated.push_str("\n\n[Output truncated: exceeded 1 MB limit]");
333 truncated
334 } else {
335 formatted
336 };
337
338 Ok(ToolResult {
339 success: true,
340 output: final_output,
341 error: None,
342 })
343 }
344}
345
346fn build_rg_command(
347 pattern: &str,
348 search_path: &std::path::Path,
349 output_mode: &str,
350 include: Option<&str>,
351 case_sensitive: bool,
352 context_before: usize,
353 context_after: usize,
354 multiline: bool,
355) -> std::process::Command {
356 let mut cmd = std::process::Command::new("rg");
357
358 cmd.arg("--no-heading");
360 cmd.arg("--line-number");
361 cmd.arg("--with-filename");
362
363 match output_mode {
364 "files_with_matches" => {
365 cmd.arg("--files-with-matches");
366 }
367 "count" => {
368 cmd.arg("--count");
369 }
370 _ => {
371 if context_before > 0 {
373 cmd.arg("-B").arg(context_before.to_string());
374 }
375 if context_after > 0 {
376 cmd.arg("-A").arg(context_after.to_string());
377 }
378 }
379 }
380
381 if !case_sensitive {
382 cmd.arg("-i");
383 }
384
385 if multiline {
386 cmd.arg("-U");
387 cmd.arg("--multiline-dotall");
388 }
389
390 if let Some(glob) = include {
391 cmd.arg("--glob").arg(glob);
392 }
393
394 cmd.arg("--");
396 cmd.arg(pattern);
397 cmd.arg(search_path);
398
399 cmd
400}
401
402fn build_grep_command(
403 pattern: &str,
404 search_path: &std::path::Path,
405 output_mode: &str,
406 include: Option<&str>,
407 case_sensitive: bool,
408 context_before: usize,
409 context_after: usize,
410) -> std::process::Command {
411 let mut cmd = std::process::Command::new("grep");
412
413 cmd.arg("-r"); cmd.arg("-n"); cmd.arg("-E"); cmd.arg("--binary-files=without-match");
417
418 match output_mode {
419 "files_with_matches" => {
420 cmd.arg("-l");
421 }
422 "count" => {
423 cmd.arg("-c");
424 }
425 _ => {
426 if context_before > 0 {
428 cmd.arg("-B").arg(context_before.to_string());
429 }
430 if context_after > 0 {
431 cmd.arg("-A").arg(context_after.to_string());
432 }
433 }
434 }
435
436 if !case_sensitive {
437 cmd.arg("-i");
438 }
439
440 if let Some(glob) = include {
441 cmd.arg("--include").arg(glob);
442 }
443
444 cmd.arg("--");
445 cmd.arg(pattern);
446 cmd.arg(search_path);
447
448 cmd
449}
450
451fn format_rg_output(
452 raw: &str,
453 workspace_canon: &std::path::Path,
454 output_mode: &str,
455 max_results: usize,
456) -> String {
457 format_line_output(raw, workspace_canon, output_mode, max_results)
458}
459
460fn format_grep_output(
461 raw: &str,
462 workspace_canon: &std::path::Path,
463 output_mode: &str,
464 max_results: usize,
465) -> String {
466 format_line_output(raw, workspace_canon, output_mode, max_results)
467}
468
469fn format_line_output(
476 raw: &str,
477 workspace_canon: &std::path::Path,
478 output_mode: &str,
479 max_results: usize,
480) -> String {
481 if raw.trim().is_empty() {
482 return "No matches found.".to_string();
483 }
484
485 let workspace_prefix = workspace_canon.to_string_lossy();
486
487 let mut lines: Vec<String> = Vec::new();
488 let mut truncated = false;
489 let mut file_set = std::collections::HashSet::new();
490 let mut total_matches: usize = 0;
491
492 for line in raw.lines() {
493 if line.is_empty() {
494 continue;
495 }
496
497 let relativized = relativize_path(line, &workspace_prefix);
499
500 match output_mode {
501 "files_with_matches" => {
502 let path = relativized.trim();
503 if !path.is_empty() && file_set.insert(path.to_string()) {
504 lines.push(path.to_string());
505 if lines.len() >= max_results {
506 truncated = true;
507 break;
508 }
509 }
510 }
511 "count" => {
512 if let Some((path, count)) = parse_count_line(&relativized) {
514 if count > 0 {
515 file_set.insert(path.to_string());
516 total_matches += count;
517 lines.push(format!("{path}:{count}"));
518 if lines.len() >= max_results {
519 truncated = true;
520 break;
521 }
522 }
523 }
524 }
525 _ => {
526 if relativized == "--" {
529 lines.push(relativized);
530 if lines.len() >= max_results {
531 truncated = true;
532 break;
533 }
534 continue;
535 }
536 if let Some((path, is_match)) = parse_content_line(&relativized) {
537 file_set.insert(path.to_string());
538 if is_match {
539 total_matches += 1;
540 }
541 } else {
542 total_matches += 1;
544 }
545 lines.push(relativized);
546 if lines.len() >= max_results {
547 truncated = true;
548 break;
549 }
550 }
551 }
552 }
553
554 if lines.is_empty() {
555 return "No matches found.".to_string();
556 }
557
558 use std::fmt::Write;
559 let mut buf = lines.join("\n");
560
561 if truncated {
562 let _ = write!(
563 buf,
564 "\n\n[Results truncated: showing first {max_results} results]"
565 );
566 }
567
568 match output_mode {
569 "files_with_matches" => {
570 let _ = write!(buf, "\n\nTotal: {} files", file_set.len());
571 }
572 "count" => {
573 let _ = write!(
574 buf,
575 "\n\nTotal: {} matches in {} files",
576 total_matches,
577 file_set.len()
578 );
579 }
580 _ => {
581 let _ = write!(
583 buf,
584 "\n\nTotal: {} matching lines in {} files",
585 total_matches,
586 file_set.len()
587 );
588 }
589 }
590
591 buf
592}
593
594fn relativize_path(line: &str, workspace_prefix: &str) -> String {
596 if let Some(rest) = line.strip_prefix(workspace_prefix) {
597 let trimmed = rest
599 .strip_prefix('/')
600 .or_else(|| rest.strip_prefix('\\'))
601 .unwrap_or(rest);
602 return trimmed.to_string();
603 }
604 line.to_string()
605}
606
607fn parse_content_line(line: &str) -> Option<(&str, bool)> {
613 static MATCH_RE: OnceLock<regex::Regex> = OnceLock::new();
614 static CONTEXT_RE: OnceLock<regex::Regex> = OnceLock::new();
615
616 let match_re = MATCH_RE.get_or_init(|| {
617 regex::Regex::new(r"^(?P<path>.+?):\d+:").expect("match line regex must be valid")
618 });
619 if let Some(caps) = match_re.captures(line) {
620 return caps.name("path").map(|m| (m.as_str(), true));
621 }
622
623 let context_re = CONTEXT_RE.get_or_init(|| {
624 regex::Regex::new(r"^(?P<path>.+?)-\d+-").expect("context line regex must be valid")
625 });
626 if let Some(caps) = context_re.captures(line) {
627 return caps.name("path").map(|m| (m.as_str(), false));
628 }
629
630 None
631}
632
633fn parse_count_line(line: &str) -> Option<(&str, usize)> {
635 static COUNT_RE: OnceLock<regex::Regex> = OnceLock::new();
636 let count_re = COUNT_RE.get_or_init(|| {
637 regex::Regex::new(r"^(?P<path>.+?):(?P<count>\d+)\s*$").expect("count line regex valid")
638 });
639
640 let caps = count_re.captures(line)?;
641 let path = caps.name("path")?.as_str();
642 let count = caps.name("count")?.as_str().parse::<usize>().ok()?;
643 Some((path, count))
644}
645
646fn truncate_utf8(input: &str, max_bytes: usize) -> &str {
647 if input.len() <= max_bytes {
648 return input;
649 }
650 let mut end = max_bytes;
651 while end > 0 && !input.is_char_boundary(end) {
652 end -= 1;
653 }
654 &input[..end]
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::security::{AutonomyLevel, SecurityPolicy};
661 use std::path::PathBuf;
662 use tempfile::TempDir;
663
664 fn test_security(workspace: PathBuf) -> Arc<SecurityPolicy> {
665 Arc::new(SecurityPolicy {
666 autonomy: AutonomyLevel::Supervised,
667 workspace_dir: workspace,
668 ..SecurityPolicy::default()
669 })
670 }
671
672 fn test_security_with(
673 workspace: PathBuf,
674 autonomy: AutonomyLevel,
675 max_actions_per_hour: u32,
676 ) -> Arc<SecurityPolicy> {
677 Arc::new(SecurityPolicy {
678 autonomy,
679 workspace_dir: workspace,
680 max_actions_per_hour,
681 ..SecurityPolicy::default()
682 })
683 }
684
685 fn create_test_files(dir: &TempDir) {
686 std::fs::write(
687 dir.path().join("hello.rs"),
688 "fn main() {\n println!(\"hello\");\n}\n",
689 )
690 .unwrap();
691 std::fs::write(
692 dir.path().join("lib.rs"),
693 "pub fn greet() {\n println!(\"greet\");\n}\n",
694 )
695 .unwrap();
696 std::fs::write(dir.path().join("readme.txt"), "This is a readme file.\n").unwrap();
697 }
698
699 #[test]
700 fn content_search_name_and_schema() {
701 let tool = ContentSearchTool::new(test_security(std::env::temp_dir()));
702 assert_eq!(tool.name(), "content_search");
703
704 let schema = tool.parameters_schema();
705 assert!(schema["properties"]["pattern"].is_object());
706 assert!(schema["properties"]["path"].is_object());
707 assert!(schema["properties"]["output_mode"].is_object());
708 assert!(
709 schema["required"]
710 .as_array()
711 .unwrap()
712 .contains(&json!("pattern"))
713 );
714 }
715
716 #[tokio::test]
717 async fn content_search_basic_match() {
718 let dir = TempDir::new().unwrap();
719 create_test_files(&dir);
720
721 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
722 let result = tool.execute(json!({"pattern": "fn main"})).await.unwrap();
723
724 assert!(result.success);
725 assert!(result.output.contains("hello.rs"));
726 assert!(result.output.contains("fn main"));
727 }
728
729 #[tokio::test]
730 async fn content_search_files_with_matches_mode() {
731 let dir = TempDir::new().unwrap();
732 create_test_files(&dir);
733
734 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
735 let result = tool
736 .execute(json!({"pattern": "println", "output_mode": "files_with_matches"}))
737 .await
738 .unwrap();
739
740 assert!(result.success);
741 assert!(result.output.contains("hello.rs"));
742 assert!(result.output.contains("lib.rs"));
743 assert!(!result.output.contains("readme.txt"));
744 assert!(result.output.contains("Total: 2 files"));
745 }
746
747 #[tokio::test]
748 async fn content_search_count_mode() {
749 let dir = TempDir::new().unwrap();
750 create_test_files(&dir);
751
752 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
753 let result = tool
754 .execute(json!({"pattern": "println", "output_mode": "count"}))
755 .await
756 .unwrap();
757
758 assert!(result.success);
759 assert!(result.output.contains("hello.rs"));
760 assert!(result.output.contains("lib.rs"));
761 assert!(result.output.contains("Total:"));
762 }
763
764 #[tokio::test]
765 async fn content_search_case_insensitive() {
766 let dir = TempDir::new().unwrap();
767 std::fs::write(dir.path().join("test.txt"), "Hello World\nhello world\n").unwrap();
768
769 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
770 let result = tool
771 .execute(json!({"pattern": "HELLO", "case_sensitive": false}))
772 .await
773 .unwrap();
774
775 assert!(result.success);
776 assert!(result.output.contains("Hello World"));
777 assert!(result.output.contains("hello world"));
778 }
779
780 #[tokio::test]
781 async fn content_search_include_filter() {
782 let dir = TempDir::new().unwrap();
783 create_test_files(&dir);
784
785 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
786 let result = tool
787 .execute(json!({"pattern": "fn", "include": "*.rs"}))
788 .await
789 .unwrap();
790
791 assert!(result.success);
792 assert!(result.output.contains("hello.rs"));
793 assert!(!result.output.contains("readme.txt"));
794 }
795
796 #[tokio::test]
797 async fn content_search_context_lines() {
798 let dir = TempDir::new().unwrap();
799 std::fs::write(
800 dir.path().join("ctx.rs"),
801 "line1\nline2\ntarget_line\nline4\nline5\n",
802 )
803 .unwrap();
804
805 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
806 let result = tool
807 .execute(json!({"pattern": "target_line", "context_before": 1, "context_after": 1}))
808 .await
809 .unwrap();
810
811 assert!(result.success);
812 assert!(result.output.contains("target_line"));
813 assert!(result.output.contains("line2"));
814 assert!(result.output.contains("line4"));
815 }
816
817 #[tokio::test]
818 async fn content_search_no_matches() {
819 let dir = TempDir::new().unwrap();
820 create_test_files(&dir);
821
822 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
823 let result = tool
824 .execute(json!({"pattern": "nonexistent_string_xyz"}))
825 .await
826 .unwrap();
827
828 assert!(result.success);
829 assert!(result.output.contains("No matches found"));
830 }
831
832 #[tokio::test]
833 async fn content_search_empty_pattern_rejected() {
834 let tool = ContentSearchTool::new(test_security(std::env::temp_dir()));
835 let result = tool.execute(json!({"pattern": ""})).await.unwrap();
836
837 assert!(!result.success);
838 assert!(result.error.as_ref().unwrap().contains("Empty pattern"));
839 }
840
841 #[tokio::test]
842 async fn content_search_missing_pattern() {
843 let tool = ContentSearchTool::new(test_security(std::env::temp_dir()));
844 let result = tool.execute(json!({})).await;
845 assert!(result.is_err());
846 }
847
848 #[tokio::test]
849 async fn content_search_invalid_output_mode_rejected() {
850 let dir = TempDir::new().unwrap();
851 create_test_files(&dir);
852
853 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
854 let result = tool
855 .execute(json!({"pattern": "fn", "output_mode": "invalid_mode"}))
856 .await
857 .unwrap();
858
859 assert!(!result.success);
860 assert!(
861 result
862 .error
863 .as_ref()
864 .unwrap()
865 .contains("Invalid output_mode")
866 );
867 }
868
869 #[tokio::test]
870 async fn content_search_subdirectory() {
871 let dir = TempDir::new().unwrap();
872 std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
873 std::fs::write(dir.path().join("sub/deep/nested.rs"), "fn nested() {}\n").unwrap();
874 std::fs::write(dir.path().join("root.rs"), "fn root() {}\n").unwrap();
875
876 let tool = ContentSearchTool::new(test_security(dir.path().to_path_buf()));
877 let result = tool
878 .execute(json!({"pattern": "fn nested", "path": "sub"}))
879 .await
880 .unwrap();
881
882 assert!(result.success);
883 assert!(result.output.contains("nested"));
884 assert!(!result.output.contains("root"));
885 }
886
887 #[tokio::test]
890 async fn content_search_rejects_absolute_path() {
891 let tool = ContentSearchTool::new(test_security(std::env::temp_dir()));
892 let result = tool
893 .execute(json!({"pattern": "test", "path": "/etc"}))
894 .await
895 .unwrap();
896
897 assert!(!result.success);
898 assert!(result.error.as_ref().unwrap().contains("Absolute paths"));
899 }
900
901 #[tokio::test]
902 async fn content_search_rejects_path_traversal() {
903 let tool = ContentSearchTool::new(test_security(std::env::temp_dir()));
904 let result = tool
905 .execute(json!({"pattern": "test", "path": "../../../etc"}))
906 .await
907 .unwrap();
908
909 assert!(!result.success);
910 assert!(result.error.as_ref().unwrap().contains("Path traversal"));
911 }
912
913 #[tokio::test]
914 async fn content_search_rate_limited() {
915 let dir = TempDir::new().unwrap();
916 std::fs::write(dir.path().join("file.txt"), "test content\n").unwrap();
917
918 let tool = ContentSearchTool::new(test_security_with(
919 dir.path().to_path_buf(),
920 AutonomyLevel::Supervised,
921 0,
922 ));
923 let result = tool.execute(json!({"pattern": "test"})).await.unwrap();
924
925 assert!(!result.success);
926 assert!(result.error.as_ref().unwrap().contains("Rate limit"));
927 }
928
929 #[cfg(unix)]
930 #[tokio::test]
931 async fn content_search_symlink_escape_blocked() {
932 use std::os::unix::fs::symlink;
933
934 let root = TempDir::new().unwrap();
935 let workspace = root.path().join("workspace");
936 let outside = root.path().join("outside");
937
938 std::fs::create_dir_all(&workspace).unwrap();
939 std::fs::create_dir_all(&outside).unwrap();
940 std::fs::write(outside.join("secret.txt"), "secret data\n").unwrap();
941
942 symlink(&outside, workspace.join("escape_dir")).unwrap();
944 std::fs::write(workspace.join("legit.txt"), "legit data\n").unwrap();
946
947 let tool = ContentSearchTool::new(test_security(workspace.clone()));
948 let result = tool.execute(json!({"pattern": "data"})).await.unwrap();
949
950 assert!(result.success);
951 assert!(result.output.contains("legit.txt"));
953 }
956
957 #[tokio::test]
958 async fn content_search_multiline_without_rg() {
959 let dir = TempDir::new().unwrap();
960 std::fs::write(dir.path().join("test.txt"), "line1\nline2\n").unwrap();
961
962 let tool = ContentSearchTool::new_with_backend(
963 test_security(dir.path().to_path_buf()),
964 false, );
966 let result = tool
967 .execute(json!({"pattern": "line1", "multiline": true}))
968 .await
969 .unwrap();
970
971 assert!(!result.success);
972 assert!(result.error.as_ref().unwrap().contains("ripgrep"));
973 }
974
975 #[test]
976 fn relativize_path_strips_prefix() {
977 let result = relativize_path("/workspace/src/main.rs:42:fn main()", "/workspace");
978 assert_eq!(result, "src/main.rs:42:fn main()");
979 }
980
981 #[test]
982 fn relativize_path_no_prefix() {
983 let result = relativize_path("src/main.rs:42:fn main()", "/workspace");
984 assert_eq!(result, "src/main.rs:42:fn main()");
985 }
986
987 #[test]
988 fn format_line_output_content_counts_match_lines_only() {
989 let raw = "src/main.rs-1-use std::fmt;\nsrc/main.rs:2:fn main() {}\n--\nsrc/lib.rs:10:pub fn f() {}";
990 let output = format_line_output(raw, std::path::Path::new("/workspace"), "content", 100);
991 assert!(output.contains("Total: 2 matching lines in 2 files"));
992 }
993
994 #[test]
995 fn parse_count_line_supports_colons_in_path() {
996 let parsed = parse_count_line("dir:with:colon/file.rs:12");
997 assert_eq!(parsed, Some(("dir:with:colon/file.rs", 12)));
998 }
999
1000 #[test]
1001 fn truncate_utf8_keeps_char_boundary() {
1002 let text = "abc你好";
1003 let truncated = truncate_utf8(text, 4);
1005 assert_eq!(truncated, "abc");
1006 }
1007}