Skip to main content

sparrow/tools/
file_search.rs

1//! File search tool for Sparrow.
2//!
3//! Fast file content search using ripgrep (rg) with fallback to grep.
4//! Returns formatted results with file paths, line numbers, and context.
5
6use std::path::Path;
7use std::process::Command;
8
9/// Search result for a single match.
10#[derive(Debug, Clone)]
11pub struct SearchMatch {
12    pub file: String,
13    pub line_number: u64,
14    pub content: String,
15}
16
17/// Search options.
18#[derive(Debug, Clone)]
19pub struct SearchOptions {
20    /// File glob pattern (e.g., "*.rs", "*.py")
21    pub file_glob: Option<String>,
22    /// Max results to return
23    pub max_results: usize,
24    /// Case insensitive search
25    pub case_insensitive: bool,
26    /// Show N lines of context around matches
27    pub context_lines: usize,
28    /// Only match whole words
29    pub whole_word: bool,
30}
31
32impl Default for SearchOptions {
33    fn default() -> Self {
34        Self {
35            file_glob: None,
36            max_results: 50,
37            case_insensitive: true,
38            context_lines: 0,
39            whole_word: false,
40        }
41    }
42}
43
44/// Search for a pattern in files.
45///
46/// Uses ripgrep if available, falls back to grep.
47pub fn search_files(
48    pattern: &str,
49    directory: &Path,
50    options: &SearchOptions,
51) -> anyhow::Result<Vec<SearchMatch>> {
52    if rg_available() {
53        search_with_rg(pattern, directory, options)
54    } else {
55        search_with_grep(pattern, directory, options)
56    }
57}
58
59/// Check if ripgrep is available.
60fn rg_available() -> bool {
61    Command::new("rg")
62        .arg("--version")
63        .stdout(std::process::Stdio::null())
64        .stderr(std::process::Stdio::null())
65        .status()
66        .map(|s| s.success())
67        .unwrap_or(false)
68}
69
70/// Search using ripgrep.
71fn search_with_rg(
72    pattern: &str,
73    directory: &Path,
74    options: &SearchOptions,
75) -> anyhow::Result<Vec<SearchMatch>> {
76    let mut cmd = Command::new("rg");
77
78    cmd.arg("--line-number");
79    cmd.arg("--no-heading");
80    cmd.arg("--color=never");
81
82    if options.case_insensitive {
83        cmd.arg("--ignore-case");
84    }
85
86    if options.whole_word {
87        cmd.arg("--word-regexp");
88    }
89
90    if options.context_lines > 0 {
91        cmd.arg("-C")
92            .arg(options.context_lines.to_string());
93    }
94
95    if let Some(ref glob) = options.file_glob {
96        cmd.arg("--glob").arg(glob);
97    }
98
99    cmd.arg("--").arg(pattern);
100    cmd.arg(directory);
101
102    let output = cmd.output()?;
103
104    if !output.status.success() {
105        // rg returns 1 when no matches found — not an error
106        let code = output.status.code().unwrap_or(0);
107        if code == 1 {
108            return Ok(Vec::new());
109        }
110        let stderr = String::from_utf8_lossy(&output.stderr);
111        anyhow::bail!("ripgrep failed: {stderr}");
112    }
113
114    parse_rg_output(&String::from_utf8_lossy(&output.stdout), options.max_results)
115}
116
117/// Parse ripgrep output into SearchMatch structs.
118fn parse_rg_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
119    let mut matches = Vec::new();
120
121    for line in output.lines().take(max_results) {
122        // rg output format: file.rs:42:matched line content
123        if let Some((file_part, rest)) = line.split_once(':') {
124            if let Some((line_num_str, content)) = rest.split_once(':') {
125                if let Ok(line_number) = line_num_str.parse::<u64>() {
126                    matches.push(SearchMatch {
127                        file: file_part.to_string(),
128                        line_number,
129                        content: content.trim().to_string(),
130                    });
131                }
132            }
133        }
134    }
135
136    Ok(matches)
137}
138
139/// Fallback search using grep.
140fn search_with_grep(
141    pattern: &str,
142    directory: &Path,
143    options: &SearchOptions,
144) -> anyhow::Result<Vec<SearchMatch>> {
145    let mut cmd = Command::new("grep");
146
147    cmd.arg("-rn"); // recursive, line numbers
148    cmd.arg("--color=never");
149
150    if options.case_insensitive {
151        cmd.arg("-i");
152    }
153
154    if options.whole_word {
155        cmd.arg("-w");
156    }
157
158    if options.context_lines > 0 {
159        cmd.arg("-C")
160            .arg(options.context_lines.to_string());
161    }
162
163    if let Some(ref glob) = options.file_glob {
164        cmd.arg("--include").arg(glob);
165    }
166
167    cmd.arg(pattern);
168    cmd.arg(directory);
169
170    let output = cmd.output()?;
171
172    // grep returns 1 when no matches
173    let code = output.status.code().unwrap_or(0);
174    if code > 1 {
175        let stderr = String::from_utf8_lossy(&output.stderr);
176        anyhow::bail!("grep failed: {stderr}");
177    }
178
179    parse_grep_output(&String::from_utf8_lossy(&output.stdout), options.max_results)
180}
181
182/// Parse grep output.
183fn parse_grep_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
184    // Same format as rg: file:line:content
185    parse_rg_output(output, max_results)
186}
187
188/// Search for files by name pattern (glob).
189pub fn find_files(pattern: &str, directory: &Path) -> anyhow::Result<Vec<String>> {
190    let mut files = Vec::new();
191
192    // Use fd if available, else find
193    if fd_available() {
194        let output = Command::new("fd")
195            .arg("--type").arg("f")
196            .arg(pattern)
197            .arg(directory)
198            .output()?;
199
200        for line in String::from_utf8_lossy(&output.stdout).lines() {
201            files.push(line.to_string());
202        }
203    } else {
204        let output = Command::new("find")
205            .arg(directory)
206            .arg("-name").arg(pattern)
207            .arg("-type").arg("f")
208            .output()?;
209
210        for line in String::from_utf8_lossy(&output.stdout).lines() {
211            files.push(line.to_string());
212        }
213    }
214
215    files.sort();
216    Ok(files)
217}
218
219fn fd_available() -> bool {
220    Command::new("fd")
221        .arg("--version")
222        .stdout(std::process::Stdio::null())
223        .stderr(std::process::Stdio::null())
224        .status()
225        .map(|s| s.success())
226        .unwrap_or(false)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_parse_rg_output() {
235        let output = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub mod tools;\n";
236        let matches = parse_rg_output(output, 10).unwrap();
237        assert_eq!(matches.len(), 2);
238        assert_eq!(matches[0].file, "src/main.rs");
239        assert_eq!(matches[0].line_number, 42);
240        assert_eq!(matches[1].content, "pub mod tools;");
241    }
242}