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")
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 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
117fn 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 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
139fn 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"); 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 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
182fn parse_grep_output(output: &str, max_results: usize) -> anyhow::Result<Vec<SearchMatch>> {
184 parse_rg_output(output, max_results)
186}
187
188pub fn find_files(pattern: &str, directory: &Path) -> anyhow::Result<Vec<String>> {
190 let mut files = Vec::new();
191
192 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}