Skip to main content

construct/tools/
content_search.rs

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; // 1 MB
10const TIMEOUT_SECS: u64 = 30;
11
12/// Search file contents by regex pattern within the workspace.
13///
14/// Uses ripgrep (`rg`) when available, falling back to `grep -rn -E`.
15/// All searches are confined to the workspace directory by security policy.
16pub 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        // --- Parse parameters ---
102        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        // --- Rate limit check ---
165        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        // --- Path security checks ---
174        // Reject absolute paths unless they fall under an explicit allowed root.
175        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        // Record action to consume rate limit budget
204        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        // --- Resolve search directory ---
213        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        // --- Multiline check for grep fallback ---
237        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        // --- Build and execute command ---
248        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        // Security: clear environment, keep only safe variables
272        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        // Exit code: 0 = matches found, 1 = no matches (grep/rg), 2 = error
306        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        // --- Parse and format output ---
319        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        // Truncate output if too large
330        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    // Use line-based output for structured parsing
359    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            // content mode (default)
372            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    // Separator to prevent pattern from being parsed as flag
395    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"); // recursive
414    cmd.arg("-n"); // line numbers
415    cmd.arg("-E"); // extended regex
416    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            // content mode
427            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
469/// Shared formatting for both rg and grep line-based outputs.
470///
471/// Both tools produce similar line-based output in our configuration:
472/// - content mode: `path:line:content` or `path-line-content` (context lines)
473/// - files_with_matches mode: `path`
474/// - count mode: `path:count`
475fn 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        // Relativize paths: strip workspace prefix
498        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                // Format: path:count — filter out zero-count entries
513                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                // content mode: pass through with relativized paths
527                // Track files from both match and context lines.
528                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                    // Unknown line format: keep output visible and count conservatively as a match.
543                    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            // content mode: show summary
582            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
594/// Strip workspace prefix from a line, converting absolute paths to relative.
595fn relativize_path(line: &str, workspace_prefix: &str) -> String {
596    if let Some(rest) = line.strip_prefix(workspace_prefix) {
597        // Strip leading separator
598        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
607/// Parse content output line and determine whether it is a real match line.
608///
609/// Supported formats:
610/// - Match line: `path:line:content`
611/// - Context line: `path-line-content`
612fn 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
633/// Parse count output line in `path:count` format.
634fn 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    // --- Security tests ---
888
889    #[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 inside workspace pointing outside
943        symlink(&outside, workspace.join("escape_dir")).unwrap();
944        // Also add a legitimate file
945        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        // Legit file should be found
952        assert!(result.output.contains("legit.txt"));
953        // The search runs in workspace, rg/grep may or may not follow symlinks,
954        // but results are relativized — we mainly verify no crash
955    }
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, // no rg
965        );
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        // Byte index 4 splits the first Chinese character.
1004        let truncated = truncate_utf8(text, 4);
1005        assert_eq!(truncated, "abc");
1006    }
1007}