1use chrono::{DateTime, TimeZone, Utc};
4use ito_common::fs::{FileSystem, StdFs};
5use ito_common::match_::nearest_matches;
6use ito_common::paths;
7use ito_domain::changes::{
8 Change, ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeStatus,
9 ChangeSummary, ChangeTargetResolution, ResolveTargetOptions, Spec, extract_module_id,
10 extract_sub_module_id, parse_change_id, parse_module_id,
11};
12use ito_domain::discovery;
13use ito_domain::errors::{DomainError, DomainResult};
14use ito_domain::tasks::TaskRepository as DomainTaskRepository;
15use regex::Regex;
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Path, PathBuf};
18
19use crate::front_matter;
20use crate::task_repository::FsTaskRepository;
21
22pub struct FsChangeRepository<'a, F: FileSystem = StdFs> {
24 ito_path: &'a Path,
25 task_repo: FsTaskRepository<'a>,
26 fs: F,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ChangeLifecycle {
31 Active,
32 Archived,
33}
34
35#[derive(Debug, Clone)]
36struct ChangeLocation {
37 id: String,
38 path: PathBuf,
39 lifecycle: ChangeLifecycle,
40}
41
42impl<'a> FsChangeRepository<'a, StdFs> {
43 pub fn new(ito_path: &'a Path) -> Self {
45 Self::with_fs(ito_path, StdFs)
46 }
47}
48
49impl<'a, F: FileSystem> FsChangeRepository<'a, F> {
50 pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
52 Self::with_task_repo(ito_path, FsTaskRepository::new(ito_path), fs)
53 }
54
55 pub fn with_task_repo(ito_path: &'a Path, task_repo: FsTaskRepository<'a>, fs: F) -> Self {
60 Self {
61 ito_path,
62 task_repo,
63 fs,
64 }
65 }
66
67 pub fn resolve_target(&self, input: &str) -> ChangeTargetResolution {
69 DomainChangeRepository::resolve_target(self, input)
70 }
71
72 pub fn resolve_target_with_options(
74 &self,
75 input: &str,
76 options: ResolveTargetOptions,
77 ) -> ChangeTargetResolution {
78 DomainChangeRepository::resolve_target_with_options(self, input, options)
79 }
80
81 pub fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
83 DomainChangeRepository::suggest_targets(self, input, max)
84 }
85
86 pub fn exists(&self, id: &str) -> bool {
88 DomainChangeRepository::exists(self, id)
89 }
90
91 pub fn get(&self, id: &str) -> DomainResult<Change> {
93 DomainChangeRepository::get(self, id)
94 }
95
96 pub fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
98 DomainChangeRepository::list(self)
99 }
100
101 pub fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
103 DomainChangeRepository::list_by_module(self, module_id)
104 }
105
106 pub fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
108 DomainChangeRepository::list_incomplete(self)
109 }
110
111 pub fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
113 DomainChangeRepository::list_complete(self)
114 }
115
116 pub fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
118 DomainChangeRepository::get_summary(self, id)
119 }
120
121 fn changes_dir(&self) -> std::path::PathBuf {
122 paths::changes_dir(self.ito_path)
123 }
124
125 fn list_change_locations(&self, filter: ChangeLifecycleFilter) -> Vec<ChangeLocation> {
126 let mut active = Vec::new();
127 if filter.includes_active() {
128 active = self.list_active_locations();
129 }
130
131 let mut archived = Vec::new();
132 if filter.includes_archived() {
133 archived = self.list_archived_locations();
134 }
135
136 match filter {
137 ChangeLifecycleFilter::Active => active,
138 ChangeLifecycleFilter::Archived => archived,
139 ChangeLifecycleFilter::All => {
140 let mut merged: BTreeMap<String, ChangeLocation> = BTreeMap::new();
141 for loc in active {
142 merged.insert(loc.id.clone(), loc);
143 }
144 for loc in archived {
145 merged.entry(loc.id.clone()).or_insert(loc);
146 }
147 merged.into_values().collect()
148 }
149 }
150 }
151
152 fn list_active_locations(&self) -> Vec<ChangeLocation> {
153 let mut out = Vec::new();
154 for name in discovery::list_change_dir_names(&self.fs, self.ito_path).unwrap_or_default() {
155 let path = self.changes_dir().join(&name);
156 out.push(ChangeLocation {
157 id: name,
158 path,
159 lifecycle: ChangeLifecycle::Active,
160 });
161 }
162 out.sort_by(|a, b| a.id.cmp(&b.id));
163 out
164 }
165
166 fn list_archived_locations(&self) -> Vec<ChangeLocation> {
167 let archive_dir = self.changes_dir().join("archive");
168 let mut by_id: BTreeMap<String, String> = BTreeMap::new();
169 let archived = discovery::list_dir_names(&self.fs, &archive_dir).unwrap_or_default();
170
171 for name in archived {
172 let Some(change_id) = self.parse_archived_change_id(&name) else {
173 continue;
174 };
175 let entry = by_id.entry(change_id).or_insert(name.clone());
176 if name > *entry {
177 *entry = name;
178 }
179 }
180
181 let mut out = Vec::new();
182 for (id, dir_name) in by_id {
183 out.push(ChangeLocation {
184 id,
185 path: archive_dir.join(dir_name),
186 lifecycle: ChangeLifecycle::Archived,
187 });
188 }
189 out
190 }
191
192 fn parse_archived_change_id(&self, name: &str) -> Option<String> {
193 if let Some(remainder) = self.strip_archive_date_prefix(name)
194 && parse_change_id(remainder).is_some()
195 {
196 return Some(remainder.to_string());
197 }
198
199 if parse_change_id(name).is_some() {
200 return Some(name.to_string());
201 }
202
203 None
204 }
205
206 fn strip_archive_date_prefix<'b>(&self, name: &'b str) -> Option<&'b str> {
207 let mut parts = name.splitn(4, '-');
208 let year = parts.next()?;
209 let month = parts.next()?;
210 let day = parts.next()?;
211 let remainder = parts.next()?;
212
213 if year.len() != 4 || month.len() != 2 || day.len() != 2 {
214 return None;
215 }
216 if !year.chars().all(|c| c.is_ascii_digit())
217 || !month.chars().all(|c| c.is_ascii_digit())
218 || !day.chars().all(|c| c.is_ascii_digit())
219 {
220 return None;
221 }
222 if remainder.trim().is_empty() {
223 return None;
224 }
225 Some(remainder)
226 }
227
228 fn list_change_ids(&self, filter: ChangeLifecycleFilter) -> Vec<String> {
229 let mut out: Vec<String> = self
230 .list_change_locations(filter)
231 .into_iter()
232 .map(|loc| loc.id)
233 .collect();
234 out.sort();
235 out.dedup();
236 out
237 }
238
239 fn find_change_location(
240 &self,
241 id: &str,
242 filter: ChangeLifecycleFilter,
243 ) -> Option<ChangeLocation> {
244 self.list_change_locations(filter)
245 .into_iter()
246 .find(|loc| loc.id == id)
247 }
248
249 fn split_canonical_change_id<'b>(&self, name: &'b str) -> Option<(String, String, &'b str)> {
250 let (module_id, change_num) = parse_change_id(name)?;
251 let slug = name.split_once('_').map(|(_id, s)| s).unwrap_or("");
252 Some((module_id, change_num, slug))
253 }
254
255 fn tokenize_query(&self, input: &str) -> Vec<String> {
256 input
257 .split_whitespace()
258 .map(|s| s.trim().to_lowercase())
259 .filter(|s| !s.is_empty())
260 .collect()
261 }
262
263 fn normalized_slug_text(&self, slug: &str) -> String {
264 let mut out = String::new();
265 for ch in slug.chars() {
266 if ch.is_ascii_alphanumeric() {
267 out.push(ch.to_ascii_lowercase());
268 } else {
269 out.push(' ');
270 }
271 }
272 out
273 }
274
275 fn slug_matches_tokens(&self, slug: &str, tokens: &[String]) -> bool {
276 if tokens.is_empty() {
277 return false;
278 }
279 let text = self.normalized_slug_text(slug);
280 for token in tokens {
281 if !text.contains(token) {
282 return false;
283 }
284 }
285 true
286 }
287
288 fn is_numeric_module_selector(&self, input: &str) -> bool {
289 let trimmed = input.trim();
290 !trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_digit())
291 }
292
293 fn extract_two_numbers_as_change_id(&self, input: &str) -> Option<(String, String)> {
294 let re = Regex::new(r"\d+").ok()?;
295 let mut parts: Vec<&str> = Vec::new();
296 for m in re.find_iter(input) {
297 parts.push(m.as_str());
298 if parts.len() > 2 {
299 return None;
300 }
301 }
302 if parts.len() != 2 {
303 return None;
304 }
305 let parsed = format!("{}-{}", parts[0], parts[1]);
306 parse_change_id(&parsed)
307 }
308
309 fn resolve_unique_change_id(
310 &self,
311 input: &str,
312 filter: ChangeLifecycleFilter,
313 ) -> DomainResult<String> {
314 match self.resolve_target_with_options(input, ResolveTargetOptions { lifecycle: filter }) {
315 ChangeTargetResolution::Unique(id) => Ok(id),
316 ChangeTargetResolution::Ambiguous(matches) => {
317 Err(DomainError::ambiguous_target("change", input, &matches))
318 }
319 ChangeTargetResolution::NotFound => Err(DomainError::not_found("change", input)),
320 }
321 }
322
323 fn resolve_unique_change_location(
324 &self,
325 input: &str,
326 filter: ChangeLifecycleFilter,
327 ) -> DomainResult<ChangeLocation> {
328 let id = self.resolve_unique_change_id(input, filter)?;
329 self.find_change_location(&id, filter)
330 .ok_or_else(|| DomainError::not_found("change", input))
331 }
332
333 fn read_optional_file(&self, path: &Path) -> DomainResult<Option<String>> {
334 if self.fs.is_file(path) {
335 let content = self
336 .fs
337 .read_to_string(path)
338 .map_err(|source| DomainError::io("reading optional file", source))?;
339 Ok(Some(content))
340 } else {
341 Ok(None)
342 }
343 }
344
345 fn load_specs(&self, change_path: &Path) -> DomainResult<Vec<Spec>> {
346 let specs_dir = change_path.join("specs");
347 if !self.fs.is_dir(&specs_dir) {
348 return Ok(Vec::new());
349 }
350
351 let mut specs = Vec::new();
352 for name in discovery::list_dir_names(&self.fs, &specs_dir)? {
353 let spec_file = specs_dir.join(&name).join("spec.md");
354 if self.fs.is_file(&spec_file) {
355 let content = self
356 .fs
357 .read_to_string(&spec_file)
358 .map_err(|source| DomainError::io("reading spec file", source))?;
359 specs.push(Spec { name, content });
360 }
361 }
362
363 specs.sort_by(|a, b| a.name.cmp(&b.name));
364 Ok(specs)
365 }
366
367 fn has_specs(&self, change_path: &Path) -> bool {
368 let specs_dir = change_path.join("specs");
369 if !self.fs.is_dir(&specs_dir) {
370 return false;
371 }
372
373 discovery::list_dir_names(&self.fs, &specs_dir)
374 .map(|entries| {
375 entries
376 .into_iter()
377 .any(|name| self.fs.is_file(&specs_dir.join(name).join("spec.md")))
378 })
379 .unwrap_or(false)
380 }
381
382 fn validate_artifact_front_matter(
389 &self,
390 content: &str,
391 expected_change_id: &str,
392 ) -> DomainResult<()> {
393 let parsed = front_matter::parse(content).map_err(|e| {
394 DomainError::io(
395 "parsing front matter",
396 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
397 )
398 })?;
399
400 let Some(fm) = &parsed.front_matter else {
401 return Ok(());
402 };
403
404 front_matter::validate_id("change_id", fm.change_id.as_deref(), expected_change_id)
406 .map_err(|e| {
407 DomainError::io(
408 "front matter validation",
409 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
410 )
411 })?;
412
413 front_matter::validate_integrity(fm, &parsed.body).map_err(|e| {
415 DomainError::io(
416 "front matter integrity",
417 std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
418 )
419 })?;
420
421 Ok(())
422 }
423
424 fn get_last_modified(&self, change_path: &Path) -> DomainResult<DateTime<Utc>> {
425 let mut latest = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap();
426
427 for entry in walkdir::WalkDir::new(change_path)
428 .into_iter()
429 .filter_map(|e| e.ok())
430 {
431 if let Ok(metadata) = entry.metadata()
432 && let Ok(modified) = metadata.modified()
433 {
434 let dt: DateTime<Utc> = modified.into();
435 if dt > latest {
436 latest = dt;
437 }
438 }
439 }
440
441 Ok(latest)
442 }
443
444 fn load_tasks_for_location(
445 &self,
446 location: &ChangeLocation,
447 ) -> DomainResult<ito_domain::tasks::TasksParseResult> {
448 match location.lifecycle {
449 ChangeLifecycle::Active => self.task_repo.load_tasks(&location.id),
450 ChangeLifecycle::Archived => self.task_repo.load_tasks_from_dir(&location.path),
451 }
452 }
453
454 fn build_summary_for_location(&self, location: &ChangeLocation) -> DomainResult<ChangeSummary> {
455 let tasks = self.load_tasks_for_location(location)?;
456 let progress = tasks.progress;
457 let completed_tasks = progress.complete as u32;
458 let shelved_tasks = progress.shelved as u32;
459 let in_progress_tasks = progress.in_progress as u32;
460 let pending_tasks = progress.pending as u32;
461 let total_tasks = progress.total as u32;
462 let last_modified = self.get_last_modified(&location.path)?;
463
464 let has_proposal = self.fs.is_file(&location.path.join("proposal.md"));
465 let has_design = self.fs.is_file(&location.path.join("design.md"));
466 let has_specs = self.has_specs(&location.path);
467 let has_tasks = total_tasks > 0;
468 let module_id = extract_module_id(&location.id);
469 let sub_module_id = extract_sub_module_id(&location.id);
470
471 Ok(ChangeSummary {
472 id: location.id.clone(),
473 module_id,
474 sub_module_id,
475 completed_tasks,
476 shelved_tasks,
477 in_progress_tasks,
478 pending_tasks,
479 total_tasks,
480 last_modified,
481 has_proposal,
482 has_design,
483 has_specs,
484 has_tasks,
485 })
486 }
487}
488
489impl<'a, F: FileSystem> DomainChangeRepository for FsChangeRepository<'a, F> {
490 fn resolve_target_with_options(
491 &self,
492 input: &str,
493 options: ResolveTargetOptions,
494 ) -> ChangeTargetResolution {
495 let names = self.list_change_ids(options.lifecycle);
496 if names.is_empty() {
497 return ChangeTargetResolution::NotFound;
498 }
499
500 let input = input.trim();
501 if input.is_empty() {
502 return ChangeTargetResolution::NotFound;
503 }
504
505 if names.iter().any(|name| name == input) {
506 return ChangeTargetResolution::Unique(input.to_string());
507 }
508
509 let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
510 let numeric_selector =
511 parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
512 if let Some((module_id, change_num)) = numeric_selector {
513 let numeric_prefix = format!("{module_id}-{change_num}");
514 let with_separator = format!("{numeric_prefix}_");
515 for name in &names {
516 if name == &numeric_prefix || name.starts_with(&with_separator) {
517 numeric_matches.insert(name.clone());
518 }
519 }
520 if !numeric_matches.is_empty() {
521 let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
522 if numeric_matches.len() == 1 {
523 return ChangeTargetResolution::Unique(numeric_matches[0].clone());
524 }
525 return ChangeTargetResolution::Ambiguous(numeric_matches);
526 }
527 }
528
529 if let Some((module, query)) = input.split_once(':') {
530 let query = query.trim();
531 if !query.is_empty() {
532 let module_id = parse_module_id(module);
533 let tokens = self.tokenize_query(query);
534 let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
535 for name in &names {
536 let Some((name_module, _name_change, slug)) =
537 self.split_canonical_change_id(name)
538 else {
539 continue;
540 };
541 if name_module != module_id {
542 continue;
543 }
544 if self.slug_matches_tokens(slug, &tokens) {
545 scoped_matches.insert(name.clone());
546 }
547 }
548
549 if scoped_matches.is_empty() {
550 return ChangeTargetResolution::NotFound;
551 }
552 let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
553 if scoped_matches.len() == 1 {
554 return ChangeTargetResolution::Unique(scoped_matches[0].clone());
555 }
556 return ChangeTargetResolution::Ambiguous(scoped_matches);
557 }
558 }
559
560 if self.is_numeric_module_selector(input) {
561 let module_id = parse_module_id(input);
562 let mut module_matches: BTreeSet<String> = BTreeSet::new();
563 for name in &names {
564 let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
565 else {
566 continue;
567 };
568 if name_module == module_id {
569 module_matches.insert(name.clone());
570 }
571 }
572
573 if !module_matches.is_empty() {
574 let module_matches: Vec<String> = module_matches.into_iter().collect();
575 if module_matches.len() == 1 {
576 return ChangeTargetResolution::Unique(module_matches[0].clone());
577 }
578 return ChangeTargetResolution::Ambiguous(module_matches);
579 }
580 }
581
582 let mut matches: BTreeSet<String> = BTreeSet::new();
583 for name in &names {
584 if name.starts_with(input) {
585 matches.insert(name.clone());
586 }
587 }
588
589 if matches.is_empty() {
590 let tokens = self.tokenize_query(input);
591 for name in &names {
592 let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
593 continue;
594 };
595 if self.slug_matches_tokens(slug, &tokens) {
596 matches.insert(name.clone());
597 }
598 }
599 }
600
601 if matches.is_empty() {
602 return ChangeTargetResolution::NotFound;
603 }
604
605 let matches: Vec<String> = matches.into_iter().collect();
606 if matches.len() == 1 {
607 return ChangeTargetResolution::Unique(matches[0].clone());
608 }
609
610 ChangeTargetResolution::Ambiguous(matches)
611 }
612
613 fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
614 let input = input.trim().to_lowercase();
615 if input.is_empty() || max == 0 {
616 return Vec::new();
617 }
618
619 let names = self.list_change_ids(ChangeLifecycleFilter::Active);
620 let canonical_names: Vec<String> = names
621 .iter()
622 .filter_map(|name| {
623 self.split_canonical_change_id(name)
624 .map(|(_module, _change, _slug)| name.clone())
625 })
626 .collect();
627 let mut scored: Vec<(usize, String)> = Vec::new();
628 let tokens = self.tokenize_query(&input);
629
630 for name in &canonical_names {
631 let lower = name.to_lowercase();
632 let mut score = 0;
633
634 if lower.starts_with(&input) {
635 score = score.max(100);
636 }
637 if lower.contains(&input) {
638 score = score.max(80);
639 }
640
641 let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
642 continue;
643 };
644 if !tokens.is_empty() && self.slug_matches_tokens(slug, &tokens) {
645 score = score.max(70);
646 }
647
648 if let Some((module_id, change_num)) = parse_change_id(&input) {
649 let numeric_prefix = format!("{module_id}-{change_num}");
650 if name.starts_with(&numeric_prefix) {
651 score = score.max(95);
652 }
653 }
654
655 if score > 0 {
656 scored.push((score, name.clone()));
657 }
658 }
659
660 scored.sort_by(|(a_score, a_name), (b_score, b_name)| {
661 b_score.cmp(a_score).then_with(|| a_name.cmp(b_name))
662 });
663
664 let mut out: Vec<String> = scored
665 .into_iter()
666 .map(|(_score, name)| name)
667 .take(max)
668 .collect();
669
670 if out.len() < max {
671 let nearest = nearest_matches(&input, &canonical_names, max * 2);
672 for candidate in nearest {
673 if out.iter().any(|existing| existing == &candidate) {
674 continue;
675 }
676 out.push(candidate);
677 if out.len() == max {
678 break;
679 }
680 }
681 }
682
683 out
684 }
685
686 fn exists(&self, id: &str) -> bool {
687 self.exists_with_filter(id, ChangeLifecycleFilter::Active)
688 }
689
690 fn exists_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> bool {
691 let resolution =
692 self.resolve_target_with_options(id, ResolveTargetOptions { lifecycle: filter });
693 match resolution {
694 ChangeTargetResolution::Unique(_) => true,
695 ChangeTargetResolution::Ambiguous(_) => false,
696 ChangeTargetResolution::NotFound => false,
697 }
698 }
699
700 fn get_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
701 let location = self.resolve_unique_change_location(id, filter)?;
702 let actual_id = location.id.clone();
703
704 let proposal = self.read_optional_file(&location.path.join("proposal.md"))?;
705 let design = self.read_optional_file(&location.path.join("design.md"))?;
706
707 if let Some(content) = &proposal {
710 self.validate_artifact_front_matter(content, &actual_id)?;
711 }
712 if let Some(content) = &design {
713 self.validate_artifact_front_matter(content, &actual_id)?;
714 }
715
716 let specs = self.load_specs(&location.path)?;
717 let tasks = self.load_tasks_for_location(&location)?;
718 let last_modified = self.get_last_modified(&location.path)?;
719 let path = location.path;
720
721 let sub_module_id = extract_sub_module_id(&actual_id);
722
723 Ok(Change {
724 id: actual_id.clone(),
725 module_id: extract_module_id(&actual_id),
726 sub_module_id,
727 path,
728 proposal,
729 design,
730 specs,
731 tasks,
732 last_modified,
733 })
734 }
735
736 fn list_with_filter(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
737 let mut summaries = Vec::new();
738 for location in self.list_change_locations(filter) {
739 summaries.push(self.build_summary_for_location(&location)?);
740 }
741
742 summaries.sort_by(|a, b| a.id.cmp(&b.id));
743 Ok(summaries)
744 }
745
746 fn list_by_module_with_filter(
747 &self,
748 module_id: &str,
749 filter: ChangeLifecycleFilter,
750 ) -> DomainResult<Vec<ChangeSummary>> {
751 let normalized_id = parse_module_id(module_id);
752 let all = self.list_with_filter(filter)?;
753 Ok(all
754 .into_iter()
755 .filter(|c| c.module_id.as_deref() == Some(&normalized_id))
756 .collect())
757 }
758
759 fn list_incomplete_with_filter(
760 &self,
761 filter: ChangeLifecycleFilter,
762 ) -> DomainResult<Vec<ChangeSummary>> {
763 let all = self.list_with_filter(filter)?;
764 Ok(all
765 .into_iter()
766 .filter(|c| c.status() == ChangeStatus::InProgress)
767 .collect())
768 }
769
770 fn list_complete_with_filter(
771 &self,
772 filter: ChangeLifecycleFilter,
773 ) -> DomainResult<Vec<ChangeSummary>> {
774 let all = self.list_with_filter(filter)?;
775 Ok(all
776 .into_iter()
777 .filter(|c| c.status() == ChangeStatus::Complete)
778 .collect())
779 }
780
781 fn get_summary_with_filter(
782 &self,
783 id: &str,
784 filter: ChangeLifecycleFilter,
785 ) -> DomainResult<ChangeSummary> {
786 let location = self.resolve_unique_change_location(id, filter)?;
787 self.build_summary_for_location(&location)
788 }
789}
790
791pub type ChangeRepository<'a> = FsChangeRepository<'a, StdFs>;
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use std::fs;
798 use tempfile::TempDir;
799
800 fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
801 let ito_path = tmp.path().join(".ito");
802 fs::create_dir_all(ito_path.join("changes")).unwrap();
803 ito_path
804 }
805
806 fn create_change(ito_path: &Path, id: &str, with_tasks: bool) {
807 let change_dir = ito_path.join("changes").join(id);
808 fs::create_dir_all(&change_dir).unwrap();
809 fs::write(change_dir.join("proposal.md"), "# Proposal\n").unwrap();
810 fs::write(change_dir.join("design.md"), "# Design\n").unwrap();
811
812 let specs_dir = change_dir.join("specs").join("test-spec");
813 fs::create_dir_all(&specs_dir).unwrap();
814 fs::write(specs_dir.join("spec.md"), "## Requirements\n").unwrap();
815
816 if with_tasks {
817 fs::write(
818 change_dir.join("tasks.md"),
819 "# Tasks\n- [x] Task 1\n- [ ] Task 2\n",
820 )
821 .unwrap();
822 }
823 }
824
825 fn create_archived_change(ito_path: &Path, id: &str) {
826 let archive_dir = ito_path.join("changes").join("archive").join(id);
827 fs::create_dir_all(&archive_dir).unwrap();
828 fs::write(archive_dir.join("proposal.md"), "# Archived\n").unwrap();
829 }
830
831 #[test]
832 fn exists_and_get_work() {
833 let tmp = TempDir::new().unwrap();
834 let ito_path = setup_test_ito(&tmp);
835 create_change(&ito_path, "005-01_test", true);
836
837 let repo = FsChangeRepository::new(&ito_path);
838 assert!(repo.exists("005-01_test"));
839 assert!(!repo.exists("999-99_missing"));
840
841 let change = repo.get("005-01_test").unwrap();
842 assert_eq!(change.id, "005-01_test");
843 assert_eq!(change.task_progress(), (1, 2));
844 assert!(change.proposal.is_some());
845 assert!(change.design.is_some());
846 assert_eq!(change.specs.len(), 1);
847 }
848
849 #[test]
850 fn list_skips_archive_dir() {
851 let tmp = TempDir::new().unwrap();
852 let ito_path = setup_test_ito(&tmp);
853 create_change(&ito_path, "005-01_first", true);
854 create_archived_change(&ito_path, "005-99_old");
855
856 let repo = FsChangeRepository::new(&ito_path);
857 let changes = repo.list().unwrap();
858
859 assert_eq!(changes.len(), 1);
860 assert_eq!(changes[0].id, "005-01_first");
861 }
862
863 #[test]
864 fn resolve_target_reports_ambiguity() {
865 let tmp = TempDir::new().unwrap();
866 let ito_path = setup_test_ito(&tmp);
867 create_change(&ito_path, "001-12_first-change", false);
868 create_change(&ito_path, "001-12_follow-up", false);
869
870 let repo = FsChangeRepository::new(&ito_path);
871 assert_eq!(
872 repo.resolve_target("1-12"),
873 ChangeTargetResolution::Ambiguous(vec![
874 "001-12_first-change".to_string(),
875 "001-12_follow-up".to_string(),
876 ])
877 );
878 }
879
880 #[test]
881 fn resolve_target_module_scoped_query() {
882 let tmp = TempDir::new().unwrap();
883 let ito_path = setup_test_ito(&tmp);
884 create_change(&ito_path, "001-12_setup-wizard", false);
885 create_change(&ito_path, "002-12_setup-wizard", false);
886
887 let repo = FsChangeRepository::new(&ito_path);
888 assert_eq!(
889 repo.resolve_target("1:setup"),
890 ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
891 );
892 assert_eq!(
893 repo.resolve_target("2:setup"),
894 ChangeTargetResolution::Unique("002-12_setup-wizard".to_string())
895 );
896 }
897
898 #[test]
899 fn resolve_target_includes_archive_when_requested() {
900 let tmp = TempDir::new().unwrap();
901 let ito_path = setup_test_ito(&tmp);
902 create_archived_change(&ito_path, "001-12_setup-wizard");
903
904 let repo = FsChangeRepository::new(&ito_path);
905 assert_eq!(
906 repo.resolve_target("1-12"),
907 ChangeTargetResolution::NotFound
908 );
909
910 assert_eq!(
911 repo.resolve_target_with_options(
912 "1-12",
913 ResolveTargetOptions {
914 lifecycle: ChangeLifecycleFilter::All,
915 }
916 ),
917 ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
918 );
919 }
920
921 #[test]
922 fn suggest_targets_prioritizes_slug_matches() {
923 let tmp = TempDir::new().unwrap();
924 let ito_path = setup_test_ito(&tmp);
925 create_change(&ito_path, "001-12_setup-wizard", false);
926 create_change(&ito_path, "001-13_setup-service", false);
927 create_change(&ito_path, "002-01_other-work", false);
928
929 let repo = FsChangeRepository::new(&ito_path);
930 let suggestions = repo.suggest_targets("setup", 2);
931 assert_eq!(
932 suggestions,
933 vec!["001-12_setup-wizard", "001-13_setup-service"]
934 );
935 }
936}