1use ito_domain::backend::BackendChangeReader;
8use ito_domain::changes::{
9 Change, ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
10 ResolveTargetOptions,
11};
12use ito_domain::errors::DomainResult;
13
14pub struct BackendChangeRepository<R: BackendChangeReader> {
20 reader: R,
21}
22
23impl<R: BackendChangeReader> BackendChangeRepository<R> {
24 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 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}