1use crate::error::SpecError;
4use crate::models::Spec;
5use crate::parsers::{MarkdownParser, YamlParser};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub struct SpecManager {
12 cache: HashMap<PathBuf, Spec>,
14}
15
16impl SpecManager {
17 pub fn new() -> Self {
19 SpecManager {
20 cache: HashMap::new(),
21 }
22 }
23
24 pub fn discover_specs(&mut self, path: &Path) -> Result<Vec<Spec>, SpecError> {
35 let mut specs = Vec::new();
36
37 if !path.exists() {
38 return Ok(specs);
39 }
40
41 self.discover_specs_recursive(path, &mut specs)?;
42 Ok(specs)
43 }
44
45 fn discover_specs_recursive(
47 &mut self,
48 path: &Path,
49 specs: &mut Vec<Spec>,
50 ) -> Result<(), SpecError> {
51 if !path.is_dir() {
52 return Ok(());
53 }
54
55 let entries = fs::read_dir(path).map_err(SpecError::IoError)?;
56
57 for entry in entries {
58 let entry = entry.map_err(SpecError::IoError)?;
59 let path = entry.path();
60
61 if path.is_dir() {
62 self.discover_specs_recursive(&path, specs)?;
64 } else if path.is_file() {
65 if let Some(ext) = path.extension() {
67 let ext_str = ext.to_string_lossy().to_lowercase();
68 if ext_str == "yaml" || ext_str == "yml" || ext_str == "md" {
69 if let Ok(spec) = self.load_spec(&path) {
71 specs.push(spec);
72 }
73 }
75 }
76 }
77 }
78
79 Ok(())
80 }
81
82 pub fn load_spec(&mut self, path: &Path) -> Result<Spec, SpecError> {
93 if let Some(spec) = self.cache.get(path) {
95 return Ok(spec.clone());
96 }
97
98 let content = fs::read_to_string(path).map_err(SpecError::IoError)?;
100
101 let spec = if let Some(ext) = path.extension() {
103 let ext_str = ext.to_string_lossy().to_lowercase();
104 match ext_str.as_str() {
105 "yaml" | "yml" => YamlParser::parse(&content)?,
106 "md" => MarkdownParser::parse(&content)?,
107 _ => {
108 return Err(SpecError::InvalidFormat(format!(
109 "Unsupported file format: {}",
110 ext_str
111 )))
112 }
113 }
114 } else {
115 return Err(SpecError::InvalidFormat(
116 "File has no extension".to_string(),
117 ));
118 };
119
120 self.cache.insert(path.to_path_buf(), spec.clone());
122
123 Ok(spec)
124 }
125
126 pub fn save_spec(&mut self, spec: &Spec, path: &Path) -> Result<(), SpecError> {
138 let content = if let Some(ext) = path.extension() {
140 let ext_str = ext.to_string_lossy().to_lowercase();
141 match ext_str.as_str() {
142 "yaml" | "yml" => YamlParser::serialize(spec)?,
143 "md" => MarkdownParser::serialize(spec)?,
144 _ => {
145 return Err(SpecError::InvalidFormat(format!(
146 "Unsupported file format: {}",
147 ext_str
148 )))
149 }
150 }
151 } else {
152 YamlParser::serialize(spec)?
154 };
155
156 if let Some(parent) = path.parent() {
158 if !parent.as_os_str().is_empty() {
159 fs::create_dir_all(parent).map_err(SpecError::IoError)?;
160 }
161 }
162
163 fs::write(path, content).map_err(SpecError::IoError)?;
165
166 self.cache.insert(path.to_path_buf(), spec.clone());
168
169 Ok(())
170 }
171
172 pub fn invalidate_cache(&mut self, path: &Path) {
177 self.cache.remove(path);
178 }
179
180 pub fn clear_cache(&mut self) {
182 self.cache.clear();
183 }
184
185 pub fn cache_size(&self) -> usize {
187 self.cache.len()
188 }
189}
190
191impl Default for SpecManager {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::fs;
201 use tempfile::TempDir;
202
203 #[test]
204 fn test_spec_manager_creation() {
205 let manager = SpecManager::new();
206 assert_eq!(manager.cache_size(), 0);
207 }
208
209 #[test]
210 fn test_spec_manager_default() {
211 let manager = SpecManager::default();
212 assert_eq!(manager.cache_size(), 0);
213 }
214
215 #[test]
216 fn test_discover_specs_empty_directory() {
217 let temp_dir = TempDir::new().unwrap();
218 let mut manager = SpecManager::new();
219
220 let specs = manager.discover_specs(temp_dir.path()).unwrap();
221 assert_eq!(specs.len(), 0);
222 }
223
224 #[test]
225 fn test_discover_specs_nonexistent_directory() {
226 let mut manager = SpecManager::new();
227 let nonexistent = Path::new("/nonexistent/path/that/does/not/exist");
228
229 let specs = manager.discover_specs(nonexistent).unwrap();
230 assert_eq!(specs.len(), 0);
231 }
232
233 #[test]
234 fn test_save_and_load_yaml_spec() {
235 let temp_dir = TempDir::new().unwrap();
236 let spec_path = temp_dir.path().join("test.yaml");
237
238 let mut manager = SpecManager::new();
239
240 let spec = Spec {
242 id: "test-spec".to_string(),
243 name: "Test Spec".to_string(),
244 version: "1.0.0".to_string(),
245 requirements: vec![],
246 design: None,
247 tasks: vec![],
248 metadata: crate::models::SpecMetadata {
249 author: Some("Test Author".to_string()),
250 created_at: chrono::Utc::now(),
251 updated_at: chrono::Utc::now(),
252 phase: crate::models::SpecPhase::Requirements,
253 status: crate::models::SpecStatus::Draft,
254 },
255 inheritance: None,
256 };
257
258 manager.save_spec(&spec, &spec_path).unwrap();
260 assert!(spec_path.exists());
261
262 let loaded_spec = manager.load_spec(&spec_path).unwrap();
264 assert_eq!(loaded_spec.id, spec.id);
265 assert_eq!(loaded_spec.name, spec.name);
266 assert_eq!(loaded_spec.version, spec.version);
267 }
268
269 #[test]
270 fn test_cache_invalidation() {
271 let temp_dir = TempDir::new().unwrap();
272 let spec_path = temp_dir.path().join("test.yaml");
273
274 let mut manager = SpecManager::new();
275
276 let spec = Spec {
277 id: "test-spec".to_string(),
278 name: "Test Spec".to_string(),
279 version: "1.0.0".to_string(),
280 requirements: vec![],
281 design: None,
282 tasks: vec![],
283 metadata: crate::models::SpecMetadata {
284 author: None,
285 created_at: chrono::Utc::now(),
286 updated_at: chrono::Utc::now(),
287 phase: crate::models::SpecPhase::Discovery,
288 status: crate::models::SpecStatus::Draft,
289 },
290 inheritance: None,
291 };
292
293 manager.save_spec(&spec, &spec_path).unwrap();
294 assert_eq!(manager.cache_size(), 1);
295
296 manager.invalidate_cache(&spec_path);
297 assert_eq!(manager.cache_size(), 0);
298 }
299
300 #[test]
301 fn test_clear_cache() {
302 let temp_dir = TempDir::new().unwrap();
303 let spec_path1 = temp_dir.path().join("test1.yaml");
304 let spec_path2 = temp_dir.path().join("test2.yaml");
305
306 let mut manager = SpecManager::new();
307
308 let spec = Spec {
309 id: "test-spec".to_string(),
310 name: "Test Spec".to_string(),
311 version: "1.0.0".to_string(),
312 requirements: vec![],
313 design: None,
314 tasks: vec![],
315 metadata: crate::models::SpecMetadata {
316 author: None,
317 created_at: chrono::Utc::now(),
318 updated_at: chrono::Utc::now(),
319 phase: crate::models::SpecPhase::Discovery,
320 status: crate::models::SpecStatus::Draft,
321 },
322 inheritance: None,
323 };
324
325 manager.save_spec(&spec, &spec_path1).unwrap();
326 manager.save_spec(&spec, &spec_path2).unwrap();
327 assert_eq!(manager.cache_size(), 2);
328
329 manager.clear_cache();
330 assert_eq!(manager.cache_size(), 0);
331 }
332
333 #[test]
334 fn test_load_spec_caching() {
335 let temp_dir = TempDir::new().unwrap();
336 let spec_path = temp_dir.path().join("test.yaml");
337
338 let mut manager = SpecManager::new();
339
340 let spec = Spec {
341 id: "test-spec".to_string(),
342 name: "Test Spec".to_string(),
343 version: "1.0.0".to_string(),
344 requirements: vec![],
345 design: None,
346 tasks: vec![],
347 metadata: crate::models::SpecMetadata {
348 author: None,
349 created_at: chrono::Utc::now(),
350 updated_at: chrono::Utc::now(),
351 phase: crate::models::SpecPhase::Discovery,
352 status: crate::models::SpecStatus::Draft,
353 },
354 inheritance: None,
355 };
356
357 manager.save_spec(&spec, &spec_path).unwrap();
358 assert_eq!(manager.cache_size(), 1);
359
360 let _loaded = manager.load_spec(&spec_path).unwrap();
362 assert_eq!(manager.cache_size(), 1);
363 }
364
365 #[test]
366 fn test_save_creates_parent_directories() {
367 let temp_dir = TempDir::new().unwrap();
368 let spec_path = temp_dir.path().join("nested/deep/path/test.yaml");
369
370 let mut manager = SpecManager::new();
371
372 let spec = Spec {
373 id: "test-spec".to_string(),
374 name: "Test Spec".to_string(),
375 version: "1.0.0".to_string(),
376 requirements: vec![],
377 design: None,
378 tasks: vec![],
379 metadata: crate::models::SpecMetadata {
380 author: None,
381 created_at: chrono::Utc::now(),
382 updated_at: chrono::Utc::now(),
383 phase: crate::models::SpecPhase::Discovery,
384 status: crate::models::SpecStatus::Draft,
385 },
386 inheritance: None,
387 };
388
389 manager.save_spec(&spec, &spec_path).unwrap();
390 assert!(spec_path.exists());
391 }
392
393 #[test]
394 fn test_discover_specs_recursive() {
395 let temp_dir = TempDir::new().unwrap();
396
397 let feature_dir = temp_dir.path().join("feature1");
399 fs::create_dir(&feature_dir).unwrap();
400 let task_dir = feature_dir.join("task1");
401 fs::create_dir(&task_dir).unwrap();
402
403 let mut manager = SpecManager::new();
404
405 let spec = Spec {
406 id: "test-spec".to_string(),
407 name: "Test Spec".to_string(),
408 version: "1.0.0".to_string(),
409 requirements: vec![],
410 design: None,
411 tasks: vec![],
412 metadata: crate::models::SpecMetadata {
413 author: None,
414 created_at: chrono::Utc::now(),
415 updated_at: chrono::Utc::now(),
416 phase: crate::models::SpecPhase::Discovery,
417 status: crate::models::SpecStatus::Draft,
418 },
419 inheritance: None,
420 };
421
422 manager
424 .save_spec(&spec, &temp_dir.path().join("project.yaml"))
425 .unwrap();
426 manager
427 .save_spec(&spec, &feature_dir.join("feature.yaml"))
428 .unwrap();
429 manager
430 .save_spec(&spec, &task_dir.join("task.yaml"))
431 .unwrap();
432
433 let discovered = manager.discover_specs(temp_dir.path()).unwrap();
435 assert_eq!(discovered.len(), 3);
436 }
437
438 #[test]
439 fn test_unsupported_file_format() {
440 let temp_dir = TempDir::new().unwrap();
441 let spec_path = temp_dir.path().join("test.txt");
442
443 let mut manager = SpecManager::new();
444
445 let spec = Spec {
446 id: "test-spec".to_string(),
447 name: "Test Spec".to_string(),
448 version: "1.0.0".to_string(),
449 requirements: vec![],
450 design: None,
451 tasks: vec![],
452 metadata: crate::models::SpecMetadata {
453 author: None,
454 created_at: chrono::Utc::now(),
455 updated_at: chrono::Utc::now(),
456 phase: crate::models::SpecPhase::Discovery,
457 status: crate::models::SpecStatus::Draft,
458 },
459 inheritance: None,
460 };
461
462 let result = manager.save_spec(&spec, &spec_path);
463 assert!(result.is_err());
464 }
465}