use std::collections::HashMap;
use std::path::{Path, PathBuf};
use ito_common::fs::{FileSystem, StdFs};
use ito_domain::changes::{extract_module_id, parse_module_id};
use ito_domain::errors::{DomainError, DomainResult};
use ito_domain::modules::{
Module, ModuleRepository as DomainModuleRepository, ModuleSummary, SubModule, SubModuleSummary,
};
pub struct FsModuleRepository<'a, F: FileSystem = StdFs> {
ito_path: &'a Path,
fs: F,
}
impl<'a> FsModuleRepository<'a, StdFs> {
pub fn new(ito_path: &'a Path) -> Self {
Self {
ito_path,
fs: StdFs,
}
}
}
impl<'a, F: FileSystem> FsModuleRepository<'a, F> {
pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
Self { ito_path, fs }
}
fn modules_dir(&self) -> PathBuf {
self.ito_path.join("modules")
}
fn find_module_dir(&self, id_or_name: &str) -> Option<PathBuf> {
let modules_dir = self.modules_dir();
if !self.fs.is_dir(&modules_dir) {
return None;
}
let normalized_id = parse_module_id(id_or_name);
let prefix = format!("{normalized_id}_");
let entries = self.fs.read_dir(&modules_dir).ok()?;
for entry in entries {
let matches = entry
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix));
if matches {
return Some(entry);
}
}
None
}
fn load_module_description(&self, module_path: &Path) -> DomainResult<Option<String>> {
let yaml_path = module_path.join("module.yaml");
if !self.fs.is_file(&yaml_path) {
return Ok(None);
}
let content = self
.fs
.read_to_string(&yaml_path)
.map_err(|source| DomainError::io("reading module.yaml", source))?;
for line in content.lines() {
let line = line.trim();
if let Some(desc) = line.strip_prefix("description:") {
let desc = desc.trim().trim_matches('"').trim_matches('\'');
if !desc.is_empty() {
return Ok(Some(desc.to_string()));
}
}
}
Ok(None)
}
fn count_changes_by_module(&self) -> DomainResult<HashMap<String, u32>> {
use ito_common::id::{ItoIdKind, classify_id};
let mut counts = HashMap::new();
let changes_dir = self.ito_path.join("changes");
if !self.fs.is_dir(&changes_dir) {
return Ok(counts);
}
for path in self
.fs
.read_dir(&changes_dir)
.map_err(|source| DomainError::io("listing change directories", source))?
{
if !self.fs.is_dir(&path) {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if classify_id(name) == ItoIdKind::SubModuleChangeId {
continue;
}
if let Some(module_id) = extract_module_id(name) {
*counts.entry(module_id).or_insert(0) += 1;
}
}
Ok(counts)
}
fn count_changes_by_sub_module(&self) -> DomainResult<HashMap<String, u32>> {
use ito_common::id::{ItoIdKind, classify_id};
let mut counts: HashMap<String, u32> = HashMap::new();
let changes_dir = self.ito_path.join("changes");
if !self.fs.is_dir(&changes_dir) {
return Ok(counts);
}
for path in self
.fs
.read_dir(&changes_dir)
.map_err(|source| DomainError::io("listing change directories", source))?
{
if !self.fs.is_dir(&path) {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if classify_id(name) == ItoIdKind::SubModuleChangeId {
if let Some(sub_module_key) = name.split('-').next() {
if let Ok(parsed) = ito_common::id::parse_sub_module_id(sub_module_key) {
*counts
.entry(parsed.sub_module_id.as_str().to_string())
.or_insert(0) += 1;
}
}
}
}
Ok(counts)
}
fn scan_sub_modules(
&self,
module_dir: &Path,
parent_module_id: &str,
) -> DomainResult<Vec<SubModule>> {
let sub_dir = module_dir.join("sub");
if !self.fs.is_dir(&sub_dir) {
return Ok(Vec::new());
}
let change_counts = self.count_changes_by_sub_module()?;
let mut sub_modules = Vec::new();
for path in self
.fs
.read_dir(&sub_dir)
.map_err(|source| DomainError::io("listing sub-module directories", source))?
{
if !self.fs.is_dir(&path) {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some((sub_num_str, name)) = dir_name.split_once('_') else {
continue;
};
if sub_num_str.is_empty() || !sub_num_str.chars().all(|c| c.is_ascii_digit()) {
continue;
}
let sub_num: u32 = match sub_num_str.parse() {
Ok(n) => n,
Err(_) => continue,
};
let sub_id_str = format!("{parent_module_id}.{sub_num:02}");
let description = self.load_sub_module_description(&path)?;
let change_count = change_counts.get(&sub_id_str).copied().unwrap_or(0);
sub_modules.push(SubModule {
id: sub_id_str,
parent_module_id: parent_module_id.to_string(),
sub_id: format!("{sub_num:02}"),
name: name.to_string(),
description,
change_count,
path,
});
}
sub_modules.sort_by(|a, b| a.sub_id.cmp(&b.sub_id));
Ok(sub_modules)
}
fn scan_sub_module_summaries(
&self,
module_dir: &Path,
parent_module_id: &str,
) -> DomainResult<Vec<SubModuleSummary>> {
let sub_modules = self.scan_sub_modules(module_dir, parent_module_id)?;
let mut summaries = Vec::with_capacity(sub_modules.len());
for sm in sub_modules {
summaries.push(SubModuleSummary {
id: sm.id,
name: sm.name,
change_count: sm.change_count,
});
}
Ok(summaries)
}
fn load_sub_module_description(&self, sub_module_path: &Path) -> DomainResult<Option<String>> {
let md_path = sub_module_path.join("module.md");
if !self.fs.is_file(&md_path) {
return Ok(None);
}
let content = self
.fs
.read_to_string(&md_path)
.map_err(|source| DomainError::io("reading sub-module module.md", source))?;
let mut in_purpose = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case("## purpose") {
in_purpose = true;
continue;
}
if in_purpose {
if trimmed.starts_with('#') {
break;
}
if !trimmed.is_empty() && !trimmed.starts_with("<!--") {
return Ok(Some(trimmed.to_string()));
}
}
}
Ok(None)
}
pub fn exists(&self, id: &str) -> bool {
DomainModuleRepository::exists(self, id)
}
pub fn get(&self, id_or_name: &str) -> DomainResult<Module> {
DomainModuleRepository::get(self, id_or_name)
}
pub fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
DomainModuleRepository::list(self)
}
}
impl<F: FileSystem> DomainModuleRepository for FsModuleRepository<'_, F> {
fn exists(&self, id: &str) -> bool {
self.find_module_dir(id).is_some()
}
fn get(&self, id_or_name: &str) -> DomainResult<Module> {
let path = self
.find_module_dir(id_or_name)
.ok_or_else(|| DomainError::not_found("module", id_or_name))?;
let id = parse_module_id(id_or_name);
let name = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_prefix(&format!("{id}_")))
.unwrap_or("unknown")
.to_string();
let description = self.load_module_description(&path)?;
let sub_modules = self.scan_sub_modules(&path, &id)?;
Ok(Module {
id,
name,
description,
path,
sub_modules,
})
}
fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
let modules_dir = self.modules_dir();
if !self.fs.is_dir(&modules_dir) {
return Ok(Vec::new());
}
let change_counts = self.count_changes_by_module()?;
let mut summaries = Vec::new();
for path in self
.fs
.read_dir(&modules_dir)
.map_err(|source| DomainError::io("listing module directories", source))?
{
if !self.fs.is_dir(&path) {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some((id, name)) = dir_name.split_once('_') else {
continue;
};
let sub_modules = self.scan_sub_module_summaries(&path, id)?;
summaries.push(ModuleSummary {
id: id.to_string(),
name: name.to_string(),
change_count: change_counts.get(id).copied().unwrap_or(0),
sub_modules,
});
}
summaries.sort_by(|a, b| a.id.cmp(&b.id));
Ok(summaries)
}
fn list_sub_modules(&self, parent_id: &str) -> DomainResult<Vec<SubModuleSummary>> {
let path = self
.find_module_dir(parent_id)
.ok_or_else(|| DomainError::not_found("module", parent_id))?;
let normalized_id = parse_module_id(parent_id);
self.scan_sub_module_summaries(&path, &normalized_id)
}
fn get_sub_module(&self, composite_id: &str) -> DomainResult<SubModule> {
let parsed = ito_common::id::parse_sub_module_id(composite_id).map_err(|e| {
DomainError::io(
"parsing sub-module id",
std::io::Error::new(std::io::ErrorKind::InvalidInput, e.error),
)
})?;
let parent_id = parsed.parent_module_id.as_str();
let sub_num = &parsed.sub_num;
let module_path = self
.find_module_dir(parent_id)
.ok_or_else(|| DomainError::not_found("module", parent_id))?;
let sub_dir = module_path.join("sub");
if !self.fs.is_dir(&sub_dir) {
return Err(DomainError::not_found("sub-module", composite_id));
}
let prefix = format!("{sub_num}_");
let entries = self
.fs
.read_dir(&sub_dir)
.map_err(|source| DomainError::io("listing sub-module directories", source))?;
let mut sub_module_path = None;
for entry in entries {
let matches = entry
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix));
if matches {
sub_module_path = Some(entry);
break;
}
}
let Some(sub_module_path) = sub_module_path else {
return Err(DomainError::not_found("sub-module", composite_id));
};
let Some(dir_name) = sub_module_path.file_name().and_then(|n| n.to_str()) else {
return Err(DomainError::not_found("sub-module", composite_id));
};
let name = dir_name
.strip_prefix(&prefix)
.unwrap_or(dir_name)
.to_string();
let change_counts = self.count_changes_by_sub_module()?;
let sub_id_str = parsed.sub_module_id.as_str().to_string();
let change_count = change_counts.get(&sub_id_str).copied().unwrap_or(0);
let description = self.load_sub_module_description(&sub_module_path)?;
Ok(SubModule {
id: sub_id_str,
parent_module_id: parent_id.to_string(),
sub_id: sub_num.clone(),
name,
description,
change_count,
path: sub_module_path,
})
}
}
pub type ModuleRepository<'a> = FsModuleRepository<'a, StdFs>;
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use super::{FsModuleRepository, ModuleRepository};
fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
let ito_path = tmp.path().join(".ito");
fs::create_dir_all(ito_path.join("modules")).unwrap();
fs::create_dir_all(ito_path.join("changes")).unwrap();
ito_path
}
fn create_module(ito_path: &Path, id: &str, name: &str) {
let module_dir = ito_path.join("modules").join(format!("{}_{}", id, name));
fs::create_dir_all(&module_dir).unwrap();
}
fn create_change(ito_path: &Path, id: &str) {
let change_dir = ito_path.join("changes").join(id);
fs::create_dir_all(&change_dir).unwrap();
}
#[test]
fn test_exists() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "005", "dev-tooling");
let repo = ModuleRepository::new(&ito_path);
assert!(repo.exists("005"));
assert!(!repo.exists("999"));
}
#[test]
fn test_get() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "005", "dev-tooling");
let repo = ModuleRepository::new(&ito_path);
let module = repo.get("005").unwrap();
assert_eq!(module.id, "005");
assert_eq!(module.name, "dev-tooling");
}
#[test]
fn test_get_not_found() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
let repo = ModuleRepository::new(&ito_path);
let result = repo.get("999");
assert!(result.is_err());
}
#[test]
fn test_list() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "005", "dev-tooling");
create_module(&ito_path, "003", "qa-testing");
create_module(&ito_path, "001", "workflow");
let repo = ModuleRepository::new(&ito_path);
let modules = repo.list().unwrap();
assert_eq!(modules.len(), 3);
assert_eq!(modules[0].id, "001");
assert_eq!(modules[1].id, "003");
assert_eq!(modules[2].id, "005");
}
#[test]
fn test_list_with_change_counts() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "005", "dev-tooling");
create_module(&ito_path, "003", "qa-testing");
create_change(&ito_path, "005-01_first");
create_change(&ito_path, "005-02_second");
create_change(&ito_path, "003-01_test");
let repo = ModuleRepository::new(&ito_path);
let modules = repo.list().unwrap();
let module_005 = modules.iter().find(|m| m.id == "005").unwrap();
let module_003 = modules.iter().find(|m| m.id == "003").unwrap();
assert_eq!(module_005.change_count, 2);
assert_eq!(module_003.change_count, 1);
}
#[test]
fn test_get_uses_full_name_input() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "005", "dev-tooling");
let repo = FsModuleRepository::new(&ito_path);
let module = repo.get("005_dev-tooling").unwrap();
assert_eq!(module.id, "005");
assert_eq!(module.name, "dev-tooling");
}
fn create_sub_module(
ito_path: &Path,
parent_id: &str,
parent_name: &str,
sub_num: &str,
sub_name: &str,
) {
let module_dir = ito_path
.join("modules")
.join(format!("{parent_id}_{parent_name}"));
let sub_dir = module_dir.join("sub").join(format!("{sub_num}_{sub_name}"));
fs::create_dir_all(&sub_dir).unwrap();
fs::write(
sub_dir.join("module.md"),
format!("# {sub_name}\n\n## Purpose\n{sub_name} sub-module\n"),
)
.unwrap();
}
#[test]
fn regression_parent_module_retains_direct_changes_while_sub_module_owns_sub_changes() {
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "024", "ito-backend");
create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
create_change(&ito_path, "024-07_health-check");
create_change(&ito_path, "024.01-01_add-jwt");
let repo = ModuleRepository::new(&ito_path);
let modules = repo.list().unwrap();
assert_eq!(modules.len(), 1);
let m = &modules[0];
assert_eq!(m.id, "024");
assert_eq!(
m.change_count, 1,
"parent module should count only direct changes"
);
assert_eq!(m.sub_modules.len(), 1);
assert_eq!(m.sub_modules[0].id, "024.01");
assert_eq!(m.sub_modules[0].name, "auth");
assert_eq!(
m.sub_modules[0].change_count, 1,
"sub-module should count its own change"
);
let module = repo.get("024").unwrap();
assert_eq!(module.id, "024");
assert_eq!(module.sub_modules.len(), 1);
assert_eq!(module.sub_modules[0].id, "024.01");
assert_eq!(module.sub_modules[0].parent_module_id, "024");
assert_eq!(module.sub_modules[0].sub_id, "01");
assert_eq!(module.sub_modules[0].name, "auth");
assert_eq!(module.sub_modules[0].change_count, 1);
use ito_domain::modules::ModuleRepository as DomainModuleRepository;
let sub_summaries = DomainModuleRepository::list_sub_modules(&repo, "024").unwrap();
assert_eq!(sub_summaries.len(), 1);
assert_eq!(sub_summaries[0].id, "024.01");
let sub = DomainModuleRepository::get_sub_module(&repo, "024.01").unwrap();
assert_eq!(sub.id, "024.01");
assert_eq!(sub.parent_module_id, "024");
assert_eq!(sub.sub_id, "01");
assert_eq!(sub.name, "auth");
assert_eq!(sub.change_count, 1);
}
#[test]
fn regression_change_repository_populates_sub_module_id() {
use crate::change_repository::FsChangeRepository;
let tmp = TempDir::new().unwrap();
let ito_path = setup_test_ito(&tmp);
create_module(&ito_path, "024", "ito-backend");
create_sub_module(&ito_path, "024", "ito-backend", "01", "auth");
create_change(&ito_path, "024-07_health-check");
create_change(&ito_path, "024.01-01_add-jwt");
let change_repo = FsChangeRepository::new(&ito_path);
let summaries = change_repo.list().unwrap();
let direct = summaries
.iter()
.find(|s| s.id == "024-07_health-check")
.unwrap();
assert_eq!(direct.module_id.as_deref(), Some("024"));
assert_eq!(
direct.sub_module_id, None,
"direct change should have no sub_module_id"
);
let sub_change = summaries
.iter()
.find(|s| s.id == "024.01-01_add-jwt")
.unwrap();
assert_eq!(sub_change.module_id.as_deref(), Some("024"));
assert_eq!(
sub_change.sub_module_id.as_deref(),
Some("024.01"),
"sub-module change should have sub_module_id set"
);
}
}