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)]
236#[path = "grep_tests.rs"]
237mod grep_tests;