1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GrepMatch {
20 pub path: PathBuf,
22 pub line_number: u64,
24 pub line: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum GrepScope {
31 Change(String),
33 Module(String),
35 All,
37}
38
39#[derive(Debug, Clone)]
41pub struct GrepInput {
42 pub pattern: String,
44 pub scope: GrepScope,
46 pub limit: usize,
48}
49
50#[derive(Debug, Clone)]
52pub struct GrepOutput {
53 pub matches: Vec<GrepMatch>,
55 pub truncated: bool,
57}
58
59pub 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 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
118pub 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
154pub 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
176fn 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 let module = module_repo
207 .get(module_id)
208 .map_err(|e| CoreError::not_found(format!("module '{module_id}': {e}")))?;
209
210 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
222fn 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); 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 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 assert_eq!(output.matches[0].line_number, 2);
346 assert_eq!(output.matches[1].line_number, 3);
347 }
348}