Skip to main content

ito_core/
change_repository.rs

1//! Filesystem-backed implementation of the domain change repository port.
2
3use 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
22/// Filesystem-backed change repository.
23pub 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    /// Create a repository backed by the real filesystem.
44    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    /// Create a repository with an explicit filesystem implementation.
51    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    /// Create a repository with an injected task repository.
56    ///
57    /// Use this when you need to control the task repository instance
58    /// (e.g., in tests or when sharing a repo across multiple consumers).
59    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    /// Resolve an input change target into a canonical change id.
68    pub fn resolve_target(&self, input: &str) -> ChangeTargetResolution {
69        DomainChangeRepository::resolve_target(self, input)
70    }
71
72    /// Resolve an input change target into a canonical change id using options.
73    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    /// Return best-effort suggestions for a change target.
82    pub fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
83        DomainChangeRepository::suggest_targets(self, input, max)
84    }
85
86    /// Check if a change exists.
87    pub fn exists(&self, id: &str) -> bool {
88        DomainChangeRepository::exists(self, id)
89    }
90
91    /// Get a full change with all artifacts loaded.
92    pub fn get(&self, id: &str) -> DomainResult<Change> {
93        DomainChangeRepository::get(self, id)
94    }
95
96    /// List all changes as summaries (lightweight).
97    pub fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
98        DomainChangeRepository::list(self)
99    }
100
101    /// List changes belonging to a specific module.
102    pub fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
103        DomainChangeRepository::list_by_module(self, module_id)
104    }
105
106    /// List changes with incomplete tasks.
107    pub fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
108        DomainChangeRepository::list_incomplete(self)
109    }
110
111    /// List changes with all tasks complete.
112    pub fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
113        DomainChangeRepository::list_complete(self)
114    }
115
116    /// Get a summary for a specific change (lightweight).
117    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    /// Validate front matter identifiers in a change artifact, if present.
383    ///
384    /// Parses front matter from the content and checks that any declared
385    /// `change_id` matches the expected change directory name. Integrity
386    /// checksums are also validated when present. If the content has no
387    /// front matter, this is a no-op.
388    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        // Validate change_id if declared in front matter
405        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        // Validate body integrity checksum if present
414        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        // Validate front matter identifiers in artifacts (non-blocking for
708        // files without front matter).
709        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
791/// Backward-compatible alias for the default filesystem-backed repository.
792pub 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}