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").arg(options.context_lines.to_string());
92    }
93
94    if let Some(ref glob) = options.file_glob {
95        cmd.arg("--glob").arg(glob);
96    }
97
98    cmd.arg("--").arg(pattern);
99    cmd.arg(directory);
100
101    let output = cmd.output()?;
102
103    if !output.status.success() {
104        // rg returns 1 when no matches found — not an error
105        let code = output.status.code().unwrap_or(0);
106        if code == 1 {
107            return Ok(Vec::new());
108        }
109        let stderr = String::from_utf8_lossy(&output.stderr);
110        anyhow::bail!("ripgrep failed: {stderr}");
111    }
112
113    parse_rg_output(
114        &String::from_utf8_lossy(&output.stdout),
115        options.max_results,
116    )
117}
118
119/// Parse ripgrep output into SearchMatch structs.
120fn parse_rg_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
121    let mut matches = Vec::new();
122
123    for line in output.lines().take(max_results) {
124        // rg output format: file.rs:42:matched line content
125        if let Some((file_part, rest)) = line.split_once(':') {
126            if let Some((line_num_str, content)) = rest.split_once(':') {
127                if let Ok(line_number) = line_num_str.parse::<u64>() {
128                    matches.push(SearchMatch {
129                        file: file_part.to_string(),
130                        line_number,
131                        content: content.trim().to_string(),
132                    });
133                }
134            }
135        }
136    }
137
138    Ok(matches)
139}
140
141/// Fallback search using grep.
142fn search_with_grep(
143    pattern: &str,
144    directory: &Path,
145    options: &SearchOptions,
146) -> anyhow::Result<Vec<SearchMatch>> {
147    let mut cmd = Command::new("grep");
148
149    cmd.arg("-rn"); // recursive, line numbers
150    cmd.arg("--color=never");
151
152    if options.case_insensitive {
153        cmd.arg("-i");
154    }
155
156    if options.whole_word {
157        cmd.arg("-w");
158    }
159
160    if options.context_lines > 0 {
161        cmd.arg("-C").arg(options.context_lines.to_string());
162    }
163
164    if let Some(ref glob) = options.file_glob {
165        cmd.arg("--include").arg(glob);
166    }
167
168    cmd.arg(pattern);
169    cmd.arg(directory);
170
171    let output = cmd.output()?;
172
173    // grep returns 1 when no matches
174    let code = output.status.code().unwrap_or(0);
175    if code > 1 {
176        let stderr = String::from_utf8_lossy(&output.stderr);
177        anyhow::bail!("grep failed: {stderr}");
178    }
179
180    parse_grep_output(
181        &String::from_utf8_lossy(&output.stdout),
182        options.max_results,
183    )
184}
185
186/// Parse grep output.
187fn parse_grep_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
188    // Same format as rg: file:line:content
189    parse_rg_output(output, max_results)
190}
191
192/// Search for files by name pattern (glob).
193pub fn find_files(pattern: &str, directory: &Path) -> anyhow::Result<Vec<String>> {
194    let mut files = Vec::new();
195
196    // Use fd if available, else find
197    if fd_available() {
198        let output = Command::new("fd")
199            .arg("--type")
200            .arg("f")
201            .arg(pattern)
202            .arg(directory)
203            .output()?;
204
205        for line in String::from_utf8_lossy(&output.stdout).lines() {
206            files.push(line.to_string());
207        }
208    } else {
209        let output = Command::new("find")
210            .arg(directory)
211            .arg("-name")
212            .arg(pattern)
213            .arg("-type")
214            .arg("f")
215            .output()?;
216
217        for line in String::from_utf8_lossy(&output.stdout).lines() {
218            files.push(line.to_string());
219        }
220    }
221
222    files.sort();
223    Ok(files)
224}
225
226fn fd_available() -> bool {
227    Command::new("fd")
228        .arg("--version")
229        .stdout(std::process::Stdio::null())
230        .stderr(std::process::Stdio::null())
231        .status()
232        .map(|s| s.success())
233        .unwrap_or(false)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_parse_rg_output() {
242        let output = "src/main.rs:42:fn main() {\nsrc/lib.rs:10:pub mod tools;\n";
243        let matches = parse_rg_output(output, 10).unwrap();
244        assert_eq!(matches.len(), 2);
245        assert_eq!(matches[0].file, "src/main.rs");
246        assert_eq!(matches[0].line_number, 42);
247        assert_eq!(matches[1].content, "pub mod tools;");
248    }
249}