1use std::collections::BTreeSet;
8
9use ito_domain::backend::BackendChangeReader;
10use ito_domain::changes::{
11 Change, ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeSummary,
12 ChangeTargetResolution, ResolveTargetOptions, parse_change_id, parse_module_id,
13};
14use ito_domain::errors::DomainResult;
15use regex::Regex;
16
17pub struct BackendChangeRepository<R: BackendChangeReader> {
23 reader: R,
24}
25
26impl<R: BackendChangeReader> BackendChangeRepository<R> {
27 pub fn new(reader: R) -> Self {
29 Self { reader }
30 }
31}
32
33impl<R: BackendChangeReader> BackendChangeRepository<R> {
34 fn split_canonical_change_id<'b>(&self, name: &'b str) -> Option<(String, String, &'b str)> {
35 let (module_id, change_num) = parse_change_id(name)?;
36 let slug = name.split_once('_').map(|(_id, s)| s).unwrap_or("");
37 Some((module_id, change_num, slug))
38 }
39
40 fn tokenize_query(&self, input: &str) -> Vec<String> {
41 let mut out = Vec::new();
42 for part in input.split_whitespace() {
43 let trimmed = part.trim();
44 if trimmed.is_empty() {
45 continue;
46 }
47 out.push(trimmed.to_lowercase());
48 }
49 out
50 }
51
52 fn normalized_slug_text(&self, slug: &str) -> String {
53 let mut out = String::new();
54 for ch in slug.chars() {
55 if ch.is_ascii_alphanumeric() {
56 out.push(ch.to_ascii_lowercase());
57 } else {
58 out.push(' ');
59 }
60 }
61 out
62 }
63
64 fn slug_matches_tokens(&self, slug: &str, tokens: &[String]) -> bool {
65 if tokens.is_empty() {
66 return false;
67 }
68 let text = self.normalized_slug_text(slug);
69 for token in tokens {
70 if !text.contains(token) {
71 return false;
72 }
73 }
74 true
75 }
76
77 fn is_numeric_module_selector(&self, input: &str) -> bool {
78 let trimmed = input.trim();
79 !trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_digit())
80 }
81
82 fn extract_two_numbers_as_change_id(&self, input: &str) -> Option<(String, String)> {
83 let re = Regex::new(r"\d+").ok()?;
84 let mut parts: Vec<&str> = Vec::new();
85 for m in re.find_iter(input) {
86 parts.push(m.as_str());
87 if parts.len() > 2 {
88 return None;
89 }
90 }
91 if parts.len() != 2 {
92 return None;
93 }
94 let parsed = format!("{}-{}", parts[0], parts[1]);
95 parse_change_id(&parsed)
96 }
97}
98
99impl<R: BackendChangeReader> DomainChangeRepository for BackendChangeRepository<R> {
100 fn resolve_target_with_options(
101 &self,
102 input: &str,
103 options: ResolveTargetOptions,
104 ) -> ChangeTargetResolution {
105 let Ok(summaries) = self.reader.list_changes(options.lifecycle) else {
106 return ChangeTargetResolution::NotFound;
107 };
108
109 let mut names: Vec<String> = summaries.iter().map(|s| s.id.clone()).collect();
110 names.sort();
111 names.dedup();
112
113 if names.is_empty() {
114 return ChangeTargetResolution::NotFound;
115 }
116
117 let input = input.trim();
118 if input.is_empty() {
119 return ChangeTargetResolution::NotFound;
120 }
121
122 if names.iter().any(|name| name == input) {
123 return ChangeTargetResolution::Unique(input.to_string());
124 }
125
126 let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
127 let numeric_selector =
128 parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
129 if let Some((module_id, change_num)) = numeric_selector {
130 let numeric_prefix = format!("{module_id}-{change_num}");
131 let with_separator = format!("{numeric_prefix}_");
132 for name in &names {
133 if name == &numeric_prefix || name.starts_with(&with_separator) {
134 numeric_matches.insert(name.clone());
135 }
136 }
137 if !numeric_matches.is_empty() {
138 let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
139 if numeric_matches.len() == 1 {
140 return ChangeTargetResolution::Unique(numeric_matches[0].clone());
141 }
142 return ChangeTargetResolution::Ambiguous(numeric_matches);
143 }
144 }
145
146 if let Some((module, query)) = input.split_once(':') {
147 let query = query.trim();
148 if !query.is_empty() {
149 let module_id = parse_module_id(module);
150 let tokens = self.tokenize_query(query);
151 let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
152 for name in &names {
153 let Some((name_module, _name_change, slug)) =
154 self.split_canonical_change_id(name)
155 else {
156 continue;
157 };
158 if name_module != module_id {
159 continue;
160 }
161 if self.slug_matches_tokens(slug, &tokens) {
162 scoped_matches.insert(name.clone());
163 }
164 }
165 if scoped_matches.is_empty() {
166 return ChangeTargetResolution::NotFound;
167 }
168 let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
169 if scoped_matches.len() == 1 {
170 return ChangeTargetResolution::Unique(scoped_matches[0].clone());
171 }
172 return ChangeTargetResolution::Ambiguous(scoped_matches);
173 }
174 }
175
176 if self.is_numeric_module_selector(input) {
177 let module_id = parse_module_id(input);
178 let mut module_matches: BTreeSet<String> = BTreeSet::new();
179 for name in &names {
180 let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
181 else {
182 continue;
183 };
184 if name_module == module_id {
185 module_matches.insert(name.clone());
186 }
187 }
188
189 if !module_matches.is_empty() {
190 let module_matches: Vec<String> = module_matches.into_iter().collect();
191 if module_matches.len() == 1 {
192 return ChangeTargetResolution::Unique(module_matches[0].clone());
193 }
194 return ChangeTargetResolution::Ambiguous(module_matches);
195 }
196 }
197
198 let mut matches: BTreeSet<String> = BTreeSet::new();
199 for name in &names {
200 if name.starts_with(input) {
201 matches.insert(name.clone());
202 }
203 }
204
205 if matches.is_empty() {
206 let tokens = self.tokenize_query(input);
207 for name in &names {
208 let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
209 continue;
210 };
211 if self.slug_matches_tokens(slug, &tokens) {
212 matches.insert(name.clone());
213 }
214 }
215 }
216
217 if matches.is_empty() {
218 return ChangeTargetResolution::NotFound;
219 }
220
221 let matches: Vec<String> = matches.into_iter().collect();
222 if matches.len() == 1 {
223 return ChangeTargetResolution::Unique(matches[0].clone());
224 }
225
226 ChangeTargetResolution::Ambiguous(matches)
227 }
228
229 fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
230 let Ok(summaries) = self.reader.list_changes(ChangeLifecycleFilter::Active) else {
231 return Vec::new();
232 };
233
234 let ids: Vec<String> = summaries.iter().map(|s| s.id.clone()).collect();
235 ito_common::match_::nearest_matches(input, &ids, max)
236 }
237
238 fn exists(&self, id: &str) -> bool {
239 self.exists_with_filter(id, ChangeLifecycleFilter::Active)
240 }
241
242 fn exists_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> bool {
243 self.reader.get_change(id, filter).is_ok()
244 }
245
246 fn get_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
247 self.reader.get_change(id, filter)
248 }
249
250 fn list_with_filter(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
251 self.reader.list_changes(filter)
252 }
253
254 fn list_by_module_with_filter(
255 &self,
256 module_id: &str,
257 filter: ChangeLifecycleFilter,
258 ) -> DomainResult<Vec<ChangeSummary>> {
259 let normalized_id = parse_module_id(module_id);
260 let all = self.reader.list_changes(filter)?;
261 let mut filtered = Vec::new();
262 for s in all {
263 if s.module_id.as_deref() == Some(&normalized_id) {
264 filtered.push(s);
265 }
266 }
267 Ok(filtered)
268 }
269
270 fn list_incomplete_with_filter(
271 &self,
272 filter: ChangeLifecycleFilter,
273 ) -> DomainResult<Vec<ChangeSummary>> {
274 let all = self.reader.list_changes(filter)?;
275 let filtered = all
276 .into_iter()
277 .filter(|s| s.completed_tasks < s.total_tasks || s.total_tasks == 0)
278 .collect();
279 Ok(filtered)
280 }
281
282 fn list_complete_with_filter(
283 &self,
284 filter: ChangeLifecycleFilter,
285 ) -> DomainResult<Vec<ChangeSummary>> {
286 let all = self.reader.list_changes(filter)?;
287 let filtered = all
288 .into_iter()
289 .filter(|s| s.total_tasks > 0 && s.completed_tasks == s.total_tasks)
290 .collect();
291 Ok(filtered)
292 }
293
294 fn get_summary_with_filter(
295 &self,
296 id: &str,
297 filter: ChangeLifecycleFilter,
298 ) -> DomainResult<ChangeSummary> {
299 let all = self.reader.list_changes(filter)?;
300 for summary in all {
301 if summary.id == id {
302 return Ok(summary);
303 }
304 }
305 Err(ito_domain::errors::DomainError::not_found("change", id))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use chrono::Utc;
313 use ito_domain::tasks::TasksParseResult;
314
315 struct FakeReader {
317 changes: Vec<ChangeSummary>,
318 full: Vec<Change>,
319 }
320
321 impl FakeReader {
322 fn new(changes: Vec<ChangeSummary>, full: Vec<Change>) -> Self {
323 Self { changes, full }
324 }
325 }
326
327 impl BackendChangeReader for FakeReader {
328 fn list_changes(
329 &self,
330 _filter: ito_domain::changes::ChangeLifecycleFilter,
331 ) -> DomainResult<Vec<ChangeSummary>> {
332 Ok(self.changes.clone())
333 }
334
335 fn get_change(
336 &self,
337 change_id: &str,
338 _filter: ito_domain::changes::ChangeLifecycleFilter,
339 ) -> DomainResult<Change> {
340 for c in &self.full {
341 if c.id == change_id {
342 return Ok(c.clone());
343 }
344 }
345 Err(ito_domain::errors::DomainError::not_found(
346 "change", change_id,
347 ))
348 }
349 }
350
351 fn make_summary(id: &str, completed: u32, total: u32) -> ChangeSummary {
352 ChangeSummary {
353 id: id.to_string(),
354 module_id: None,
355 completed_tasks: completed,
356 shelved_tasks: 0,
357 in_progress_tasks: 0,
358 pending_tasks: total - completed,
359 total_tasks: total,
360 last_modified: Utc::now(),
361 has_proposal: true,
362 has_design: false,
363 has_specs: true,
364 has_tasks: true,
365 }
366 }
367
368 fn make_change(id: &str) -> Change {
369 Change {
370 id: id.to_string(),
371 module_id: None,
372 path: std::path::PathBuf::from("/fake"),
373 proposal: Some("# Proposal".to_string()),
374 design: None,
375 specs: vec![],
376 tasks: TasksParseResult::empty(),
377 last_modified: Utc::now(),
378 }
379 }
380
381 #[test]
382 fn list_returns_all_changes() {
383 let summaries = vec![
384 make_summary("001-01_a", 0, 2),
385 make_summary("001-02_b", 1, 2),
386 ];
387 let reader = FakeReader::new(summaries.clone(), vec![]);
388 let repo = BackendChangeRepository::new(reader);
389
390 let result = repo.list().unwrap();
391 assert_eq!(result.len(), 2);
392 }
393
394 #[test]
395 fn resolve_target_exact_match() {
396 let summaries = vec![make_summary("001-01_a", 0, 2)];
397 let reader = FakeReader::new(summaries, vec![]);
398 let repo = BackendChangeRepository::new(reader);
399
400 let resolution = DomainChangeRepository::resolve_target(&repo, "001-01_a");
401 assert!(matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_a"));
402 }
403
404 #[test]
405 fn resolve_target_prefix_match() {
406 let summaries = vec![make_summary("001-01_something", 0, 2)];
407 let reader = FakeReader::new(summaries, vec![]);
408 let repo = BackendChangeRepository::new(reader);
409
410 let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
411 assert!(
412 matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_something")
413 );
414 }
415
416 #[test]
417 fn resolve_target_ambiguous() {
418 let summaries = vec![
419 make_summary("001-01_a", 0, 2),
420 make_summary("001-01_b", 0, 2),
421 ];
422 let reader = FakeReader::new(summaries, vec![]);
423 let repo = BackendChangeRepository::new(reader);
424
425 let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
426 assert!(matches!(resolution, ChangeTargetResolution::Ambiguous(_)));
427 }
428
429 #[test]
430 fn resolve_target_not_found() {
431 let summaries = vec![make_summary("001-01_a", 0, 2)];
432 let reader = FakeReader::new(summaries, vec![]);
433 let repo = BackendChangeRepository::new(reader);
434
435 let resolution = DomainChangeRepository::resolve_target(&repo, "999");
436 assert!(matches!(resolution, ChangeTargetResolution::NotFound));
437 }
438
439 #[test]
440 fn get_delegates_to_reader() {
441 let change = make_change("001-01_a");
442 let reader = FakeReader::new(vec![], vec![change]);
443 let repo = BackendChangeRepository::new(reader);
444
445 let result = DomainChangeRepository::get(&repo, "001-01_a").unwrap();
446 assert_eq!(result.id, "001-01_a");
447 }
448
449 #[test]
450 fn list_incomplete_filters_correctly() {
451 let summaries = vec![
452 make_summary("001-01_a", 2, 2),
453 make_summary("001-02_b", 1, 2),
454 ];
455 let reader = FakeReader::new(summaries, vec![]);
456 let repo = BackendChangeRepository::new(reader);
457
458 let incomplete = DomainChangeRepository::list_incomplete(&repo).unwrap();
459 assert_eq!(incomplete.len(), 1);
460 assert_eq!(incomplete[0].id, "001-02_b");
461 }
462
463 #[test]
464 fn list_complete_filters_correctly() {
465 let summaries = vec![
466 make_summary("001-01_a", 2, 2),
467 make_summary("001-02_b", 1, 2),
468 ];
469 let reader = FakeReader::new(summaries, vec![]);
470 let repo = BackendChangeRepository::new(reader);
471
472 let complete = DomainChangeRepository::list_complete(&repo).unwrap();
473 assert_eq!(complete.len(), 1);
474 assert_eq!(complete[0].id, "001-01_a");
475 }
476}