1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use ito_common::fs::{FileSystem, StdFs};
7use ito_domain::changes::{extract_module_id, parse_module_id};
8use ito_domain::errors::{DomainError, DomainResult};
9use ito_domain::modules::{
10 Module, ModuleRepository as DomainModuleRepository, ModuleSummary, SubModule, SubModuleSummary,
11};
12
13pub struct FsModuleRepository<'a, F: FileSystem = StdFs> {
15 ito_path: &'a Path,
16 fs: F,
17}
18
19impl<'a> FsModuleRepository<'a, StdFs> {
20 pub fn new(ito_path: &'a Path) -> Self {
22 Self {
23 ito_path,
24 fs: StdFs,
25 }
26 }
27}
28
29impl<'a, F: FileSystem> FsModuleRepository<'a, F> {
30 pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
32 Self { ito_path, fs }
33 }
34
35 fn modules_dir(&self) -> PathBuf {
37 self.ito_path.join("modules")
38 }
39
40 fn find_module_dir(&self, id_or_name: &str) -> Option<PathBuf> {
42 let modules_dir = self.modules_dir();
43 if !self.fs.is_dir(&modules_dir) {
44 return None;
45 }
46
47 let normalized_id = parse_module_id(id_or_name);
48 let prefix = format!("{normalized_id}_");
49
50 let entries = self.fs.read_dir(&modules_dir).ok()?;
51 for entry in entries {
52 let matches = entry
53 .file_name()
54 .and_then(|n| n.to_str())
55 .is_some_and(|n| n.starts_with(&prefix));
56 if matches {
57 return Some(entry);
58 }
59 }
60 None
61 }
62
63 fn load_module_description(&self, module_path: &Path) -> DomainResult<Option<String>> {
64 let yaml_path = module_path.join("module.yaml");
65 if !self.fs.is_file(&yaml_path) {
66 return Ok(None);
67 }
68
69 let content = self
70 .fs
71 .read_to_string(&yaml_path)
72 .map_err(|source| DomainError::io("reading module.yaml", source))?;
73
74 for line in content.lines() {
75 let line = line.trim();
76 if let Some(desc) = line.strip_prefix("description:") {
77 let desc = desc.trim().trim_matches('"').trim_matches('\'');
78 if !desc.is_empty() {
79 return Ok(Some(desc.to_string()));
80 }
81 }
82 }
83
84 Ok(None)
85 }
86
87 fn count_changes_by_module(&self) -> DomainResult<HashMap<String, u32>> {
88 use ito_common::id::{ItoIdKind, classify_id};
89
90 let mut counts = HashMap::new();
91 let changes_dir = self.ito_path.join("changes");
92 if !self.fs.is_dir(&changes_dir) {
93 return Ok(counts);
94 }
95
96 for path in self
97 .fs
98 .read_dir(&changes_dir)
99 .map_err(|source| DomainError::io("listing change directories", source))?
100 {
101 if !self.fs.is_dir(&path) {
102 continue;
103 }
104
105 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
106 continue;
107 };
108
109 if classify_id(name) == ItoIdKind::SubModuleChangeId {
113 continue;
114 }
115
116 if let Some(module_id) = extract_module_id(name) {
117 *counts.entry(module_id).or_insert(0) += 1;
118 }
119 }
120
121 Ok(counts)
122 }
123
124 fn count_changes_by_sub_module(&self) -> DomainResult<HashMap<String, u32>> {
127 use ito_common::id::{ItoIdKind, classify_id};
128
129 let mut counts: HashMap<String, u32> = HashMap::new();
130 let changes_dir = self.ito_path.join("changes");
131 if !self.fs.is_dir(&changes_dir) {
132 return Ok(counts);
133 }
134
135 for path in self
136 .fs
137 .read_dir(&changes_dir)
138 .map_err(|source| DomainError::io("listing change directories", source))?
139 {
140 if !self.fs.is_dir(&path) {
141 continue;
142 }
143
144 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
145 continue;
146 };
147
148 if classify_id(name) == ItoIdKind::SubModuleChangeId {
149 if let Some(sub_module_key) = name.split('-').next() {
151 if let Ok(parsed) = ito_common::id::parse_sub_module_id(sub_module_key) {
153 *counts
154 .entry(parsed.sub_module_id.as_str().to_string())
155 .or_insert(0) += 1;
156 }
157 }
158 }
159 }
160
161 Ok(counts)
162 }
163
164 fn scan_sub_modules(
170 &self,
171 module_dir: &Path,
172 parent_module_id: &str,
173 ) -> DomainResult<Vec<SubModule>> {
174 let sub_dir = module_dir.join("sub");
175 if !self.fs.is_dir(&sub_dir) {
176 return Ok(Vec::new());
177 }
178
179 let change_counts = self.count_changes_by_sub_module()?;
180 let mut sub_modules = Vec::new();
181
182 for path in self
183 .fs
184 .read_dir(&sub_dir)
185 .map_err(|source| DomainError::io("listing sub-module directories", source))?
186 {
187 if !self.fs.is_dir(&path) {
188 continue;
189 }
190
191 let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
192 continue;
193 };
194
195 let Some((sub_num_str, name)) = dir_name.split_once('_') else {
196 continue;
197 };
198
199 if sub_num_str.is_empty() || !sub_num_str.chars().all(|c| c.is_ascii_digit()) {
201 continue;
202 }
203
204 let sub_num: u32 = match sub_num_str.parse() {
205 Ok(n) => n,
206 Err(_) => continue,
207 };
208
209 let sub_id_str = format!("{parent_module_id}.{sub_num:02}");
210 let description = self.load_sub_module_description(&path)?;
211 let change_count = change_counts.get(&sub_id_str).copied().unwrap_or(0);
212
213 sub_modules.push(SubModule {
214 id: sub_id_str,
215 parent_module_id: parent_module_id.to_string(),
216 sub_id: format!("{sub_num:02}"),
217 name: name.to_string(),
218 description,
219 change_count,
220 path,
221 });
222 }
223
224 sub_modules.sort_by(|a, b| a.sub_id.cmp(&b.sub_id));
225 Ok(sub_modules)
226 }
227
228 fn scan_sub_module_summaries(
231 &self,
232 module_dir: &Path,
233 parent_module_id: &str,
234 ) -> DomainResult<Vec<SubModuleSummary>> {
235 let sub_modules = self.scan_sub_modules(module_dir, parent_module_id)?;
236 let mut summaries = Vec::with_capacity(sub_modules.len());
237 for sm in sub_modules {
238 summaries.push(SubModuleSummary {
239 id: sm.id,
240 name: sm.name,
241 change_count: sm.change_count,
242 });
243 }
244 Ok(summaries)
245 }
246
247 fn load_sub_module_description(&self, sub_module_path: &Path) -> DomainResult<Option<String>> {
251 let md_path = sub_module_path.join("module.md");
252 if !self.fs.is_file(&md_path) {
253 return Ok(None);
254 }
255
256 let content = self
257 .fs
258 .read_to_string(&md_path)
259 .map_err(|source| DomainError::io("reading sub-module module.md", source))?;
260
261 let mut in_purpose = false;
263 for line in content.lines() {
264 let trimmed = line.trim();
265 if trimmed.eq_ignore_ascii_case("## purpose") {
266 in_purpose = true;
267 continue;
268 }
269 if in_purpose {
270 if trimmed.starts_with('#') {
271 break;
273 }
274 if !trimmed.is_empty() && !trimmed.starts_with("<!--") {
277 return Ok(Some(trimmed.to_string()));
278 }
279 }
280 }
281
282 Ok(None)
283 }
284
285 pub fn exists(&self, id: &str) -> bool {
287 DomainModuleRepository::exists(self, id)
288 }
289
290 pub fn get(&self, id_or_name: &str) -> DomainResult<Module> {
292 DomainModuleRepository::get(self, id_or_name)
293 }
294
295 pub fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
297 DomainModuleRepository::list(self)
298 }
299}
300
301impl<F: FileSystem> DomainModuleRepository for FsModuleRepository<'_, F> {
302 fn exists(&self, id: &str) -> bool {
303 self.find_module_dir(id).is_some()
304 }
305
306 fn get(&self, id_or_name: &str) -> DomainResult<Module> {
307 let path = self
308 .find_module_dir(id_or_name)
309 .ok_or_else(|| DomainError::not_found("module", id_or_name))?;
310
311 let id = parse_module_id(id_or_name);
312 let name = path
313 .file_name()
314 .and_then(|n| n.to_str())
315 .and_then(|n| n.strip_prefix(&format!("{id}_")))
316 .unwrap_or("unknown")
317 .to_string();
318
319 let description = self.load_module_description(&path)?;
320 let sub_modules = self.scan_sub_modules(&path, &id)?;
321
322 Ok(Module {
323 id,
324 name,
325 description,
326 path,
327 sub_modules,
328 })
329 }
330
331 fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
332 let modules_dir = self.modules_dir();
333 if !self.fs.is_dir(&modules_dir) {
334 return Ok(Vec::new());
335 }
336
337 let change_counts = self.count_changes_by_module()?;
338
339 let mut summaries = Vec::new();
340 for path in self
341 .fs
342 .read_dir(&modules_dir)
343 .map_err(|source| DomainError::io("listing module directories", source))?
344 {
345 if !self.fs.is_dir(&path) {
346 continue;
347 }
348
349 let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
350 continue;
351 };
352 let Some((id, name)) = dir_name.split_once('_') else {
353 continue;
354 };
355
356 let sub_modules = self.scan_sub_module_summaries(&path, id)?;
357
358 summaries.push(ModuleSummary {
359 id: id.to_string(),
360 name: name.to_string(),
361 change_count: change_counts.get(id).copied().unwrap_or(0),
362 sub_modules,
363 });
364 }
365
366 summaries.sort_by(|a, b| a.id.cmp(&b.id));
367 Ok(summaries)
368 }
369
370 fn list_sub_modules(&self, parent_id: &str) -> DomainResult<Vec<SubModuleSummary>> {
371 let path = self
372 .find_module_dir(parent_id)
373 .ok_or_else(|| DomainError::not_found("module", parent_id))?;
374
375 let normalized_id = parse_module_id(parent_id);
376 self.scan_sub_module_summaries(&path, &normalized_id)
377 }
378
379 fn get_sub_module(&self, composite_id: &str) -> DomainResult<SubModule> {
380 let parsed = ito_common::id::parse_sub_module_id(composite_id).map_err(|e| {
382 DomainError::io(
383 "parsing sub-module id",
384 std::io::Error::new(std::io::ErrorKind::InvalidInput, e.error),
385 )
386 })?;
387
388 let parent_id = parsed.parent_module_id.as_str();
389 let sub_num = &parsed.sub_num;
390
391 let module_path = self
392 .find_module_dir(parent_id)
393 .ok_or_else(|| DomainError::not_found("module", parent_id))?;
394
395 let sub_dir = module_path.join("sub");
396 if !self.fs.is_dir(&sub_dir) {
397 return Err(DomainError::not_found("sub-module", composite_id));
398 }
399
400 let prefix = format!("{sub_num}_");
402 let entries = self
403 .fs
404 .read_dir(&sub_dir)
405 .map_err(|source| DomainError::io("listing sub-module directories", source))?;
406 let mut sub_module_path = None;
407 for entry in entries {
408 let matches = entry
409 .file_name()
410 .and_then(|n| n.to_str())
411 .is_some_and(|n| n.starts_with(&prefix));
412 if matches {
413 sub_module_path = Some(entry);
414 break;
415 }
416 }
417 let Some(sub_module_path) = sub_module_path else {
418 return Err(DomainError::not_found("sub-module", composite_id));
419 };
420
421 let Some(dir_name) = sub_module_path.file_name().and_then(|n| n.to_str()) else {
422 return Err(DomainError::not_found("sub-module", composite_id));
423 };
424
425 let name = dir_name
426 .strip_prefix(&prefix)
427 .unwrap_or(dir_name)
428 .to_string();
429
430 let change_counts = self.count_changes_by_sub_module()?;
431 let sub_id_str = parsed.sub_module_id.as_str().to_string();
432 let change_count = change_counts.get(&sub_id_str).copied().unwrap_or(0);
433 let description = self.load_sub_module_description(&sub_module_path)?;
434
435 Ok(SubModule {
436 id: sub_id_str,
437 parent_module_id: parent_id.to_string(),
438 sub_id: sub_num.clone(),
439 name,
440 description,
441 change_count,
442 path: sub_module_path,
443 })
444 }
445}
446
447pub type ModuleRepository<'a> = FsModuleRepository<'a, StdFs>;
449
450#[cfg(test)]
451mod tests {
452 use std::fs;
453 use std::path::Path;
454
455 use tempfile::TempDir;
456
457 use super::{FsModuleRepository, ModuleRepository};
458
459 fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
460 let ito_path = tmp.path().join(".ito");
461 fs::create_dir_all(ito_path.join("modules")).unwrap();
462 fs::create_dir_all(ito_path.join("changes")).unwrap();
463 ito_path
464 }
465
466 fn create_module(ito_path: &Path, id: &str, name: &str) {
467 let module_dir = ito_path.join("modules").join(format!("{}_{}", id, name));
468 fs::create_dir_all(&module_dir).unwrap();
469 }
470
471 fn create_change(ito_path: &Path, id: &str) {
472 let change_dir = ito_path.join("changes").join(id);
473 fs::create_dir_all(&change_dir).unwrap();
474 }
475
476 #[test]
477 fn test_exists() {
478 let tmp = TempDir::new().unwrap();
479 let ito_path = setup_test_ito(&tmp);
480 create_module(&ito_path, "005", "dev-tooling");
481
482 let repo = ModuleRepository::new(&ito_path);
483 assert!(repo.exists("005"));
484 assert!(!repo.exists("999"));
485 }
486
487 #[test]
488 fn test_get() {
489 let tmp = TempDir::new().unwrap();
490 let ito_path = setup_test_ito(&tmp);
491 create_module(&ito_path, "005", "dev-tooling");
492
493 let repo = ModuleRepository::new(&ito_path);
494 let module = repo.get("005").unwrap();
495
496 assert_eq!(module.id, "005");
497 assert_eq!(module.name, "dev-tooling");
498 }
499
500 #[test]
501 fn test_get_not_found() {
502 let tmp = TempDir::new().unwrap();
503 let ito_path = setup_test_ito(&tmp);
504
505 let repo = ModuleRepository::new(&ito_path);
506 let result = repo.get("999");
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn test_list() {
512 let tmp = TempDir::new().unwrap();
513 let ito_path = setup_test_ito(&tmp);
514 create_module(&ito_path, "005", "dev-tooling");
515 create_module(&ito_path, "003", "qa-testing");
516 create_module(&ito_path, "001", "workflow");
517
518 let repo = ModuleRepository::new(&ito_path);
519 let modules = repo.list().unwrap();
520
521 assert_eq!(modules.len(), 3);
522 assert_eq!(modules[0].id, "001");
523 assert_eq!(modules[1].id, "003");
524 assert_eq!(modules[2].id, "005");
525 }
526
527 #[test]
528 fn test_list_with_change_counts() {
529 let tmp = TempDir::new().unwrap();
530 let ito_path = setup_test_ito(&tmp);
531 create_module(&ito_path, "005", "dev-tooling");
532 create_module(&ito_path, "003", "qa-testing");
533
534 create_change(&ito_path, "005-01_first");
535 create_change(&ito_path, "005-02_second");
536 create_change(&ito_path, "003-01_test");
537
538 let repo = ModuleRepository::new(&ito_path);
539 let modules = repo.list().unwrap();
540
541 let module_005 = modules.iter().find(|m| m.id == "005").unwrap();
542 let module_003 = modules.iter().find(|m| m.id == "003").unwrap();
543
544 assert_eq!(module_005.change_count, 2);
545 assert_eq!(module_003.change_count, 1);
546 }
547
548 #[test]
549 fn test_get_uses_full_name_input() {
550 let tmp = TempDir::new().unwrap();
551 let ito_path = setup_test_ito(&tmp);
552 create_module(&ito_path, "005", "dev-tooling");
553
554 let repo = FsModuleRepository::new(&ito_path);
555 let module = repo.get("005_dev-tooling").unwrap();
556 assert_eq!(module.id, "005");
557 assert_eq!(module.name, "dev-tooling");
558 }
559
560 fn create_sub_module(
564 ito_path: &Path,
565 parent_id: &str,
566 parent_name: &str,
567 sub_num: &str,
568 sub_name: &str,
569 ) {
570 let module_dir = ito_path
571 .join("modules")
572 .join(format!("{parent_id}_{parent_name}"));
573 let sub_dir = module_dir.join("sub").join(format!("{sub_num}_{sub_name}"));
574 fs::create_dir_all(&sub_dir).unwrap();
575 fs::write(
576 sub_dir.join("module.md"),
577 format!("# {sub_name}\n\n## Purpose\n{sub_name} sub-module\n"),
578 )
579 .unwrap();
580 }
581
582 #[test]
583 fn regression_parent_module_retains_direct_changes_while_sub_module_owns_sub_changes() {
584 let tmp = TempDir::new().unwrap();
587 let ito_path = setup_test_ito(&tmp);
588
589 create_module(&ito_path, "024", "ito-backend");
591 create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
592
593 create_change(&ito_path, "024-07_health-check");
595 create_change(&ito_path, "024.01-01_add-jwt");
597
598 let repo = ModuleRepository::new(&ito_path);
599
600 let modules = repo.list().unwrap();
602 assert_eq!(modules.len(), 1);
603 let m = &modules[0];
604 assert_eq!(m.id, "024");
605 assert_eq!(
609 m.change_count, 1,
610 "parent module should count only direct changes"
611 );
612 assert_eq!(m.sub_modules.len(), 1);
614 assert_eq!(m.sub_modules[0].id, "024.01");
615 assert_eq!(m.sub_modules[0].name, "auth");
616 assert_eq!(
617 m.sub_modules[0].change_count, 1,
618 "sub-module should count its own change"
619 );
620
621 let module = repo.get("024").unwrap();
623 assert_eq!(module.id, "024");
624 assert_eq!(module.sub_modules.len(), 1);
625 assert_eq!(module.sub_modules[0].id, "024.01");
626 assert_eq!(module.sub_modules[0].parent_module_id, "024");
627 assert_eq!(module.sub_modules[0].sub_id, "01");
628 assert_eq!(module.sub_modules[0].name, "auth");
629 assert_eq!(module.sub_modules[0].change_count, 1);
630
631 use ito_domain::modules::ModuleRepository as DomainModuleRepository;
633 let sub_summaries = DomainModuleRepository::list_sub_modules(&repo, "024").unwrap();
634 assert_eq!(sub_summaries.len(), 1);
635 assert_eq!(sub_summaries[0].id, "024.01");
636
637 let sub = DomainModuleRepository::get_sub_module(&repo, "024.01").unwrap();
639 assert_eq!(sub.id, "024.01");
640 assert_eq!(sub.parent_module_id, "024");
641 assert_eq!(sub.sub_id, "01");
642 assert_eq!(sub.name, "auth");
643 assert_eq!(sub.change_count, 1);
644 }
645
646 #[test]
647 fn regression_change_repository_populates_sub_module_id() {
648 use crate::change_repository::FsChangeRepository;
649
650 let tmp = TempDir::new().unwrap();
651 let ito_path = setup_test_ito(&tmp);
652
653 create_module(&ito_path, "024", "ito-backend");
655 create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
656
657 create_change(&ito_path, "024-07_health-check");
659 create_change(&ito_path, "024.01-01_add-jwt");
661
662 let change_repo = FsChangeRepository::new(&ito_path);
663 let summaries = change_repo.list().unwrap();
664
665 let direct = summaries
666 .iter()
667 .find(|s| s.id == "024-07_health-check")
668 .unwrap();
669 assert_eq!(direct.module_id.as_deref(), Some("024"));
670 assert_eq!(
671 direct.sub_module_id, None,
672 "direct change should have no sub_module_id"
673 );
674
675 let sub_change = summaries
676 .iter()
677 .find(|s| s.id == "024.01-01_add-jwt")
678 .unwrap();
679 assert_eq!(sub_change.module_id.as_deref(), Some("024"));
680 assert_eq!(
681 sub_change.sub_module_id.as_deref(),
682 Some("024.01"),
683 "sub-module change should have sub_module_id set"
684 );
685 }
686}