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::{Module, ModuleRepository as DomainModuleRepository, ModuleSummary};
10
11/// Filesystem-backed implementation of the domain `ModuleRepository` port.
12pub struct FsModuleRepository<'a, F: FileSystem = StdFs> {
13    ito_path: &'a Path,
14    fs: F,
15}
16
17impl<'a> FsModuleRepository<'a, StdFs> {
18    /// Create a filesystem-backed module repository using the standard filesystem.
19    pub fn new(ito_path: &'a Path) -> Self {
20        Self {
21            ito_path,
22            fs: StdFs,
23        }
24    }
25}
26
27impl<'a, F: FileSystem> FsModuleRepository<'a, F> {
28    /// Create a filesystem-backed module repository with a custom filesystem.
29    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
30        Self { ito_path, fs }
31    }
32
33    /// Get the path to the modules directory.
34    fn modules_dir(&self) -> PathBuf {
35        self.ito_path.join("modules")
36    }
37
38    /// Find the full module directory for a given module id or full name.
39    fn find_module_dir(&self, id_or_name: &str) -> Option<PathBuf> {
40        let modules_dir = self.modules_dir();
41        if !self.fs.is_dir(&modules_dir) {
42            return None;
43        }
44
45        let normalized_id = parse_module_id(id_or_name);
46        let prefix = format!("{normalized_id}_");
47
48        self.fs
49            .read_dir(&modules_dir)
50            .ok()?
51            .into_iter()
52            .find(|entry| {
53                entry
54                    .file_name()
55                    .and_then(|n| n.to_str())
56                    .map(|n| n.starts_with(&prefix))
57                    .unwrap_or(false)
58            })
59    }
60
61    fn load_module_description(&self, module_path: &Path) -> DomainResult<Option<String>> {
62        let yaml_path = module_path.join("module.yaml");
63        if !self.fs.is_file(&yaml_path) {
64            return Ok(None);
65        }
66
67        let content = self
68            .fs
69            .read_to_string(&yaml_path)
70            .map_err(|source| DomainError::io("reading module.yaml", source))?;
71
72        for line in content.lines() {
73            let line = line.trim();
74            if let Some(desc) = line.strip_prefix("description:") {
75                let desc = desc.trim().trim_matches('"').trim_matches('\'');
76                if !desc.is_empty() {
77                    return Ok(Some(desc.to_string()));
78                }
79            }
80        }
81
82        Ok(None)
83    }
84
85    fn count_changes_by_module(&self) -> DomainResult<HashMap<String, u32>> {
86        let mut counts = HashMap::new();
87        let changes_dir = self.ito_path.join("changes");
88        if !self.fs.is_dir(&changes_dir) {
89            return Ok(counts);
90        }
91
92        for path in self
93            .fs
94            .read_dir(&changes_dir)
95            .map_err(|source| DomainError::io("listing change directories", source))?
96        {
97            if !self.fs.is_dir(&path) {
98                continue;
99            }
100
101            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
102                continue;
103            };
104
105            if let Some(module_id) = extract_module_id(name) {
106                *counts.entry(module_id).or_insert(0) += 1;
107            }
108        }
109
110        Ok(counts)
111    }
112
113    /// Check if a module exists.
114    pub fn exists(&self, id: &str) -> bool {
115        DomainModuleRepository::exists(self, id)
116    }
117
118    /// Get a module by ID or full name.
119    pub fn get(&self, id_or_name: &str) -> DomainResult<Module> {
120        DomainModuleRepository::get(self, id_or_name)
121    }
122
123    /// List all modules.
124    pub fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
125        DomainModuleRepository::list(self)
126    }
127}
128
129impl<F: FileSystem> DomainModuleRepository for FsModuleRepository<'_, F> {
130    fn exists(&self, id: &str) -> bool {
131        self.find_module_dir(id).is_some()
132    }
133
134    fn get(&self, id_or_name: &str) -> DomainResult<Module> {
135        let path = self
136            .find_module_dir(id_or_name)
137            .ok_or_else(|| DomainError::not_found("module", id_or_name))?;
138
139        let id = parse_module_id(id_or_name);
140        let name = path
141            .file_name()
142            .and_then(|n| n.to_str())
143            .and_then(|n| n.strip_prefix(&format!("{id}_")))
144            .unwrap_or("unknown")
145            .to_string();
146
147        let description = self.load_module_description(&path)?;
148
149        Ok(Module {
150            id,
151            name,
152            description,
153            path,
154        })
155    }
156
157    fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
158        let modules_dir = self.modules_dir();
159        if !self.fs.is_dir(&modules_dir) {
160            return Ok(Vec::new());
161        }
162
163        let change_counts = self.count_changes_by_module()?;
164
165        let mut summaries = Vec::new();
166        for path in self
167            .fs
168            .read_dir(&modules_dir)
169            .map_err(|source| DomainError::io("listing module directories", source))?
170        {
171            if !self.fs.is_dir(&path) {
172                continue;
173            }
174
175            let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
176                continue;
177            };
178            let Some((id, name)) = dir_name.split_once('_') else {
179                continue;
180            };
181
182            summaries.push(ModuleSummary {
183                id: id.to_string(),
184                name: name.to_string(),
185                change_count: change_counts.get(id).copied().unwrap_or(0),
186            });
187        }
188
189        summaries.sort_by(|a, b| a.id.cmp(&b.id));
190        Ok(summaries)
191    }
192}
193
194/// Backward-compatible alias for the default filesystem-backed module repository.
195pub type ModuleRepository<'a> = FsModuleRepository<'a, StdFs>;
196
197#[cfg(test)]
198mod tests {
199    use std::fs;
200    use std::path::Path;
201
202    use tempfile::TempDir;
203
204    use super::{FsModuleRepository, ModuleRepository};
205
206    fn setup_test_ito(tmp: &TempDir) -> std::path::PathBuf {
207        let ito_path = tmp.path().join(".ito");
208        fs::create_dir_all(ito_path.join("modules")).unwrap();
209        fs::create_dir_all(ito_path.join("changes")).unwrap();
210        ito_path
211    }
212
213    fn create_module(ito_path: &Path, id: &str, name: &str) {
214        let module_dir = ito_path.join("modules").join(format!("{}_{}", id, name));
215        fs::create_dir_all(&module_dir).unwrap();
216    }
217
218    fn create_change(ito_path: &Path, id: &str) {
219        let change_dir = ito_path.join("changes").join(id);
220        fs::create_dir_all(&change_dir).unwrap();
221    }
222
223    #[test]
224    fn test_exists() {
225        let tmp = TempDir::new().unwrap();
226        let ito_path = setup_test_ito(&tmp);
227        create_module(&ito_path, "005", "dev-tooling");
228
229        let repo = ModuleRepository::new(&ito_path);
230        assert!(repo.exists("005"));
231        assert!(!repo.exists("999"));
232    }
233
234    #[test]
235    fn test_get() {
236        let tmp = TempDir::new().unwrap();
237        let ito_path = setup_test_ito(&tmp);
238        create_module(&ito_path, "005", "dev-tooling");
239
240        let repo = ModuleRepository::new(&ito_path);
241        let module = repo.get("005").unwrap();
242
243        assert_eq!(module.id, "005");
244        assert_eq!(module.name, "dev-tooling");
245    }
246
247    #[test]
248    fn test_get_not_found() {
249        let tmp = TempDir::new().unwrap();
250        let ito_path = setup_test_ito(&tmp);
251
252        let repo = ModuleRepository::new(&ito_path);
253        let result = repo.get("999");
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn test_list() {
259        let tmp = TempDir::new().unwrap();
260        let ito_path = setup_test_ito(&tmp);
261        create_module(&ito_path, "005", "dev-tooling");
262        create_module(&ito_path, "003", "qa-testing");
263        create_module(&ito_path, "001", "workflow");
264
265        let repo = ModuleRepository::new(&ito_path);
266        let modules = repo.list().unwrap();
267
268        assert_eq!(modules.len(), 3);
269        assert_eq!(modules[0].id, "001");
270        assert_eq!(modules[1].id, "003");
271        assert_eq!(modules[2].id, "005");
272    }
273
274    #[test]
275    fn test_list_with_change_counts() {
276        let tmp = TempDir::new().unwrap();
277        let ito_path = setup_test_ito(&tmp);
278        create_module(&ito_path, "005", "dev-tooling");
279        create_module(&ito_path, "003", "qa-testing");
280
281        create_change(&ito_path, "005-01_first");
282        create_change(&ito_path, "005-02_second");
283        create_change(&ito_path, "003-01_test");
284
285        let repo = ModuleRepository::new(&ito_path);
286        let modules = repo.list().unwrap();
287
288        let module_005 = modules.iter().find(|m| m.id == "005").unwrap();
289        let module_003 = modules.iter().find(|m| m.id == "003").unwrap();
290
291        assert_eq!(module_005.change_count, 2);
292        assert_eq!(module_003.change_count, 1);
293    }
294
295    #[test]
296    fn test_get_uses_full_name_input() {
297        let tmp = TempDir::new().unwrap();
298        let ito_path = setup_test_ito(&tmp);
299        create_module(&ito_path, "005", "dev-tooling");
300
301        let repo = FsModuleRepository::new(&ito_path);
302        let module = repo.get("005_dev-tooling").unwrap();
303        assert_eq!(module.id, "005");
304        assert_eq!(module.name, "dev-tooling");
305    }
306}