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    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
470        Ok(ChangeSummary {
471            id: location.id.clone(),
472            module_id,
473            completed_tasks,
474            shelved_tasks,
475            in_progress_tasks,
476            pending_tasks,
477            total_tasks,
478            last_modified,
479            has_proposal,
480            has_design,
481            has_specs,
482            has_tasks,
483        })
484    }
485}
486
487impl<'a, F: FileSystem> DomainChangeRepository for FsChangeRepository<'a, F> {
488    fn resolve_target_with_options(
489        &self,
490        input: &str,
491        options: ResolveTargetOptions,
492    ) -> ChangeTargetResolution {
493        let names = self.list_change_ids(options.lifecycle);
494        if names.is_empty() {
495            return ChangeTargetResolution::NotFound;
496        }
497
498        let input = input.trim();
499        if input.is_empty() {
500            return ChangeTargetResolution::NotFound;
501        }
502
503        if names.iter().any(|name| name == input) {
504            return ChangeTargetResolution::Unique(input.to_string());
505        }
506
507        let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
508        let numeric_selector =
509            parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
510        if let Some((module_id, change_num)) = numeric_selector {
511            let numeric_prefix = format!("{module_id}-{change_num}");
512            let with_separator = format!("{numeric_prefix}_");
513            for name in &names {
514                if name == &numeric_prefix || name.starts_with(&with_separator) {
515                    numeric_matches.insert(name.clone());
516                }
517            }
518            if !numeric_matches.is_empty() {
519                let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
520                if numeric_matches.len() == 1 {
521                    return ChangeTargetResolution::Unique(numeric_matches[0].clone());
522                }
523                return ChangeTargetResolution::Ambiguous(numeric_matches);
524            }
525        }
526
527        if let Some((module, query)) = input.split_once(':') {
528            let query = query.trim();
529            if !query.is_empty() {
530                let module_id = parse_module_id(module);
531                let tokens = self.tokenize_query(query);
532                let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
533                for name in &names {
534                    let Some((name_module, _name_change, slug)) =
535                        self.split_canonical_change_id(name)
536                    else {
537                        continue;
538                    };
539                    if name_module != module_id {
540                        continue;
541                    }
542                    if self.slug_matches_tokens(slug, &tokens) {
543                        scoped_matches.insert(name.clone());
544                    }
545                }
546
547                if scoped_matches.is_empty() {
548                    return ChangeTargetResolution::NotFound;
549                }
550                let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
551                if scoped_matches.len() == 1 {
552                    return ChangeTargetResolution::Unique(scoped_matches[0].clone());
553                }
554                return ChangeTargetResolution::Ambiguous(scoped_matches);
555            }
556        }
557
558        if self.is_numeric_module_selector(input) {
559            let module_id = parse_module_id(input);
560            let mut module_matches: BTreeSet<String> = BTreeSet::new();
561            for name in &names {
562                let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
563                else {
564                    continue;
565                };
566                if name_module == module_id {
567                    module_matches.insert(name.clone());
568                }
569            }
570
571            if !module_matches.is_empty() {
572                let module_matches: Vec<String> = module_matches.into_iter().collect();
573                if module_matches.len() == 1 {
574                    return ChangeTargetResolution::Unique(module_matches[0].clone());
575                }
576                return ChangeTargetResolution::Ambiguous(module_matches);
577            }
578        }
579
580        let mut matches: BTreeSet<String> = BTreeSet::new();
581        for name in &names {
582            if name.starts_with(input) {
583                matches.insert(name.clone());
584            }
585        }
586
587        if matches.is_empty() {
588            let tokens = self.tokenize_query(input);
589            for name in &names {
590                let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
591                    continue;
592                };
593                if self.slug_matches_tokens(slug, &tokens) {
594                    matches.insert(name.clone());
595                }
596            }
597        }
598
599        if matches.is_empty() {
600            return ChangeTargetResolution::NotFound;
601        }
602
603        let matches: Vec<String> = matches.into_iter().collect();
604        if matches.len() == 1 {
605            return ChangeTargetResolution::Unique(matches[0].clone());
606        }
607
608        ChangeTargetResolution::Ambiguous(matches)
609    }
610
611    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
612        let input = input.trim().to_lowercase();
613        if input.is_empty() || max == 0 {
614            return Vec::new();
615        }
616
617        let names = self.list_change_ids(ChangeLifecycleFilter::Active);
618        let canonical_names: Vec<String> = names
619            .iter()
620            .filter_map(|name| {
621                self.split_canonical_change_id(name)
622                    .map(|(_module, _change, _slug)| name.clone())
623            })
624            .collect();
625        let mut scored: Vec<(usize, String)> = Vec::new();
626        let tokens = self.tokenize_query(&input);
627
628        for name in &canonical_names {
629            let lower = name.to_lowercase();
630            let mut score = 0;
631
632            if lower.starts_with(&input) {
633                score = score.max(100);
634            }
635            if lower.contains(&input) {
636                score = score.max(80);
637            }
638
639            let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
640                continue;
641            };
642            if !tokens.is_empty() && self.slug_matches_tokens(slug, &tokens) {
643                score = score.max(70);
644            }
645
646            if let Some((module_id, change_num)) = parse_change_id(&input) {
647                let numeric_prefix = format!("{module_id}-{change_num}");
648                if name.starts_with(&numeric_prefix) {
649                    score = score.max(95);
650                }
651            }
652
653            if score > 0 {
654                scored.push((score, name.clone()));
655            }
656        }
657
658        scored.sort_by(|(a_score, a_name), (b_score, b_name)| {
659            b_score.cmp(a_score).then_with(|| a_name.cmp(b_name))
660        });
661
662        let mut out: Vec<String> = scored
663            .into_iter()
664            .map(|(_score, name)| name)
665            .take(max)
666            .collect();
667
668        if out.len() < max {
669            let nearest = nearest_matches(&input, &canonical_names, max * 2);
670            for candidate in nearest {
671                if out.iter().any(|existing| existing == &candidate) {
672                    continue;
673                }
674                out.push(candidate);
675                if out.len() == max {
676                    break;
677                }
678            }
679        }
680
681        out
682    }
683
684    fn exists(&self, id: &str) -> bool {
685        self.exists_with_filter(id, ChangeLifecycleFilter::Active)
686    }
687
688    fn exists_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> bool {
689        let resolution =
690            self.resolve_target_with_options(id, ResolveTargetOptions { lifecycle: filter });
691        match resolution {
692            ChangeTargetResolution::Unique(_) => true,
693            ChangeTargetResolution::Ambiguous(_) => false,
694            ChangeTargetResolution::NotFound => false,
695        }
696    }
697
698    fn get_with_filter(&self, id: &str, filter: ChangeLifecycleFilter) -> DomainResult<Change> {
699        let location = self.resolve_unique_change_location(id, filter)?;
700        let actual_id = location.id.clone();
701
702        let proposal = self.read_optional_file(&location.path.join("proposal.md"))?;
703        let design = self.read_optional_file(&location.path.join("design.md"))?;
704
705        // Validate front matter identifiers in artifacts (non-blocking for
706        // files without front matter).
707        if let Some(content) = &proposal {
708            self.validate_artifact_front_matter(content, &actual_id)?;
709        }
710        if let Some(content) = &design {
711            self.validate_artifact_front_matter(content, &actual_id)?;
712        }
713
714        let specs = self.load_specs(&location.path)?;
715        let tasks = self.load_tasks_for_location(&location)?;
716        let last_modified = self.get_last_modified(&location.path)?;
717        let path = location.path;
718
719        Ok(Change {
720            id: actual_id.clone(),
721            module_id: extract_module_id(&actual_id),
722            path,
723            proposal,
724            design,
725            specs,
726            tasks,
727            last_modified,
728        })
729    }
730
731    fn list_with_filter(&self, filter: ChangeLifecycleFilter) -> DomainResult<Vec<ChangeSummary>> {
732        let mut summaries = Vec::new();
733        for location in self.list_change_locations(filter) {
734            summaries.push(self.build_summary_for_location(&location)?);
735        }
736
737        summaries.sort_by(|a, b| a.id.cmp(&b.id));
738        Ok(summaries)
739    }
740
741    fn list_by_module_with_filter(
742        &self,
743        module_id: &str,
744        filter: ChangeLifecycleFilter,
745    ) -> DomainResult<Vec<ChangeSummary>> {
746        let normalized_id = parse_module_id(module_id);
747        let all = self.list_with_filter(filter)?;
748        Ok(all
749            .into_iter()
750            .filter(|c| c.module_id.as_deref() == Some(&normalized_id))
751            .collect())
752    }
753
754    fn list_incomplete_with_filter(
755        &self,
756        filter: ChangeLifecycleFilter,
757    ) -> DomainResult<Vec<ChangeSummary>> {
758        let all = self.list_with_filter(filter)?;
759        Ok(all
760            .into_iter()
761            .filter(|c| c.status() == ChangeStatus::InProgress)
762            .collect())
763    }
764
765    fn list_complete_with_filter(
766        &self,
767        filter: ChangeLifecycleFilter,
768    ) -> DomainResult<Vec<ChangeSummary>> {
769        let all = self.list_with_filter(filter)?;
770        Ok(all
771            .into_iter()
772            .filter(|c| c.status() == ChangeStatus::Complete)
773            .collect())
774    }
775
776    fn get_summary_with_filter(
777        &self,
778        id: &str,
779        filter: ChangeLifecycleFilter,
780    ) -> DomainResult<ChangeSummary> {
781        let location = self.resolve_unique_change_location(id, filter)?;
782        self.build_summary_for_location(&location)
783    }
784}
785
786/// Backward-compatible alias for the default filesystem-backed repository.
787pub type ChangeRepository<'a> = FsChangeRepository<'a, StdFs>;
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use std::fs;
793    use tempfile::TempDir;
794
795    fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
796        let ito_path = tmp.path().join(".ito");
797        fs::create_dir_all(ito_path.join("changes")).unwrap();
798        ito_path
799    }
800
801    fn create_change(ito_path: &Path, id: &str, with_tasks: bool) {
802        let change_dir = ito_path.join("changes").join(id);
803        fs::create_dir_all(&change_dir).unwrap();
804        fs::write(change_dir.join("proposal.md"), "# Proposal\n").unwrap();
805        fs::write(change_dir.join("design.md"), "# Design\n").unwrap();
806
807        let specs_dir = change_dir.join("specs").join("test-spec");
808        fs::create_dir_all(&specs_dir).unwrap();
809        fs::write(specs_dir.join("spec.md"), "## Requirements\n").unwrap();
810
811        if with_tasks {
812            fs::write(
813                change_dir.join("tasks.md"),
814                "# Tasks\n- [x] Task 1\n- [ ] Task 2\n",
815            )
816            .unwrap();
817        }
818    }
819
820    fn create_archived_change(ito_path: &Path, id: &str) {
821        let archive_dir = ito_path.join("changes").join("archive").join(id);
822        fs::create_dir_all(&archive_dir).unwrap();
823        fs::write(archive_dir.join("proposal.md"), "# Archived\n").unwrap();
824    }
825
826    #[test]
827    fn exists_and_get_work() {
828        let tmp = TempDir::new().unwrap();
829        let ito_path = setup_test_ito(&tmp);
830        create_change(&ito_path, "005-01_test", true);
831
832        let repo = FsChangeRepository::new(&ito_path);
833        assert!(repo.exists("005-01_test"));
834        assert!(!repo.exists("999-99_missing"));
835
836        let change = repo.get("005-01_test").unwrap();
837        assert_eq!(change.id, "005-01_test");
838        assert_eq!(change.task_progress(), (1, 2));
839        assert!(change.proposal.is_some());
840        assert!(change.design.is_some());
841        assert_eq!(change.specs.len(), 1);
842    }
843
844    #[test]
845    fn list_skips_archive_dir() {
846        let tmp = TempDir::new().unwrap();
847        let ito_path = setup_test_ito(&tmp);
848        create_change(&ito_path, "005-01_first", true);
849        create_archived_change(&ito_path, "005-99_old");
850
851        let repo = FsChangeRepository::new(&ito_path);
852        let changes = repo.list().unwrap();
853
854        assert_eq!(changes.len(), 1);
855        assert_eq!(changes[0].id, "005-01_first");
856    }
857
858    #[test]
859    fn resolve_target_reports_ambiguity() {
860        let tmp = TempDir::new().unwrap();
861        let ito_path = setup_test_ito(&tmp);
862        create_change(&ito_path, "001-12_first-change", false);
863        create_change(&ito_path, "001-12_follow-up", false);
864
865        let repo = FsChangeRepository::new(&ito_path);
866        assert_eq!(
867            repo.resolve_target("1-12"),
868            ChangeTargetResolution::Ambiguous(vec![
869                "001-12_first-change".to_string(),
870                "001-12_follow-up".to_string(),
871            ])
872        );
873    }
874
875    #[test]
876    fn resolve_target_module_scoped_query() {
877        let tmp = TempDir::new().unwrap();
878        let ito_path = setup_test_ito(&tmp);
879        create_change(&ito_path, "001-12_setup-wizard", false);
880        create_change(&ito_path, "002-12_setup-wizard", false);
881
882        let repo = FsChangeRepository::new(&ito_path);
883        assert_eq!(
884            repo.resolve_target("1:setup"),
885            ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
886        );
887        assert_eq!(
888            repo.resolve_target("2:setup"),
889            ChangeTargetResolution::Unique("002-12_setup-wizard".to_string())
890        );
891    }
892
893    #[test]
894    fn resolve_target_includes_archive_when_requested() {
895        let tmp = TempDir::new().unwrap();
896        let ito_path = setup_test_ito(&tmp);
897        create_archived_change(&ito_path, "001-12_setup-wizard");
898
899        let repo = FsChangeRepository::new(&ito_path);
900        assert_eq!(
901            repo.resolve_target("1-12"),
902            ChangeTargetResolution::NotFound
903        );
904
905        assert_eq!(
906            repo.resolve_target_with_options(
907                "1-12",
908                ResolveTargetOptions {
909                    lifecycle: ChangeLifecycleFilter::All,
910                }
911            ),
912            ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
913        );
914    }
915
916    #[test]
917    fn suggest_targets_prioritizes_slug_matches() {
918        let tmp = TempDir::new().unwrap();
919        let ito_path = setup_test_ito(&tmp);
920        create_change(&ito_path, "001-12_setup-wizard", false);
921        create_change(&ito_path, "001-13_setup-service", false);
922        create_change(&ito_path, "002-01_other-work", false);
923
924        let repo = FsChangeRepository::new(&ito_path);
925        let suggestions = repo.suggest_targets("setup", 2);
926        assert_eq!(
927            suggestions,
928            vec!["001-12_setup-wizard", "001-13_setup-service"]
929        );
930    }
931}