Skip to main content

ito_core/
module_repository.rs

1//! Filesystem-backed module repository implementation.
2
3use 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
13/// Filesystem-backed implementation of the domain `ModuleRepository` port.
14pub struct FsModuleRepository<'a, F: FileSystem = StdFs> {
15    ito_path: &'a Path,
16    fs: F,
17}
18
19impl<'a> FsModuleRepository<'a, StdFs> {
20    /// Create a filesystem-backed module repository using the standard filesystem.
21    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    /// Create a filesystem-backed module repository with a custom filesystem.
31    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
32        Self { ito_path, fs }
33    }
34
35    /// Get the path to the modules directory.
36    fn modules_dir(&self) -> PathBuf {
37        self.ito_path.join("modules")
38    }
39
40    /// Find the full module directory for a given module id or full name.
41    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            // Only count direct module changes (NNN-NN_name), not sub-module
110            // changes (NNN.SS-NN_name). Sub-module changes are counted separately
111            // in `count_changes_by_sub_module`.
112            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    /// Count changes per sub-module key (e.g., `"024.01"`) by scanning the
125    /// changes directory for entries whose IDs contain a dot before the hyphen.
126    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                // Extract the NNN.SS prefix (everything before the first `-`).
150                if let Some(sub_module_key) = name.split('-').next() {
151                    // Normalize: parse the NNN.SS part.
152                    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    /// Scan a module directory for sub-module directories under `sub/` and
165    /// return full [`SubModule`] values.
166    ///
167    /// Sub-module directories follow the `SS_name` naming convention where
168    /// `SS` is a zero-padded two-digit number.
169    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            // Validate that sub_num_str is numeric.
200            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    /// Scan a module directory for sub-module directories under `sub/` and
229    /// return lightweight [`SubModuleSummary`] values.
230    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    /// Load an optional description from a sub-module's `module.md`.
248    ///
249    /// Looks for the first non-empty paragraph after the `## Purpose` heading.
250    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        // Extract the first non-empty line after `## Purpose`.
262        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                    // Reached the next heading — stop.
272                    break;
273                }
274                // Skip HTML comment placeholder lines (single-line `<!-- ... -->`).
275                // Multi-line HTML comments are not expected in generated module.md files.
276                if !trimmed.is_empty() && !trimmed.starts_with("<!--") {
277                    return Ok(Some(trimmed.to_string()));
278                }
279            }
280        }
281
282        Ok(None)
283    }
284
285    /// Check if a module exists.
286    pub fn exists(&self, id: &str) -> bool {
287        DomainModuleRepository::exists(self, id)
288    }
289
290    /// Get a module by ID or full name.
291    pub fn get(&self, id_or_name: &str) -> DomainResult<Module> {
292        DomainModuleRepository::get(self, id_or_name)
293    }
294
295    /// List all modules.
296    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        // Parse the composite id (e.g., "024.01" or "024.01_auth").
381        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        // Find the sub-module directory matching the sub number prefix.
401        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
447/// Backward-compatible alias for the default filesystem-backed module repository.
448pub 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    // ── Task 2.7: Regression tests for sub-module support ─────────────────
561
562    /// Set up a sub-module directory under a parent module.
563    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        // Regression test: a parent module can have direct changes (024-07_*)
585        // while a sub-module (024.01) owns its own changes (024.01-01_*).
586        let tmp = TempDir::new().unwrap();
587        let ito_path = setup_test_ito(&tmp);
588
589        // Set up module 024 with sub-module 01_auth.
590        create_module(&ito_path, "024", "ito-backend");
591        create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
592
593        // Direct change on the parent module.
594        create_change(&ito_path, "024-07_health-check");
595        // Sub-module change.
596        create_change(&ito_path, "024.01-01_add-jwt");
597
598        let repo = ModuleRepository::new(&ito_path);
599
600        // --- Module listing ---
601        let modules = repo.list().unwrap();
602        assert_eq!(modules.len(), 1);
603        let m = &modules[0];
604        assert_eq!(m.id, "024");
605        // The parent module's change_count should include only the direct change
606        // (024-07_health-check). The sub-module change (024.01-01_add-jwt) is
607        // attributed to the sub-module, not the parent.
608        assert_eq!(
609            m.change_count, 1,
610            "parent module should count only direct changes"
611        );
612        // Sub-module summary should be populated.
613        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        // --- Module get ---
622        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        // --- list_sub_modules ---
632        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        // --- get_sub_module ---
638        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        // Set up module 024 with sub-module 01_auth.
654        create_module(&ito_path, "024", "ito-backend");
655        create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
656
657        // Direct change on the parent module.
658        create_change(&ito_path, "024-07_health-check");
659        // Sub-module change.
660        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}