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<CR, MR>(
167    ito_path: &Path,
168    input: &GrepInput,
169    change_repo: &CR,
170    module_repo: &MR,
171) -> CoreResult<GrepOutput>
172where
173    CR: ito_domain::changes::ChangeRepository,
174    MR: ito_domain::modules::ModuleRepository,
175{
176    let files = resolve_scope_files(ito_path, &input.scope, change_repo, module_repo)?;
177    search_files(&files, &input.pattern, input.limit)
178}
179
180/// Resolve a grep scope into the list of artifact files to search.
181fn resolve_scope_files<CR, MR>(
182    ito_path: &Path,
183    scope: &GrepScope,
184    change_repo: &CR,
185    module_repo: &MR,
186) -> CoreResult<Vec<PathBuf>>
187where
188    CR: ito_domain::changes::ChangeRepository,
189    MR: ito_domain::modules::ModuleRepository,
190{
191    match scope {
192        GrepScope::Change(change_id) => {
193            let resolution = change_repo.resolve_target(change_id);
194            let actual_id = match resolution {
195                ito_domain::changes::ChangeTargetResolution::Unique(id) => id,
196                ito_domain::changes::ChangeTargetResolution::Ambiguous(matches) => {
197                    return Err(CoreError::validation(format!(
198                        "ambiguous change target '{change_id}', matches: {}",
199                        matches.join(", ")
200                    )));
201                }
202                ito_domain::changes::ChangeTargetResolution::NotFound => {
203                    return Err(CoreError::not_found(format!(
204                        "change '{change_id}' not found"
205                    )));
206                }
207            };
208            let change_dir = ito_common::paths::change_dir(ito_path, &actual_id);
209            Ok(collect_change_artifact_files(&change_dir))
210        }
211
212        GrepScope::Module(module_id) => {
213            // Verify the module exists
214            let module = module_repo
215                .get(module_id)
216                .map_err(|e| CoreError::not_found(format!("module '{module_id}': {e}")))?;
217
218            // List all changes in the module
219            let changes = change_repo.list_by_module(&module.id)?;
220            Ok(collect_files_for_changes(ito_path, &changes))
221        }
222
223        GrepScope::All => {
224            let changes = change_repo.list()?;
225            Ok(collect_files_for_changes(ito_path, &changes))
226        }
227    }
228}
229
230/// Collect all artifact files for a list of changes.
231fn collect_files_for_changes(
232    ito_path: &Path,
233    changes: &[ito_domain::changes::ChangeSummary],
234) -> Vec<PathBuf> {
235    let mut files = Vec::new();
236    for change in changes {
237        let change_dir = ito_common::paths::change_dir(ito_path, &change.id);
238        files.extend(collect_change_artifact_files(&change_dir));
239    }
240    files
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use std::fs;
247    use tempfile::TempDir;
248
249    fn setup_test_dir(tmp: &TempDir) -> PathBuf {
250        let ito = tmp.path().join(".ito");
251        fs::create_dir_all(ito.join("changes/001-01_test-change/specs/auth")).unwrap();
252        fs::write(
253            ito.join("changes/001-01_test-change/proposal.md"),
254            "# Proposal\n\nThis adds auth support.\n",
255        )
256        .unwrap();
257        fs::write(
258            ito.join("changes/001-01_test-change/tasks.md"),
259            "# Tasks\n- [ ] Add login endpoint\n- [ ] Add tests\n",
260        )
261        .unwrap();
262        fs::write(
263            ito.join("changes/001-01_test-change/specs/auth/spec.md"),
264            "## ADDED Requirements\n\n### Requirement: Login\nThe system SHALL provide login.\n\n#### Scenario: Success\n- **WHEN** valid creds\n- **THEN** token returned\n",
265        )
266        .unwrap();
267        ito
268    }
269
270    #[test]
271    fn collect_change_artifact_files_finds_all_md_files() {
272        let tmp = TempDir::new().unwrap();
273        let ito = setup_test_dir(&tmp);
274        let change_dir = ito.join("changes/001-01_test-change");
275
276        let files = collect_change_artifact_files(&change_dir);
277        assert_eq!(files.len(), 3); // proposal, tasks, specs/auth/spec.md (no design.md)
278
279        let mut names: Vec<String> = Vec::new();
280        for p in &files {
281            names.push(
282                p.strip_prefix(&change_dir)
283                    .unwrap()
284                    .to_string_lossy()
285                    .to_string(),
286            );
287        }
288        assert!(names.contains(&"proposal.md".to_string()));
289        assert!(names.contains(&"tasks.md".to_string()));
290        assert!(names.contains(&"specs/auth/spec.md".to_string()));
291    }
292
293    #[test]
294    fn search_files_finds_matching_lines() {
295        let tmp = TempDir::new().unwrap();
296        let ito = setup_test_dir(&tmp);
297        let change_dir = ito.join("changes/001-01_test-change");
298
299        let files = collect_change_artifact_files(&change_dir);
300        let output = search_files(&files, "Requirement:", 0).unwrap();
301
302        assert_eq!(output.matches.len(), 1);
303        assert!(output.matches[0].line.contains("Requirement: Login"));
304        assert!(!output.truncated);
305    }
306
307    #[test]
308    fn search_files_respects_limit() {
309        let tmp = TempDir::new().unwrap();
310        let ito = setup_test_dir(&tmp);
311        let change_dir = ito.join("changes/001-01_test-change");
312
313        let files = collect_change_artifact_files(&change_dir);
314        // Search for something that matches many lines
315        let output = search_files(&files, ".", 2).unwrap();
316
317        assert_eq!(output.matches.len(), 2);
318        assert!(output.truncated);
319    }
320
321    #[test]
322    fn search_files_returns_empty_for_no_matches() {
323        let tmp = TempDir::new().unwrap();
324        let ito = setup_test_dir(&tmp);
325        let change_dir = ito.join("changes/001-01_test-change");
326
327        let files = collect_change_artifact_files(&change_dir);
328        let output = search_files(&files, "ZZZZZZZ_NOMATCH", 0).unwrap();
329
330        assert!(output.matches.is_empty());
331        assert!(!output.truncated);
332    }
333
334    #[test]
335    fn search_files_rejects_invalid_regex() {
336        let files = vec![PathBuf::from("/nonexistent")];
337        let result = search_files(&files, "[invalid", 0);
338        assert!(result.is_err());
339        let err = result.unwrap_err().to_string();
340        assert!(err.contains("invalid grep pattern"));
341    }
342
343    #[test]
344    fn search_files_includes_correct_line_numbers() {
345        let tmp = TempDir::new().unwrap();
346        let ito = setup_test_dir(&tmp);
347
348        let files = vec![ito.join("changes/001-01_test-change/tasks.md")];
349        let output = search_files(&files, r"Add", 0).unwrap();
350
351        assert_eq!(output.matches.len(), 2);
352        // "Add login endpoint" should be line 2, "Add tests" should be line 3
353        assert_eq!(output.matches[0].line_number, 2);
354        assert_eq!(output.matches[1].line_number, 3);
355    }
356}