Skip to main content

ito_core/
backend_change_repository.rs

1//! Backend-backed change repository adapter.
2//!
3//! Delegates change reads to a [`BackendChangeReader`] when backend mode is
4//! enabled. The filesystem repository remains the fallback when backend mode
5//! is disabled.
6
7use 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
17/// Backend-backed change repository.
18///
19/// Wraps a [`BackendChangeReader`] implementation and delegates all read
20/// operations to the backend. Change target resolution is performed against
21/// the backend-supplied change list.
22pub struct BackendChangeRepository<R: BackendChangeReader> {
23    reader: R,
24}
25
26impl<R: BackendChangeReader> BackendChangeRepository<R> {
27    /// Create a backend-backed change repository.
28    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    /// In-memory backend reader for testing.
316    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}