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