1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! Grep-style search over Ito change artifacts.
//!
//! Provides a consistent search interface that works whether artifacts live on
//! the local filesystem (`.ito/`) or have been materialised from a remote
//! backend into a local cache. The search engine uses the ripgrep crate
//! ecosystem (`grep-regex`, `grep-searcher`) so callers get familiar regex
//! semantics without shelling out.
use std::path::{Path, PathBuf};
use grep_regex::RegexMatcher;
use grep_searcher::Searcher;
use grep_searcher::sinks::UTF8;
use crate::errors::{CoreError, CoreResult};
/// A single matching line returned by the grep engine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrepMatch {
/// Absolute path of the file that matched.
pub path: PathBuf,
/// 1-based line number within the file.
pub line_number: u64,
/// The full text of the matching line (without trailing newline).
pub line: String,
}
/// Scope of the grep search.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrepScope {
/// Search artifacts belonging to a single change.
Change(String),
/// Search artifacts belonging to all changes in a module.
Module(String),
/// Search artifacts across all changes in the project.
All,
}
/// Input parameters for a grep operation.
#[derive(Debug, Clone)]
pub struct GrepInput {
/// The regex pattern to search for.
pub pattern: String,
/// The scope of the search.
pub scope: GrepScope,
/// Maximum number of matching lines to return (0 = unlimited).
pub limit: usize,
}
/// Result of a grep operation.
#[derive(Debug, Clone)]
pub struct GrepOutput {
/// The matching lines found.
pub matches: Vec<GrepMatch>,
/// Whether the output was truncated due to the limit.
pub truncated: bool,
}
/// Search the given files for lines matching `pattern`, returning at most
/// `limit` results (0 means unlimited).
///
/// This is the core search engine used by all grep scopes. It uses the
/// ripgrep crate ecosystem for fast, correct regex matching.
///
/// # Errors
///
/// Returns `CoreError::Validation` if the pattern is not a valid regex.
/// Files that cannot be read are skipped (logged at debug level) so one
/// unreadable file does not fail the entire search.
pub fn search_files(files: &[PathBuf], pattern: &str, limit: usize) -> CoreResult<GrepOutput> {
let matcher = RegexMatcher::new(pattern)
.map_err(|e| CoreError::validation(format!("invalid grep pattern: {e}")))?;
let mut matches: Vec<GrepMatch> = Vec::new();
let mut truncated = false;
let mut searcher = Searcher::new();
for file_path in files {
if limit > 0 && matches.len() >= limit {
truncated = true;
break;
}
let search_result = searcher.search_path(
&matcher,
file_path,
UTF8(|line_number, line_text| {
if limit > 0 && matches.len() >= limit {
truncated = true;
return Ok(false);
}
matches.push(GrepMatch {
path: file_path.clone(),
line_number,
line: line_text.trim_end_matches(&['\r', '\n'][..]).to_string(),
});
if limit > 0 && matches.len() >= limit {
truncated = true;
Ok(false)
} else {
Ok(true)
}
}),
);
// Skip files that cannot be read (e.g. binary, permission denied)
// rather than failing the entire search.
if let Err(e) = search_result {
tracing::debug!("skipping file {}: {e}", file_path.display());
}
}
Ok(GrepOutput { matches, truncated })
}
/// Collect all artifact markdown files for a single change directory.
///
/// Returns paths to `proposal.md`, `design.md`, `tasks.md`, and any
/// `specs/<name>/spec.md` files found under the change directory.
pub fn collect_change_artifact_files(change_dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let known_files = ["proposal.md", "design.md", "tasks.md"];
for name in &known_files {
let p = change_dir.join(name);
if p.is_file() {
files.push(p);
}
}
let specs_dir = change_dir.join("specs");
if specs_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&specs_dir)
{
let mut spec_dirs: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
spec_dirs.sort_by_key(|e| e.file_name());
for entry in spec_dirs {
let spec_file = entry.path().join("spec.md");
if spec_file.is_file() {
files.push(spec_file);
}
}
}
files
}
/// Resolve the grep scope to a list of artifact files and execute the search.
///
/// # Arguments
///
/// * `ito_path` - Path to the `.ito/` directory.
/// * `input` - The grep parameters (pattern, scope, limit).
/// * `change_repo` - A change repository for resolving targets and listing changes.
/// * `module_repo` - A module repository for resolving module targets.
///
/// # Errors
///
/// Returns errors if the change/module cannot be found or the pattern is invalid.
pub fn grep(
ito_path: &Path,
input: &GrepInput,
change_repo: &dyn ito_domain::changes::ChangeRepository,
module_repo: &dyn ito_domain::modules::ModuleRepository,
) -> CoreResult<GrepOutput> {
let files = resolve_scope_files(ito_path, &input.scope, change_repo, module_repo)?;
search_files(&files, &input.pattern, input.limit)
}
/// Resolve a grep scope into the list of artifact files to search.
fn resolve_scope_files(
ito_path: &Path,
scope: &GrepScope,
change_repo: &dyn ito_domain::changes::ChangeRepository,
module_repo: &dyn ito_domain::modules::ModuleRepository,
) -> CoreResult<Vec<PathBuf>> {
match scope {
GrepScope::Change(change_id) => {
let resolution = change_repo.resolve_target(change_id);
let actual_id = match resolution {
ito_domain::changes::ChangeTargetResolution::Unique(id) => id,
ito_domain::changes::ChangeTargetResolution::Ambiguous(matches) => {
return Err(CoreError::validation(format!(
"ambiguous change target '{change_id}', matches: {}",
matches.join(", ")
)));
}
ito_domain::changes::ChangeTargetResolution::NotFound => {
return Err(CoreError::not_found(format!(
"change '{change_id}' not found"
)));
}
};
let change_dir = ito_common::paths::change_dir(ito_path, &actual_id);
Ok(collect_change_artifact_files(&change_dir))
}
GrepScope::Module(module_id) => {
// Verify the module exists
let module = module_repo
.get(module_id)
.map_err(|e| CoreError::not_found(format!("module '{module_id}': {e}")))?;
// List all changes in the module
let changes = change_repo.list_by_module(&module.id)?;
Ok(collect_files_for_changes(ito_path, &changes))
}
GrepScope::All => {
let changes = change_repo.list()?;
Ok(collect_files_for_changes(ito_path, &changes))
}
}
}
/// Collect all artifact files for a list of changes.
fn collect_files_for_changes(
ito_path: &Path,
changes: &[ito_domain::changes::ChangeSummary],
) -> Vec<PathBuf> {
let mut files = Vec::new();
for change in changes {
let change_dir = ito_common::paths::change_dir(ito_path, &change.id);
files.extend(collect_change_artifact_files(&change_dir));
}
files
}
#[cfg(test)]
#[path = "grep_tests.rs"]
mod grep_tests;