1use chrono::{DateTime, TimeZone, Utc};
4use ito_common::fs::{FileSystem, StdFs};
5use ito_common::match_::nearest_matches;
6use ito_common::paths;
7use ito_domain::changes::{
8 Change, 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
21pub 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 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 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 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 pub fn resolve_target(&self, input: &str) -> ChangeTargetResolution {
55 DomainChangeRepository::resolve_target(self, input)
56 }
57
58 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 pub fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
69 DomainChangeRepository::suggest_targets(self, input, max)
70 }
71
72 pub fn exists(&self, id: &str) -> bool {
74 DomainChangeRepository::exists(self, id)
75 }
76
77 pub fn get(&self, id: &str) -> DomainResult<Change> {
79 DomainChangeRepository::get(self, id)
80 }
81
82 pub fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
84 DomainChangeRepository::list(self)
85 }
86
87 pub fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
89 DomainChangeRepository::list_by_module(self, module_id)
90 }
91
92 pub fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
94 DomainChangeRepository::list_incomplete(self)
95 }
96
97 pub fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
99 DomainChangeRepository::list_complete(self)
100 }
101
102 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
564pub 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}