ito_core/
backend_change_repository.rs1use 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)]
310#[path = "backend_change_repository_tests.rs"]
311mod backend_change_repository_tests;