1use crate::harness::{ExecutionPolicy, NetworkPolicy, SandboxPolicy};
2use crate::{RunContext, RunMetadata};
3use async_trait::async_trait;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7use std::time::Duration;
8
9use super::command::{CommandExecutor, CommandOutputLimit, CommandRequest};
10use super::workspace::{FileBody, FileReadOptions, ListFilesQuery, Workspace, WorkspacePath};
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub enum SearchMode {
16 #[default]
18 Literal,
19 Regex,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RepoSearchRequest {
26 pub query: String,
28 pub paths: Vec<WorkspacePath>,
30 pub mode: SearchMode,
32 pub max_matches: usize,
34 pub context_lines: usize,
36 pub include_hidden: bool,
38 pub respect_gitignore: bool,
40}
41
42impl RepoSearchRequest {
43 pub fn literal(query: impl Into<String>) -> Self {
45 Self {
46 query: query.into(),
47 paths: Vec::new(),
48 mode: SearchMode::Literal,
49 max_matches: 100,
50 context_lines: 0,
51 include_hidden: false,
52 respect_gitignore: true,
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SearchMatch {
60 pub path: WorkspacePath,
62 pub line: usize,
64 pub column: Option<usize>,
66 pub line_text: String,
68 pub before: Vec<String>,
70 pub after: Vec<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct RepoSearchResults {
77 pub matches: Vec<SearchMatch>,
79 pub truncated: bool,
81 pub warnings: Vec<String>,
83 pub metadata: RunMetadata,
85}
86
87#[async_trait]
89pub trait RepoSearcher: Send + Sync {
90 async fn search(
92 &self,
93 request: RepoSearchRequest,
94 context: &RunContext,
95 ) -> Result<RepoSearchResults, SearchError>;
96}
97
98#[derive(Debug, Clone)]
100pub struct WorkspaceSearcher<W> {
101 workspace: W,
102 max_file_bytes: usize,
103}
104
105impl<W> WorkspaceSearcher<W> {
106 pub fn new(workspace: W) -> Self {
108 Self {
109 workspace,
110 max_file_bytes: 256 * 1024,
111 }
112 }
113
114 pub fn with_max_file_bytes(mut self, max_file_bytes: usize) -> Self {
116 self.max_file_bytes = max_file_bytes;
117 self
118 }
119}
120
121#[async_trait]
122impl<W> RepoSearcher for WorkspaceSearcher<W>
123where
124 W: Workspace,
125{
126 async fn search(
127 &self,
128 request: RepoSearchRequest,
129 _context: &RunContext,
130 ) -> Result<RepoSearchResults, SearchError> {
131 if request.query.is_empty() {
132 return Err(SearchError::InvalidQuery {
133 message: "search query must not be empty".to_string(),
134 });
135 }
136 let matcher = Matcher::new(&request.query, request.mode)?;
137 let paths = if request.paths.is_empty() {
138 vec![WorkspacePath::root()]
139 } else {
140 request.paths.clone()
141 };
142 let mut matches = Vec::new();
143 let mut truncated = false;
144 for path in paths {
145 let entries = self
146 .workspace
147 .list_files(ListFilesQuery {
148 path,
149 recursive: true,
150 max_entries: Some(20_000),
151 include_hidden: request.include_hidden,
152 respect_gitignore: request.respect_gitignore,
153 })
154 .await
155 .map_err(|error| SearchError::Workspace {
156 message: error.to_string(),
157 })?;
158 for entry in entries {
159 if matches.len() >= request.max_matches {
160 truncated = true;
161 break;
162 }
163 if entry.kind != super::workspace::ResolvedPathKind::File {
164 continue;
165 }
166 let content = self
167 .workspace
168 .read_file(
169 &entry.path,
170 FileReadOptions {
171 max_bytes: Some(self.max_file_bytes),
172 include_binary: false,
173 },
174 )
175 .await
176 .map_err(|error| SearchError::Workspace {
177 message: error.to_string(),
178 })?;
179 let FileBody::Text { text, .. } = content.body else {
180 continue;
181 };
182 let file_matches = search_text(&entry.path, &text, &matcher, request.context_lines);
183 for mat in file_matches {
184 if matches.len() >= request.max_matches {
185 truncated = true;
186 break;
187 }
188 matches.push(mat);
189 }
190 }
191 }
192 Ok(RepoSearchResults {
193 matches,
194 truncated,
195 warnings: Vec::new(),
196 metadata: RunMetadata::new(),
197 })
198 }
199}
200
201#[derive(Clone)]
203pub struct RipgrepSearcher<C> {
204 commands: Arc<C>,
205 timeout: Duration,
206}
207
208impl<C> std::fmt::Debug for RipgrepSearcher<C> {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.debug_struct("RipgrepSearcher")
211 .field("timeout", &self.timeout)
212 .finish_non_exhaustive()
213 }
214}
215
216impl<C> RipgrepSearcher<C> {
217 pub fn new(commands: C) -> Self {
219 Self {
220 commands: Arc::new(commands),
221 timeout: Duration::from_secs(10),
222 }
223 }
224
225 pub fn with_timeout(mut self, timeout: Duration) -> Self {
227 self.timeout = timeout;
228 self
229 }
230}
231
232#[async_trait]
233impl<C> RepoSearcher for RipgrepSearcher<C>
234where
235 C: CommandExecutor + 'static,
236{
237 async fn search(
238 &self,
239 request: RepoSearchRequest,
240 context: &RunContext,
241 ) -> Result<RepoSearchResults, SearchError> {
242 if request.mode == SearchMode::Regex {
243 Regex::new(&request.query).map_err(|error| SearchError::InvalidQuery {
244 message: format!("invalid regex: {error}"),
245 })?;
246 }
247 let mut argv = vec![
248 "rg".to_string(),
249 "--line-number".to_string(),
250 "--column".to_string(),
251 "--no-heading".to_string(),
252 "--color".to_string(),
253 "never".to_string(),
254 ];
255 if request.mode == SearchMode::Literal {
256 argv.push("--fixed-strings".to_string());
257 }
258 if request.include_hidden {
259 argv.push("--hidden".to_string());
260 }
261 if !request.respect_gitignore {
262 argv.push("--no-ignore".to_string());
263 }
264 argv.push(request.query.clone());
265 for path in &request.paths {
266 argv.push(path.display());
267 }
268 let mut command = CommandRequest::new(argv);
269 command.timeout = Some(self.timeout);
270 command.output_limit = CommandOutputLimit {
271 stdout_bytes: 512 * 1024,
272 stderr_bytes: 64 * 1024,
273 };
274 let output = self
275 .commands
276 .execute(
277 command,
278 &ExecutionPolicy::new(SandboxPolicy::ReadOnly, NetworkPolicy::Deny)
279 .with_timeout(Some(self.timeout)),
280 context,
281 )
282 .await
283 .map_err(|error| SearchError::Command {
284 message: error.to_string(),
285 })?;
286 let mut warnings = Vec::new();
287 if !output.stderr.text.trim().is_empty() {
288 warnings.push(output.stderr.text);
289 }
290 let mut matches = Vec::new();
291 for line in output.stdout.text.lines() {
292 if matches.len() >= request.max_matches {
293 break;
294 }
295 if let Some(mat) = parse_rg_line(line) {
296 matches.push(mat);
297 }
298 }
299 let hit_match_limit = matches.len() >= request.max_matches;
300 Ok(RepoSearchResults {
301 matches,
302 truncated: output.truncated || output.stdout.truncated || hit_match_limit,
303 warnings,
304 metadata: output.metadata,
305 })
306 }
307}
308
309enum Matcher {
310 Literal(String),
311 Regex(Regex),
312}
313
314impl Matcher {
315 fn new(query: &str, mode: SearchMode) -> Result<Self, SearchError> {
316 match mode {
317 SearchMode::Literal => Ok(Self::Literal(query.to_string())),
318 SearchMode::Regex => {
319 Regex::new(query)
320 .map(Self::Regex)
321 .map_err(|error| SearchError::InvalidQuery {
322 message: format!("invalid regex: {error}"),
323 })
324 }
325 }
326 }
327
328 fn find(&self, line: &str) -> Option<usize> {
329 match self {
330 Self::Literal(query) => line.find(query),
331 Self::Regex(regex) => regex.find(line).map(|mat| mat.start()),
332 }
333 }
334}
335
336fn search_text(
337 path: &WorkspacePath,
338 text: &str,
339 matcher: &Matcher,
340 context_lines: usize,
341) -> Vec<SearchMatch> {
342 let lines: Vec<_> = text.lines().collect();
343 let mut matches = Vec::new();
344 for (index, line) in lines.iter().enumerate() {
345 let Some(column) = matcher.find(line) else {
346 continue;
347 };
348 let before_start = index.saturating_sub(context_lines);
349 let after_end = (index + context_lines + 1).min(lines.len());
350 matches.push(SearchMatch {
351 path: path.clone(),
352 line: index + 1,
353 column: Some(column + 1),
354 line_text: (*line).to_string(),
355 before: lines[before_start..index]
356 .iter()
357 .map(|line| (*line).to_string())
358 .collect(),
359 after: lines[index + 1..after_end]
360 .iter()
361 .map(|line| (*line).to_string())
362 .collect(),
363 });
364 }
365 matches
366}
367
368fn parse_rg_line(line: &str) -> Option<SearchMatch> {
369 let mut parts = line.splitn(4, ':');
370 let path = WorkspacePath::parse(parts.next()?).ok()?;
371 let line_number = parts.next()?.parse().ok()?;
372 let column = parts.next()?.parse().ok();
373 let line_text = parts.next().unwrap_or_default().to_string();
374 Some(SearchMatch {
375 path,
376 line: line_number,
377 column,
378 line_text,
379 before: Vec::new(),
380 after: Vec::new(),
381 })
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
386#[non_exhaustive]
387pub enum SearchError {
388 #[error("invalid search query: {message}")]
390 InvalidQuery {
391 message: String,
393 },
394 #[error("workspace search error: {message}")]
396 Workspace {
397 message: String,
399 },
400 #[error("search command error: {message}")]
402 Command {
403 message: String,
405 },
406 #[error("unsupported search operation: {message}")]
408 Unsupported {
409 message: String,
411 },
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::coding::LocalWorkspace;
418
419 fn temp_dir(tag: &str) -> std::path::PathBuf {
420 let dir =
421 std::env::temp_dir().join(format!("molo-search-test-{}-{tag}", std::process::id()));
422 let _ = std::fs::remove_dir_all(&dir);
423 std::fs::create_dir_all(&dir).unwrap();
424 dir
425 }
426
427 #[tokio::test]
428 async fn workspace_search_returns_structured_matches() {
429 let root = temp_dir("workspace");
430 std::fs::write(root.join("a.txt"), "one\ntwo\nthree").unwrap();
431 let searcher = WorkspaceSearcher::new(LocalWorkspace::new(&root).unwrap());
432 let results = searcher
433 .search(
434 RepoSearchRequest {
435 query: "two".to_string(),
436 paths: Vec::new(),
437 mode: SearchMode::Literal,
438 max_matches: 10,
439 context_lines: 1,
440 include_hidden: false,
441 respect_gitignore: true,
442 },
443 &RunContext::new("search"),
444 )
445 .await
446 .unwrap();
447 assert_eq!(results.matches.len(), 1);
448 assert_eq!(results.matches[0].line, 2);
449 assert_eq!(results.matches[0].before, vec!["one"]);
450 assert_eq!(results.matches[0].after, vec!["three"]);
451 let _ = std::fs::remove_dir_all(root);
452 }
453}