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<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
180fn 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 let module = module_repo
215 .get(module_id)
216 .map_err(|e| CoreError::not_found(format!("module '{module_id}': {e}")))?;
217
218 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
230fn 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); 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 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 assert_eq!(output.matches[0].line_number, 2);
354 assert_eq!(output.matches[1].line_number, 3);
355 }
356}