Skip to main content

ito_core/
grep.rs

1//! Grep-style search over Ito change artifacts.
2//!
3//! Provides a consistent search interface that works whether artifacts live on
4//! the local filesystem (`.ito/`) or have been materialised from a remote
5//! backend into a local cache. The search engine uses the ripgrep crate
6//! ecosystem (`grep-regex`, `grep-searcher`) so callers get familiar regex
7//! semantics without shelling out.
8
9use std::path::{Path, PathBuf};
10
11use grep_regex::RegexMatcher;
12use grep_searcher::Searcher;
13use grep_searcher::sinks::UTF8;
14
15use crate::errors::{CoreError, CoreResult};
16
17/// A single matching line returned by the grep engine.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GrepMatch {
20    /// Absolute path of the file that matched.
21    pub path: PathBuf,
22    /// 1-based line number within the file.
23    pub line_number: u64,
24    /// The full text of the matching line (without trailing newline).
25    pub line: String,
26}
27
28/// Scope of the grep search.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum GrepScope {
31    /// Search artifacts belonging to a single change.
32    Change(String),
33    /// Search artifacts belonging to all changes in a module.
34    Module(String),
35    /// Search artifacts across all changes in the project.
36    All,
37}
38
39/// Input parameters for a grep operation.
40#[derive(Debug, Clone)]
41pub struct GrepInput {
42    /// The regex pattern to search for.
43    pub pattern: String,
44    /// The scope of the search.
45    pub scope: GrepScope,
46    /// Maximum number of matching lines to return (0 = unlimited).
47    pub limit: usize,
48}
49
50/// Result of a grep operation.
51#[derive(Debug, Clone)]
52pub struct GrepOutput {
53    /// The matching lines found.
54    pub matches: Vec<GrepMatch>,
55    /// Whether the output was truncated due to the limit.
56    pub truncated: bool,
57}
58
59/// Search the given files for lines matching `pattern`, returning at most
60/// `limit` results (0 means unlimited).
61///
62/// This is the core search engine used by all grep scopes. It uses the
63/// ripgrep crate ecosystem for fast, correct regex matching.
64///
65/// # Errors
66///
67/// Returns `CoreError::Validation` if the pattern is not a valid regex.
68/// Files that cannot be read are skipped (logged at debug level) so one
69/// unreadable file does not fail the entire search.
70pub fn search_files(files: &[PathBuf], pattern: &str, limit: usize) -> CoreResult<GrepOutput> {
71    let matcher = RegexMatcher::new(pattern)
72        .map_err(|e| CoreError::validation(format!("invalid grep pattern: {e}")))?;
73
74    let mut matches: Vec<GrepMatch> = Vec::new();
75    let mut truncated = false;
76    let mut searcher = Searcher::new();
77
78    for file_path in files {
79        if limit > 0 && matches.len() >= limit {
80            truncated = true;
81            break;
82        }
83
84        let search_result = searcher.search_path(
85            &matcher,
86            file_path,
87            UTF8(|line_number, line_text| {
88                if limit > 0 && matches.len() >= limit {
89                    truncated = true;
90                    return Ok(false);
91                }
92
93                matches.push(GrepMatch {
94                    path: file_path.clone(),
95                    line_number,
96                    line: line_text.trim_end_matches(&['\r', '\n'][..]).to_string(),
97                });
98
99                if limit > 0 && matches.len() >= limit {
100                    truncated = true;
101                    Ok(false)
102                } else {
103                    Ok(true)
104                }
105            }),
106        );
107
108        // Skip files that cannot be read (e.g. binary, permission denied)
109        // rather than failing the entire search.
110        if let Err(e) = search_result {
111            tracing::debug!("skipping file {}: {e}", file_path.display());
112        }
113    }
114
115    Ok(GrepOutput { matches, truncated })
116}
117
118/// Collect all artifact markdown files for a single change directory.
119///
120/// Returns paths to `proposal.md`, `design.md`, `tasks.md`, and any
121/// `specs/<name>/spec.md` files found under the change directory.
122pub fn collect_change_artifact_files(change_dir: &Path) -> Vec<PathBuf> {
123    let mut files = Vec::new();
124
125    let known_files = ["proposal.md", "design.md", "tasks.md"];
126    for name in &known_files {
127        let p = change_dir.join(name);
128        if p.is_file() {
129            files.push(p);
130        }
131    }
132
133    let specs_dir = change_dir.join("specs");
134    if specs_dir.is_dir()
135        && let Ok(entries) = std::fs::read_dir(&specs_dir)
136    {
137        let mut spec_dirs: Vec<_> = entries
138            .filter_map(|e| e.ok())
139            .filter(|e| e.path().is_dir())
140            .collect();
141        spec_dirs.sort_by_key(|e| e.file_name());
142
143        for entry in spec_dirs {
144            let spec_file = entry.path().join("spec.md");
145            if spec_file.is_file() {
146                files.push(spec_file);
147            }
148        }
149    }
150
151    files
152}
153
154/// Resolve the grep scope to a list of artifact files and execute the search.
155///
156/// # Arguments
157///
158/// * `ito_path` - Path to the `.ito/` directory.
159/// * `input` - The grep parameters (pattern, scope, limit).
160/// * `change_repo` - A change repository for resolving targets and listing changes.
161/// * `module_repo` - A module repository for resolving module targets.
162///
163/// # Errors
164///
165/// Returns errors if the change/module cannot be found or the pattern is invalid.
166pub fn grep(
167    ito_path: &Path,
168    input: &GrepInput,
169    change_repo: &dyn ito_domain::changes::ChangeRepository,
170    module_repo: &dyn ito_domain::modules::ModuleRepository,
171) -> CoreResult<GrepOutput> {
172    let files = resolve_scope_files(ito_path, &input.scope, change_repo, module_repo)?;
173    search_files(&files, &input.pattern, input.limit)
174}
175
176/// Resolve a grep scope into the list of artifact files to search.
177fn resolve_scope_files(
178    ito_path: &Path,
179    scope: &GrepScope,
180    change_repo: &dyn ito_domain::changes::ChangeRepository,
181    module_repo: &dyn ito_domain::modules::ModuleRepository,
182) -> CoreResult<Vec<PathBuf>> {
183    match scope {
184        GrepScope::Change(change_id) => {
185            let resolution = change_repo.resolve_target(change_id);
186            let actual_id = match resolution {
187                ito_domain::changes::ChangeTargetResolution::Unique(id) => id,
188                ito_domain::changes::ChangeTargetResolution::Ambiguous(matches) => {
189                    return Err(CoreError::validation(format!(
190                        "ambiguous change target '{change_id}', matches: {}",
191                        matches.join(", ")
192                    )));
193                }
194                ito_domain::changes::ChangeTargetResolution::NotFound => {
195                    return Err(CoreError::not_found(format!(
196                        "change '{change_id}' not found"
197                    )));
198                }
199            };
200            let change_dir = ito_common::paths::change_dir(ito_path, &actual_id);
201            Ok(collect_change_artifact_files(&change_dir))
202        }
203
204        GrepScope::Module(module_id) => {
205            // Verify the module exists
206            let module = module_repo
207                .get(module_id)
208                .map_err(|e| CoreError::not_found(format!("module '{module_id}': {e}")))?;
209
210            // List all changes in the module
211            let changes = change_repo.list_by_module(&module.id)?;
212            Ok(collect_files_for_changes(ito_path, &changes))
213        }
214
215        GrepScope::All => {
216            let changes = change_repo.list()?;
217            Ok(collect_files_for_changes(ito_path, &changes))
218        }
219    }
220}
221
222/// Collect all artifact files for a list of changes.
223fn collect_files_for_changes(
224    ito_path: &Path,
225    changes: &[ito_domain::changes::ChangeSummary],
226) -> Vec<PathBuf> {
227    let mut files = Vec::new();
228    for change in changes {
229        let change_dir = ito_common::paths::change_dir(ito_path, &change.id);
230        files.extend(collect_change_artifact_files(&change_dir));
231    }
232    files
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::fs;
239    use tempfile::TempDir;
240
241    fn setup_test_dir(tmp: &TempDir) -> PathBuf {
242        let ito = tmp.path().join(".ito");
243        fs::create_dir_all(ito.join("changes/001-01_test-change/specs/auth")).unwrap();
244        fs::write(
245            ito.join("changes/001-01_test-change/proposal.md"),
246            "# Proposal\n\nThis adds auth support.\n",
247        )
248        .unwrap();
249        fs::write(
250            ito.join("changes/001-01_test-change/tasks.md"),
251            "# Tasks\n- [ ] Add login endpoint\n- [ ] Add tests\n",
252        )
253        .unwrap();
254        fs::write(
255            ito.join("changes/001-01_test-change/specs/auth/spec.md"),
256            "## ADDED Requirements\n\n### Requirement: Login\nThe system SHALL provide login.\n\n#### Scenario: Success\n- **WHEN** valid creds\n- **THEN** token returned\n",
257        )
258        .unwrap();
259        ito
260    }
261
262    #[test]
263    fn collect_change_artifact_files_finds_all_md_files() {
264        let tmp = TempDir::new().unwrap();
265        let ito = setup_test_dir(&tmp);
266        let change_dir = ito.join("changes/001-01_test-change");
267
268        let files = collect_change_artifact_files(&change_dir);
269        assert_eq!(files.len(), 3); // proposal, tasks, specs/auth/spec.md (no design.md)
270
271        let mut names: Vec<String> = Vec::new();
272        for p in &files {
273            names.push(
274                p.strip_prefix(&change_dir)
275                    .unwrap()
276                    .to_string_lossy()
277                    .to_string(),
278            );
279        }
280        assert!(names.contains(&"proposal.md".to_string()));
281        assert!(names.contains(&"tasks.md".to_string()));
282        assert!(names.contains(&"specs/auth/spec.md".to_string()));
283    }
284
285    #[test]
286    fn search_files_finds_matching_lines() {
287        let tmp = TempDir::new().unwrap();
288        let ito = setup_test_dir(&tmp);
289        let change_dir = ito.join("changes/001-01_test-change");
290
291        let files = collect_change_artifact_files(&change_dir);
292        let output = search_files(&files, "Requirement:", 0).unwrap();
293
294        assert_eq!(output.matches.len(), 1);
295        assert!(output.matches[0].line.contains("Requirement: Login"));
296        assert!(!output.truncated);
297    }
298
299    #[test]
300    fn search_files_respects_limit() {
301        let tmp = TempDir::new().unwrap();
302        let ito = setup_test_dir(&tmp);
303        let change_dir = ito.join("changes/001-01_test-change");
304
305        let files = collect_change_artifact_files(&change_dir);
306        // Search for something that matches many lines
307        let output = search_files(&files, ".", 2).unwrap();
308
309        assert_eq!(output.matches.len(), 2);
310        assert!(output.truncated);
311    }
312
313    #[test]
314    fn search_files_returns_empty_for_no_matches() {
315        let tmp = TempDir::new().unwrap();
316        let ito = setup_test_dir(&tmp);
317        let change_dir = ito.join("changes/001-01_test-change");
318
319        let files = collect_change_artifact_files(&change_dir);
320        let output = search_files(&files, "ZZZZZZZ_NOMATCH", 0).unwrap();
321
322        assert!(output.matches.is_empty());
323        assert!(!output.truncated);
324    }
325
326    #[test]
327    fn search_files_rejects_invalid_regex() {
328        let files = vec![PathBuf::from("/nonexistent")];
329        let result = search_files(&files, "[invalid", 0);
330        assert!(result.is_err());
331        let err = result.unwrap_err().to_string();
332        assert!(err.contains("invalid grep pattern"));
333    }
334
335    #[test]
336    fn search_files_includes_correct_line_numbers() {
337        let tmp = TempDir::new().unwrap();
338        let ito = setup_test_dir(&tmp);
339
340        let files = vec![ito.join("changes/001-01_test-change/tasks.md")];
341        let output = search_files(&files, r"Add", 0).unwrap();
342
343        assert_eq!(output.matches.len(), 2);
344        // "Add login endpoint" should be line 2, "Add tests" should be line 3
345        assert_eq!(output.matches[0].line_number, 2);
346        assert_eq!(output.matches[1].line_number, 3);
347    }
348}