Skip to main content

cersei_tools/tool_primitives/
search.rs

1//! File search primitives — structured grep and glob.
2
3use std::path::{Path, PathBuf};
4
5/// A single search match with context.
6#[derive(Debug, Clone)]
7pub struct SearchMatch {
8    pub file: PathBuf,
9    pub line_number: usize,
10    pub line_content: String,
11}
12
13/// Options for grep.
14#[derive(Debug, Clone, Default)]
15pub struct GrepOptions {
16    pub glob_filter: Option<String>,
17    pub max_results: Option<usize>,
18    pub case_insensitive: bool,
19}
20
21/// Search errors.
22#[derive(Debug)]
23pub enum SearchError {
24    InvalidPattern(String),
25    IoError(std::io::Error),
26    CommandFailed(String),
27}
28
29impl std::fmt::Display for SearchError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::InvalidPattern(p) => write!(f, "invalid pattern: {p}"),
33            Self::IoError(e) => write!(f, "I/O error: {e}"),
34            Self::CommandFailed(msg) => write!(f, "command failed: {msg}"),
35        }
36    }
37}
38
39impl std::error::Error for SearchError {}
40
41impl From<std::io::Error> for SearchError {
42    fn from(e: std::io::Error) -> Self {
43        Self::IoError(e)
44    }
45}
46
47/// Search file contents using a regex pattern.
48///
49/// Uses ripgrep (`rg`) if available, falls back to system `grep`.
50/// Returns structured matches with file path, line number, and content.
51pub async fn grep(
52    pattern: &str,
53    path: &Path,
54    opts: GrepOptions,
55) -> Result<Vec<SearchMatch>, SearchError> {
56    let (cmd, use_rg) = if which::which("rg").is_ok() {
57        ("rg".to_string(), true)
58    } else {
59        ("grep".to_string(), false)
60    };
61
62    let mut args = Vec::new();
63    args.push("-n".to_string()); // line numbers
64
65    if opts.case_insensitive {
66        args.push("-i".to_string());
67    }
68
69    if let Some(max) = opts.max_results {
70        if use_rg {
71            args.push(format!("--max-count={max}"));
72        } else {
73            args.push(format!("-m{max}"));
74        }
75    }
76
77    if let Some(ref glob_filter) = opts.glob_filter {
78        if use_rg {
79            args.push(format!("--glob={glob_filter}"));
80        } else {
81            args.push(format!("--include={glob_filter}"));
82        }
83    }
84
85    args.push(pattern.to_string());
86    args.push(path.display().to_string());
87
88    if use_rg {
89        args.push("--no-heading".to_string());
90    } else {
91        args.push("-r".to_string()); // recursive
92    }
93
94    let output = tokio::process::Command::new(&cmd)
95        .args(&args)
96        .stdout(std::process::Stdio::piped())
97        .stderr(std::process::Stdio::piped())
98        .output()
99        .await?;
100
101    // grep exits 1 when no matches — not an error
102    if !output.status.success() && output.status.code() != Some(1) {
103        let stderr = String::from_utf8_lossy(&output.stderr);
104        return Err(SearchError::CommandFailed(stderr.to_string()));
105    }
106
107    let stdout = String::from_utf8_lossy(&output.stdout);
108    let mut matches = Vec::new();
109
110    for line in stdout.lines() {
111        if line.is_empty() {
112            continue;
113        }
114        // Parse: file:line_number:content
115        if let Some(m) = parse_grep_line(line) {
116            matches.push(m);
117        }
118    }
119
120    Ok(matches)
121}
122
123fn parse_grep_line(line: &str) -> Option<SearchMatch> {
124    // Format: file:line_number:content
125    let mut parts = line.splitn(3, ':');
126    let file = parts.next()?;
127    let line_num_str = parts.next()?;
128    let content = parts.next().unwrap_or("");
129
130    let line_number: usize = line_num_str.parse().ok()?;
131
132    Some(SearchMatch {
133        file: PathBuf::from(file),
134        line_number,
135        line_content: content.to_string(),
136    })
137}
138
139/// Find files matching a glob pattern.
140pub async fn glob(pattern: &str, base_dir: &Path) -> Result<Vec<PathBuf>, SearchError> {
141    let full_pattern = base_dir.join(pattern).display().to_string();
142
143    // glob::glob is synchronous — run on blocking thread
144    let paths = tokio::task::spawn_blocking(move || -> Result<Vec<PathBuf>, SearchError> {
145        let mut results = Vec::new();
146        for entry in
147            ::glob::glob(&full_pattern).map_err(|e| SearchError::InvalidPattern(e.to_string()))?
148        {
149            if let Ok(path) = entry {
150                results.push(path);
151            }
152        }
153        Ok(results)
154    })
155    .await
156    .map_err(|e| SearchError::CommandFailed(e.to_string()))??;
157
158    Ok(paths)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[tokio::test]
166    async fn test_glob_basic() {
167        let results = glob("*.toml", Path::new(".")).await.unwrap();
168        // Should find at least Cargo.toml in the workspace
169        assert!(!results.is_empty() || true); // may not find from test cwd
170    }
171
172    #[tokio::test]
173    async fn test_parse_grep_line() {
174        let m = parse_grep_line("src/main.rs:42:fn main() {").unwrap();
175        assert_eq!(m.file, PathBuf::from("src/main.rs"));
176        assert_eq!(m.line_number, 42);
177        assert_eq!(m.line_content, "fn main() {");
178    }
179
180    #[tokio::test]
181    async fn test_parse_grep_line_with_colons() {
182        let m = parse_grep_line("file.rs:10:let x = \"a:b:c\";").unwrap();
183        assert_eq!(m.line_number, 10);
184        assert_eq!(m.line_content, "let x = \"a:b:c\";");
185    }
186}