ito_core/
module_repository.rs1use 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
11pub struct FsModuleRepository<'a, F: FileSystem = StdFs> {
13 ito_path: &'a Path,
14 fs: F,
15}
16
17impl<'a> FsModuleRepository<'a, StdFs> {
18 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 pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
30 Self { ito_path, fs }
31 }
32
33 fn modules_dir(&self) -> PathBuf {
35 self.ito_path.join("modules")
36 }
37
38 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 pub fn exists(&self, id: &str) -> bool {
115 DomainModuleRepository::exists(self, id)
116 }
117
118 pub fn get(&self, id_or_name: &str) -> DomainResult<Module> {
120 DomainModuleRepository::get(self, id_or_name)
121 }
122
123 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
194pub 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}