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, ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
9    ChangeTargetResolution, ResolveTargetOptions, Spec, extract_module_id, parse_change_id,
10    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::BTreeSet;
17use std::path::Path;
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
29impl<'a> FsChangeRepository<'a, StdFs> {
30    /// Create a repository backed by the real filesystem.
31    pub fn new(ito_path: &'a Path) -> Self {
32        Self::with_fs(ito_path, StdFs)
33    }
34}
35
36impl<'a, F: FileSystem> FsChangeRepository<'a, F> {
37    /// Create a repository with an explicit filesystem implementation.
38    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
39        Self::with_task_repo(ito_path, FsTaskRepository::new(ito_path), fs)
40    }
41
42    /// Create a repository with an injected task repository.
43    ///
44    /// Use this when you need to control the task repository instance
45    /// (e.g., in tests or when sharing a repo across multiple consumers).
46    pub fn with_task_repo(ito_path: &'a Path, task_repo: FsTaskRepository<'a>, fs: F) -> Self {
47        Self {
48            ito_path,
49            task_repo,
50            fs,
51        }
52    }
53
54    /// Resolve an input change target into a canonical change id.
55    pub fn resolve_target(&self, input: &str) -> ChangeTargetResolution {
56        DomainChangeRepository::resolve_target(self, input)
57    }
58
59    /// Resolve an input change target into a canonical change id using options.
60    pub fn resolve_target_with_options(
61        &self,
62        input: &str,
63        options: ResolveTargetOptions,
64    ) -> ChangeTargetResolution {
65        DomainChangeRepository::resolve_target_with_options(self, input, options)
66    }
67
68    /// Return best-effort suggestions for a change target.
69    pub fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
70        DomainChangeRepository::suggest_targets(self, input, max)
71    }
72
73    /// Check if a change exists.
74    pub fn exists(&self, id: &str) -> bool {
75        DomainChangeRepository::exists(self, id)
76    }
77
78    /// Get a full change with all artifacts loaded.
79    pub fn get(&self, id: &str) -> DomainResult<Change> {
80        DomainChangeRepository::get(self, id)
81    }
82
83    /// List all changes as summaries (lightweight).
84    pub fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
85        DomainChangeRepository::list(self)
86    }
87
88    /// List changes belonging to a specific module.
89    pub fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
90        DomainChangeRepository::list_by_module(self, module_id)
91    }
92
93    /// List changes with incomplete tasks.
94    pub fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
95        DomainChangeRepository::list_incomplete(self)
96    }
97
98    /// List changes with all tasks complete.
99    pub fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
100        DomainChangeRepository::list_complete(self)
101    }
102
103    /// Get a summary for a specific change (lightweight).
104    pub fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
105        DomainChangeRepository::get_summary(self, id)
106    }
107
108    fn changes_dir(&self) -> std::path::PathBuf {
109        paths::changes_dir(self.ito_path)
110    }
111
112    fn list_change_dir_names(&self, include_archived: bool) -> Vec<String> {
113        let mut out = discovery::list_change_dir_names(&self.fs, self.ito_path).unwrap_or_default();
114
115        if include_archived {
116            let archive_dir = self.changes_dir().join("archive");
117            let archived = discovery::list_dir_names(&self.fs, &archive_dir).unwrap_or_default();
118            out.extend(archived);
119        }
120
121        out.sort();
122        out.dedup();
123        out
124    }
125
126    fn split_canonical_change_id<'b>(&self, name: &'b str) -> Option<(String, String, &'b str)> {
127        let (module_id, change_num) = parse_change_id(name)?;
128        let slug = name.split_once('_').map(|(_id, s)| s).unwrap_or("");
129        Some((module_id, change_num, slug))
130    }
131
132    fn tokenize_query(&self, input: &str) -> Vec<String> {
133        input
134            .split_whitespace()
135            .map(|s| s.trim().to_lowercase())
136            .filter(|s| !s.is_empty())
137            .collect()
138    }
139
140    fn normalized_slug_text(&self, slug: &str) -> String {
141        let mut out = String::new();
142        for ch in slug.chars() {
143            if ch.is_ascii_alphanumeric() {
144                out.push(ch.to_ascii_lowercase());
145            } else {
146                out.push(' ');
147            }
148        }
149        out
150    }
151
152    fn slug_matches_tokens(&self, slug: &str, tokens: &[String]) -> bool {
153        if tokens.is_empty() {
154            return false;
155        }
156        let text = self.normalized_slug_text(slug);
157        for token in tokens {
158            if !text.contains(token) {
159                return false;
160            }
161        }
162        true
163    }
164
165    fn is_numeric_module_selector(&self, input: &str) -> bool {
166        let trimmed = input.trim();
167        !trimmed.is_empty() && trimmed.chars().all(|ch| ch.is_ascii_digit())
168    }
169
170    fn extract_two_numbers_as_change_id(&self, input: &str) -> Option<(String, String)> {
171        let re = Regex::new(r"\d+").ok()?;
172        let mut parts: Vec<&str> = Vec::new();
173        for m in re.find_iter(input) {
174            parts.push(m.as_str());
175            if parts.len() > 2 {
176                return None;
177            }
178        }
179        if parts.len() != 2 {
180            return None;
181        }
182        let parsed = format!("{}-{}", parts[0], parts[1]);
183        parse_change_id(&parsed)
184    }
185
186    fn resolve_unique_change_id(&self, input: &str) -> DomainResult<String> {
187        match self.resolve_target(input) {
188            ChangeTargetResolution::Unique(id) => Ok(id),
189            ChangeTargetResolution::Ambiguous(matches) => {
190                Err(DomainError::ambiguous_target("change", input, &matches))
191            }
192            ChangeTargetResolution::NotFound => Err(DomainError::not_found("change", input)),
193        }
194    }
195
196    fn read_optional_file(&self, path: &Path) -> DomainResult<Option<String>> {
197        if self.fs.is_file(path) {
198            let content = self
199                .fs
200                .read_to_string(path)
201                .map_err(|source| DomainError::io("reading optional file", source))?;
202            Ok(Some(content))
203        } else {
204            Ok(None)
205        }
206    }
207
208    fn load_specs(&self, change_path: &Path) -> DomainResult<Vec<Spec>> {
209        let specs_dir = change_path.join("specs");
210        if !self.fs.is_dir(&specs_dir) {
211            return Ok(Vec::new());
212        }
213
214        let mut specs = Vec::new();
215        for name in discovery::list_dir_names(&self.fs, &specs_dir)? {
216            let spec_file = specs_dir.join(&name).join("spec.md");
217            if self.fs.is_file(&spec_file) {
218                let content = self
219                    .fs
220                    .read_to_string(&spec_file)
221                    .map_err(|source| DomainError::io("reading spec file", source))?;
222                specs.push(Spec { name, content });
223            }
224        }
225
226        specs.sort_by(|a, b| a.name.cmp(&b.name));
227        Ok(specs)
228    }
229
230    fn has_specs(&self, change_path: &Path) -> bool {
231        let specs_dir = change_path.join("specs");
232        if !self.fs.is_dir(&specs_dir) {
233            return false;
234        }
235
236        discovery::list_dir_names(&self.fs, &specs_dir)
237            .map(|entries| {
238                entries
239                    .into_iter()
240                    .any(|name| self.fs.is_file(&specs_dir.join(name).join("spec.md")))
241            })
242            .unwrap_or(false)
243    }
244
245    /// Validate front matter identifiers in a change artifact, if present.
246    ///
247    /// Parses front matter from the content and checks that any declared
248    /// `change_id` matches the expected change directory name. Integrity
249    /// checksums are also validated when present. If the content has no
250    /// front matter, this is a no-op.
251    fn validate_artifact_front_matter(
252        &self,
253        content: &str,
254        expected_change_id: &str,
255    ) -> DomainResult<()> {
256        let parsed = front_matter::parse(content).map_err(|e| {
257            DomainError::io(
258                "parsing front matter",
259                std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
260            )
261        })?;
262
263        let Some(fm) = &parsed.front_matter else {
264            return Ok(());
265        };
266
267        // Validate change_id if declared in front matter
268        front_matter::validate_id("change_id", fm.change_id.as_deref(), expected_change_id)
269            .map_err(|e| {
270                DomainError::io(
271                    "front matter validation",
272                    std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
273                )
274            })?;
275
276        // Validate body integrity checksum if present
277        front_matter::validate_integrity(fm, &parsed.body).map_err(|e| {
278            DomainError::io(
279                "front matter integrity",
280                std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
281            )
282        })?;
283
284        Ok(())
285    }
286
287    fn get_last_modified(&self, change_path: &Path) -> DomainResult<DateTime<Utc>> {
288        let mut latest = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap();
289
290        for entry in walkdir::WalkDir::new(change_path)
291            .into_iter()
292            .filter_map(|e| e.ok())
293        {
294            if let Ok(metadata) = entry.metadata()
295                && let Ok(modified) = metadata.modified()
296            {
297                let dt: DateTime<Utc> = modified.into();
298                if dt > latest {
299                    latest = dt;
300                }
301            }
302        }
303
304        Ok(latest)
305    }
306}
307
308impl<'a, F: FileSystem> DomainChangeRepository for FsChangeRepository<'a, F> {
309    fn resolve_target_with_options(
310        &self,
311        input: &str,
312        options: ResolveTargetOptions,
313    ) -> ChangeTargetResolution {
314        let names = self.list_change_dir_names(options.include_archived);
315        if names.is_empty() {
316            return ChangeTargetResolution::NotFound;
317        }
318
319        let input = input.trim();
320        if input.is_empty() {
321            return ChangeTargetResolution::NotFound;
322        }
323
324        if names.iter().any(|name| name == input) {
325            return ChangeTargetResolution::Unique(input.to_string());
326        }
327
328        let mut numeric_matches: BTreeSet<String> = BTreeSet::new();
329        let numeric_selector =
330            parse_change_id(input).or_else(|| self.extract_two_numbers_as_change_id(input));
331        if let Some((module_id, change_num)) = numeric_selector {
332            let numeric_prefix = format!("{module_id}-{change_num}");
333            let with_separator = format!("{numeric_prefix}_");
334            for name in &names {
335                if name == &numeric_prefix || name.starts_with(&with_separator) {
336                    numeric_matches.insert(name.clone());
337                }
338            }
339            if !numeric_matches.is_empty() {
340                let numeric_matches: Vec<String> = numeric_matches.into_iter().collect();
341                if numeric_matches.len() == 1 {
342                    return ChangeTargetResolution::Unique(numeric_matches[0].clone());
343                }
344                return ChangeTargetResolution::Ambiguous(numeric_matches);
345            }
346        }
347
348        if let Some((module, query)) = input.split_once(':') {
349            let query = query.trim();
350            if !query.is_empty() {
351                let module_id = parse_module_id(module);
352                let tokens = self.tokenize_query(query);
353                let mut scoped_matches: BTreeSet<String> = BTreeSet::new();
354                for name in &names {
355                    let Some((name_module, _name_change, slug)) =
356                        self.split_canonical_change_id(name)
357                    else {
358                        continue;
359                    };
360                    if name_module != module_id {
361                        continue;
362                    }
363                    if self.slug_matches_tokens(slug, &tokens) {
364                        scoped_matches.insert(name.clone());
365                    }
366                }
367
368                if scoped_matches.is_empty() {
369                    return ChangeTargetResolution::NotFound;
370                }
371                let scoped_matches: Vec<String> = scoped_matches.into_iter().collect();
372                if scoped_matches.len() == 1 {
373                    return ChangeTargetResolution::Unique(scoped_matches[0].clone());
374                }
375                return ChangeTargetResolution::Ambiguous(scoped_matches);
376            }
377        }
378
379        if self.is_numeric_module_selector(input) {
380            let module_id = parse_module_id(input);
381            let mut module_matches: BTreeSet<String> = BTreeSet::new();
382            for name in &names {
383                let Some((name_module, _name_change, _slug)) = self.split_canonical_change_id(name)
384                else {
385                    continue;
386                };
387                if name_module == module_id {
388                    module_matches.insert(name.clone());
389                }
390            }
391
392            if !module_matches.is_empty() {
393                let module_matches: Vec<String> = module_matches.into_iter().collect();
394                if module_matches.len() == 1 {
395                    return ChangeTargetResolution::Unique(module_matches[0].clone());
396                }
397                return ChangeTargetResolution::Ambiguous(module_matches);
398            }
399        }
400
401        let mut matches: BTreeSet<String> = BTreeSet::new();
402        for name in &names {
403            if name.starts_with(input) {
404                matches.insert(name.clone());
405            }
406        }
407
408        if matches.is_empty() {
409            let tokens = self.tokenize_query(input);
410            for name in &names {
411                let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
412                    continue;
413                };
414                if self.slug_matches_tokens(slug, &tokens) {
415                    matches.insert(name.clone());
416                }
417            }
418        }
419
420        if matches.is_empty() {
421            return ChangeTargetResolution::NotFound;
422        }
423
424        let matches: Vec<String> = matches.into_iter().collect();
425        if matches.len() == 1 {
426            return ChangeTargetResolution::Unique(matches[0].clone());
427        }
428
429        ChangeTargetResolution::Ambiguous(matches)
430    }
431
432    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
433        let input = input.trim().to_lowercase();
434        if input.is_empty() || max == 0 {
435            return Vec::new();
436        }
437
438        let names = self.list_change_dir_names(false);
439        let canonical_names: Vec<String> = names
440            .iter()
441            .filter_map(|name| {
442                self.split_canonical_change_id(name)
443                    .map(|(_module, _change, _slug)| name.clone())
444            })
445            .collect();
446        let mut scored: Vec<(usize, String)> = Vec::new();
447        let tokens = self.tokenize_query(&input);
448
449        for name in &canonical_names {
450            let lower = name.to_lowercase();
451            let mut score = 0;
452
453            if lower.starts_with(&input) {
454                score = score.max(100);
455            }
456            if lower.contains(&input) {
457                score = score.max(80);
458            }
459
460            let Some((_module, _change, slug)) = self.split_canonical_change_id(name) else {
461                continue;
462            };
463            if !tokens.is_empty() && self.slug_matches_tokens(slug, &tokens) {
464                score = score.max(70);
465            }
466
467            if let Some((module_id, change_num)) = parse_change_id(&input) {
468                let numeric_prefix = format!("{module_id}-{change_num}");
469                if name.starts_with(&numeric_prefix) {
470                    score = score.max(95);
471                }
472            }
473
474            if score > 0 {
475                scored.push((score, name.clone()));
476            }
477        }
478
479        scored.sort_by(|(a_score, a_name), (b_score, b_name)| {
480            b_score.cmp(a_score).then_with(|| a_name.cmp(b_name))
481        });
482
483        let mut out: Vec<String> = scored
484            .into_iter()
485            .map(|(_score, name)| name)
486            .take(max)
487            .collect();
488
489        if out.len() < max {
490            let nearest = nearest_matches(&input, &canonical_names, max * 2);
491            for candidate in nearest {
492                if out.iter().any(|existing| existing == &candidate) {
493                    continue;
494                }
495                out.push(candidate);
496                if out.len() == max {
497                    break;
498                }
499            }
500        }
501
502        out
503    }
504
505    fn exists(&self, id: &str) -> bool {
506        let resolution = self.resolve_target(id);
507        match resolution {
508            ChangeTargetResolution::Unique(_) => true,
509            ChangeTargetResolution::Ambiguous(_) => false,
510            ChangeTargetResolution::NotFound => false,
511        }
512    }
513
514    fn get(&self, id: &str) -> DomainResult<Change> {
515        let actual_id = self.resolve_unique_change_id(id)?;
516        let path = self.changes_dir().join(&actual_id);
517
518        let proposal = self.read_optional_file(&path.join("proposal.md"))?;
519        let design = self.read_optional_file(&path.join("design.md"))?;
520
521        // Validate front matter identifiers in artifacts (non-blocking for
522        // files without front matter).
523        if let Some(content) = &proposal {
524            self.validate_artifact_front_matter(content, &actual_id)?;
525        }
526        if let Some(content) = &design {
527            self.validate_artifact_front_matter(content, &actual_id)?;
528        }
529
530        let specs = self.load_specs(&path)?;
531        let tasks = self.task_repo.load_tasks(&actual_id)?;
532        let last_modified = self.get_last_modified(&path)?;
533
534        Ok(Change {
535            id: actual_id.clone(),
536            module_id: extract_module_id(&actual_id),
537            path,
538            proposal,
539            design,
540            specs,
541            tasks,
542            last_modified,
543        })
544    }
545
546    fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
547        let changes_dir = self.changes_dir();
548        if !self.fs.is_dir(&changes_dir) {
549            return Ok(Vec::new());
550        }
551
552        let mut summaries = Vec::new();
553        for name in discovery::list_change_dir_names(&self.fs, self.ito_path)? {
554            let summary = self.get_summary(&name)?;
555            summaries.push(summary);
556        }
557
558        summaries.sort_by(|a, b| a.id.cmp(&b.id));
559        Ok(summaries)
560    }
561
562    fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
563        let normalized_id = parse_module_id(module_id);
564        let all = self.list()?;
565        Ok(all
566            .into_iter()
567            .filter(|c| c.module_id.as_deref() == Some(&normalized_id))
568            .collect())
569    }
570
571    fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
572        let all = self.list()?;
573        Ok(all
574            .into_iter()
575            .filter(|c| c.status() == ChangeStatus::InProgress)
576            .collect())
577    }
578
579    fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
580        let all = self.list()?;
581        Ok(all
582            .into_iter()
583            .filter(|c| c.status() == ChangeStatus::Complete)
584            .collect())
585    }
586
587    fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
588        let actual_id = self.resolve_unique_change_id(id)?;
589        let path = self.changes_dir().join(&actual_id);
590
591        let progress = self.task_repo.get_progress(&actual_id)?;
592        let completed_tasks = progress.complete as u32;
593        let shelved_tasks = progress.shelved as u32;
594        let in_progress_tasks = progress.in_progress as u32;
595        let pending_tasks = progress.pending as u32;
596        let total_tasks = progress.total as u32;
597        let last_modified = self.get_last_modified(&path)?;
598
599        let has_proposal = self.fs.is_file(&path.join("proposal.md"));
600        let has_design = self.fs.is_file(&path.join("design.md"));
601        let has_specs = self.has_specs(&path);
602        let has_tasks = total_tasks > 0;
603        let module_id = extract_module_id(&actual_id);
604
605        Ok(ChangeSummary {
606            id: actual_id,
607            module_id,
608            completed_tasks,
609            shelved_tasks,
610            in_progress_tasks,
611            pending_tasks,
612            total_tasks,
613            last_modified,
614            has_proposal,
615            has_design,
616            has_specs,
617            has_tasks,
618        })
619    }
620}
621
622/// Backward-compatible alias for the default filesystem-backed repository.
623pub type ChangeRepository<'a> = FsChangeRepository<'a, StdFs>;
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use std::fs;
629    use tempfile::TempDir;
630
631    fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
632        let ito_path = tmp.path().join(".ito");
633        fs::create_dir_all(ito_path.join("changes")).unwrap();
634        ito_path
635    }
636
637    fn create_change(ito_path: &Path, id: &str, with_tasks: bool) {
638        let change_dir = ito_path.join("changes").join(id);
639        fs::create_dir_all(&change_dir).unwrap();
640        fs::write(change_dir.join("proposal.md"), "# Proposal\n").unwrap();
641        fs::write(change_dir.join("design.md"), "# Design\n").unwrap();
642
643        let specs_dir = change_dir.join("specs").join("test-spec");
644        fs::create_dir_all(&specs_dir).unwrap();
645        fs::write(specs_dir.join("spec.md"), "## Requirements\n").unwrap();
646
647        if with_tasks {
648            fs::write(
649                change_dir.join("tasks.md"),
650                "# Tasks\n- [x] Task 1\n- [ ] Task 2\n",
651            )
652            .unwrap();
653        }
654    }
655
656    fn create_archived_change(ito_path: &Path, id: &str) {
657        let archive_dir = ito_path.join("changes").join("archive").join(id);
658        fs::create_dir_all(&archive_dir).unwrap();
659        fs::write(archive_dir.join("proposal.md"), "# Archived\n").unwrap();
660    }
661
662    #[test]
663    fn exists_and_get_work() {
664        let tmp = TempDir::new().unwrap();
665        let ito_path = setup_test_ito(&tmp);
666        create_change(&ito_path, "005-01_test", true);
667
668        let repo = FsChangeRepository::new(&ito_path);
669        assert!(repo.exists("005-01_test"));
670        assert!(!repo.exists("999-99_missing"));
671
672        let change = repo.get("005-01_test").unwrap();
673        assert_eq!(change.id, "005-01_test");
674        assert_eq!(change.task_progress(), (1, 2));
675        assert!(change.proposal.is_some());
676        assert!(change.design.is_some());
677        assert_eq!(change.specs.len(), 1);
678    }
679
680    #[test]
681    fn list_skips_archive_dir() {
682        let tmp = TempDir::new().unwrap();
683        let ito_path = setup_test_ito(&tmp);
684        create_change(&ito_path, "005-01_first", true);
685        create_archived_change(&ito_path, "005-99_old");
686
687        let repo = FsChangeRepository::new(&ito_path);
688        let changes = repo.list().unwrap();
689
690        assert_eq!(changes.len(), 1);
691        assert_eq!(changes[0].id, "005-01_first");
692    }
693
694    #[test]
695    fn resolve_target_reports_ambiguity() {
696        let tmp = TempDir::new().unwrap();
697        let ito_path = setup_test_ito(&tmp);
698        create_change(&ito_path, "001-12_first-change", false);
699        create_change(&ito_path, "001-12_follow-up", false);
700
701        let repo = FsChangeRepository::new(&ito_path);
702        assert_eq!(
703            repo.resolve_target("1-12"),
704            ChangeTargetResolution::Ambiguous(vec![
705                "001-12_first-change".to_string(),
706                "001-12_follow-up".to_string(),
707            ])
708        );
709    }
710
711    #[test]
712    fn resolve_target_module_scoped_query() {
713        let tmp = TempDir::new().unwrap();
714        let ito_path = setup_test_ito(&tmp);
715        create_change(&ito_path, "001-12_setup-wizard", false);
716        create_change(&ito_path, "002-12_setup-wizard", false);
717
718        let repo = FsChangeRepository::new(&ito_path);
719        assert_eq!(
720            repo.resolve_target("1:setup"),
721            ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
722        );
723        assert_eq!(
724            repo.resolve_target("2:setup"),
725            ChangeTargetResolution::Unique("002-12_setup-wizard".to_string())
726        );
727    }
728
729    #[test]
730    fn resolve_target_includes_archive_when_requested() {
731        let tmp = TempDir::new().unwrap();
732        let ito_path = setup_test_ito(&tmp);
733        create_archived_change(&ito_path, "001-12_setup-wizard");
734
735        let repo = FsChangeRepository::new(&ito_path);
736        assert_eq!(
737            repo.resolve_target("1-12"),
738            ChangeTargetResolution::NotFound
739        );
740
741        assert_eq!(
742            repo.resolve_target_with_options(
743                "1-12",
744                ResolveTargetOptions {
745                    include_archived: true,
746                }
747            ),
748            ChangeTargetResolution::Unique("001-12_setup-wizard".to_string())
749        );
750    }
751
752    #[test]
753    fn suggest_targets_prioritizes_slug_matches() {
754        let tmp = TempDir::new().unwrap();
755        let ito_path = setup_test_ito(&tmp);
756        create_change(&ito_path, "001-12_setup-wizard", false);
757        create_change(&ito_path, "001-13_setup-service", false);
758        create_change(&ito_path, "002-01_other-work", false);
759
760        let repo = FsChangeRepository::new(&ito_path);
761        let suggestions = repo.suggest_targets("setup", 2);
762        assert_eq!(
763            suggestions,
764            vec!["001-12_setup-wizard", "001-13_setup-service"]
765        );
766    }
767}