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 ito_domain::backend::BackendChangeReader;
8use ito_domain::changes::{
9    Change, ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
10    ResolveTargetOptions,
11};
12use ito_domain::errors::DomainResult;
13
14/// Backend-backed change repository.
15///
16/// Wraps a [`BackendChangeReader`] implementation and delegates all read
17/// operations to the backend. Change target resolution is performed against
18/// the backend-supplied change list.
19pub struct BackendChangeRepository<R: BackendChangeReader> {
20    reader: R,
21}
22
23impl<R: BackendChangeReader> BackendChangeRepository<R> {
24    /// Create a backend-backed change repository.
25    pub fn new(reader: R) -> Self {
26        Self { reader }
27    }
28}
29
30impl<R: BackendChangeReader> DomainChangeRepository for BackendChangeRepository<R> {
31    fn resolve_target_with_options(
32        &self,
33        input: &str,
34        _options: ResolveTargetOptions,
35    ) -> ChangeTargetResolution {
36        let Ok(summaries) = self.reader.list_changes() else {
37            return ChangeTargetResolution::NotFound;
38        };
39
40        let input = input.trim();
41        let mut exact = Vec::new();
42        let mut prefix = Vec::new();
43
44        for summary in &summaries {
45            if summary.id == input {
46                exact.push(summary.id.clone());
47            } else if summary.id.starts_with(input) {
48                prefix.push(summary.id.clone());
49            }
50        }
51
52        if exact.len() == 1 {
53            return ChangeTargetResolution::Unique(exact.into_iter().next().unwrap());
54        }
55        if exact.is_empty() && prefix.len() == 1 {
56            return ChangeTargetResolution::Unique(prefix.into_iter().next().unwrap());
57        }
58        if !exact.is_empty() || !prefix.is_empty() {
59            let mut all = exact;
60            all.extend(prefix);
61            return ChangeTargetResolution::Ambiguous(all);
62        }
63
64        ChangeTargetResolution::NotFound
65    }
66
67    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
68        let Ok(summaries) = self.reader.list_changes() else {
69            return Vec::new();
70        };
71
72        let ids: Vec<String> = summaries.iter().map(|s| s.id.clone()).collect();
73        ito_common::match_::nearest_matches(input, &ids, max)
74    }
75
76    fn exists(&self, id: &str) -> bool {
77        self.reader.get_change(id).is_ok()
78    }
79
80    fn get(&self, id: &str) -> DomainResult<Change> {
81        self.reader.get_change(id)
82    }
83
84    fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
85        self.reader.list_changes()
86    }
87
88    fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
89        let all = self.reader.list_changes()?;
90        let filtered = all
91            .into_iter()
92            .filter(|s| s.module_id.as_deref() == Some(module_id))
93            .collect();
94        Ok(filtered)
95    }
96
97    fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
98        let all = self.reader.list_changes()?;
99        let filtered = all
100            .into_iter()
101            .filter(|s| s.completed_tasks < s.total_tasks || s.total_tasks == 0)
102            .collect();
103        Ok(filtered)
104    }
105
106    fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
107        let all = self.reader.list_changes()?;
108        let filtered = all
109            .into_iter()
110            .filter(|s| s.total_tasks > 0 && s.completed_tasks == s.total_tasks)
111            .collect();
112        Ok(filtered)
113    }
114
115    fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
116        let all = self.reader.list_changes()?;
117        for summary in all {
118            if summary.id == id {
119                return Ok(summary);
120            }
121        }
122        Err(ito_domain::errors::DomainError::not_found("change", id))
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use chrono::Utc;
130    use ito_domain::tasks::TasksParseResult;
131
132    /// In-memory backend reader for testing.
133    struct FakeReader {
134        changes: Vec<ChangeSummary>,
135        full: Vec<Change>,
136    }
137
138    impl FakeReader {
139        fn new(changes: Vec<ChangeSummary>, full: Vec<Change>) -> Self {
140            Self { changes, full }
141        }
142    }
143
144    impl BackendChangeReader for FakeReader {
145        fn list_changes(&self) -> DomainResult<Vec<ChangeSummary>> {
146            Ok(self.changes.clone())
147        }
148
149        fn get_change(&self, change_id: &str) -> DomainResult<Change> {
150            for c in &self.full {
151                if c.id == change_id {
152                    return Ok(c.clone());
153                }
154            }
155            Err(ito_domain::errors::DomainError::not_found(
156                "change", change_id,
157            ))
158        }
159    }
160
161    fn make_summary(id: &str, completed: u32, total: u32) -> ChangeSummary {
162        ChangeSummary {
163            id: id.to_string(),
164            module_id: None,
165            completed_tasks: completed,
166            shelved_tasks: 0,
167            in_progress_tasks: 0,
168            pending_tasks: total - completed,
169            total_tasks: total,
170            last_modified: Utc::now(),
171            has_proposal: true,
172            has_design: false,
173            has_specs: true,
174            has_tasks: true,
175        }
176    }
177
178    fn make_change(id: &str) -> Change {
179        Change {
180            id: id.to_string(),
181            module_id: None,
182            path: std::path::PathBuf::from("/fake"),
183            proposal: Some("# Proposal".to_string()),
184            design: None,
185            specs: vec![],
186            tasks: TasksParseResult::empty(),
187            last_modified: Utc::now(),
188        }
189    }
190
191    #[test]
192    fn list_returns_all_changes() {
193        let summaries = vec![
194            make_summary("001-01_a", 0, 2),
195            make_summary("001-02_b", 1, 2),
196        ];
197        let reader = FakeReader::new(summaries.clone(), vec![]);
198        let repo = BackendChangeRepository::new(reader);
199
200        let result = repo.list().unwrap();
201        assert_eq!(result.len(), 2);
202    }
203
204    #[test]
205    fn resolve_target_exact_match() {
206        let summaries = vec![make_summary("001-01_a", 0, 2)];
207        let reader = FakeReader::new(summaries, vec![]);
208        let repo = BackendChangeRepository::new(reader);
209
210        let resolution = DomainChangeRepository::resolve_target(&repo, "001-01_a");
211        assert!(matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_a"));
212    }
213
214    #[test]
215    fn resolve_target_prefix_match() {
216        let summaries = vec![make_summary("001-01_something", 0, 2)];
217        let reader = FakeReader::new(summaries, vec![]);
218        let repo = BackendChangeRepository::new(reader);
219
220        let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
221        assert!(
222            matches!(resolution, ChangeTargetResolution::Unique(id) if id == "001-01_something")
223        );
224    }
225
226    #[test]
227    fn resolve_target_ambiguous() {
228        let summaries = vec![
229            make_summary("001-01_a", 0, 2),
230            make_summary("001-01_b", 0, 2),
231        ];
232        let reader = FakeReader::new(summaries, vec![]);
233        let repo = BackendChangeRepository::new(reader);
234
235        let resolution = DomainChangeRepository::resolve_target(&repo, "001-01");
236        assert!(matches!(resolution, ChangeTargetResolution::Ambiguous(_)));
237    }
238
239    #[test]
240    fn resolve_target_not_found() {
241        let summaries = vec![make_summary("001-01_a", 0, 2)];
242        let reader = FakeReader::new(summaries, vec![]);
243        let repo = BackendChangeRepository::new(reader);
244
245        let resolution = DomainChangeRepository::resolve_target(&repo, "999");
246        assert!(matches!(resolution, ChangeTargetResolution::NotFound));
247    }
248
249    #[test]
250    fn get_delegates_to_reader() {
251        let change = make_change("001-01_a");
252        let reader = FakeReader::new(vec![], vec![change]);
253        let repo = BackendChangeRepository::new(reader);
254
255        let result = DomainChangeRepository::get(&repo, "001-01_a").unwrap();
256        assert_eq!(result.id, "001-01_a");
257    }
258
259    #[test]
260    fn list_incomplete_filters_correctly() {
261        let summaries = vec![
262            make_summary("001-01_a", 2, 2),
263            make_summary("001-02_b", 1, 2),
264        ];
265        let reader = FakeReader::new(summaries, vec![]);
266        let repo = BackendChangeRepository::new(reader);
267
268        let incomplete = DomainChangeRepository::list_incomplete(&repo).unwrap();
269        assert_eq!(incomplete.len(), 1);
270        assert_eq!(incomplete[0].id, "001-02_b");
271    }
272
273    #[test]
274    fn list_complete_filters_correctly() {
275        let summaries = vec![
276            make_summary("001-01_a", 2, 2),
277            make_summary("001-02_b", 1, 2),
278        ];
279        let reader = FakeReader::new(summaries, vec![]);
280        let repo = BackendChangeRepository::new(reader);
281
282        let complete = DomainChangeRepository::list_complete(&repo).unwrap();
283        assert_eq!(complete.len(), 1);
284        assert_eq!(complete[0].id, "001-01_a");
285    }
286}