sparrow/tools/
file_search.rs1use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug, Clone)]
11pub struct SearchMatch {
12 pub file: String,
13 pub line_number: u64,
14 pub content: String,
15}
16
17#[derive(Debug, Clone)]
19pub struct SearchOptions {
20 pub file_glob: Option<String>,
22 pub max_results: usize,
24 pub case_insensitive: bool,
26 pub context_lines: usize,
28 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
44pub 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
59fn 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
70fn 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 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
119fn 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 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
141fn 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"); 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 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
186fn parse_grep_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
188 parse_rg_output(output, max_results)
190}
191
192pub fn find_files(pattern: &str, directory: &Path) -> anyhow::Result<Vec<String>> {
194 let mut files = Vec::new();
195
196 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}