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