ricecoder_specs/
manager.rs

1//! Spec manager for discovering and managing specifications
2
3use 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
10/// Central coordinator for spec operations and lifecycle management
11pub struct SpecManager {
12    /// Cache of loaded specs (path -> spec)
13    cache: HashMap<PathBuf, Spec>,
14}
15
16impl SpecManager {
17    /// Create a new spec manager
18    pub fn new() -> Self {
19        SpecManager {
20            cache: HashMap::new(),
21        }
22    }
23
24    /// Discover specs in a directory recursively
25    ///
26    /// Searches for YAML and Markdown spec files in the given directory and all subdirectories.
27    /// Supports nested spec discovery (project > feature > task hierarchy).
28    ///
29    /// # Arguments
30    /// * `path` - Root directory to search for specs
31    ///
32    /// # Returns
33    /// A vector of discovered specs, or an error if discovery fails
34    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    /// Recursively discover specs in a directory
46    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                // Recursively search subdirectories
63                self.discover_specs_recursive(&path, specs)?;
64            } else if path.is_file() {
65                // Check if file is a spec file (YAML or Markdown)
66                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                        // Try to load the spec
70                        if let Ok(spec) = self.load_spec(&path) {
71                            specs.push(spec);
72                        }
73                        // Silently skip files that can't be parsed as specs
74                    }
75                }
76            }
77        }
78
79        Ok(())
80    }
81
82    /// Load a spec from a file
83    ///
84    /// Automatically detects the format (YAML or Markdown) based on file extension.
85    /// Caches the loaded spec for future access.
86    ///
87    /// # Arguments
88    /// * `path` - Path to the spec file
89    ///
90    /// # Returns
91    /// The loaded spec, or an error if loading fails
92    pub fn load_spec(&mut self, path: &Path) -> Result<Spec, SpecError> {
93        // Check cache first
94        if let Some(spec) = self.cache.get(path) {
95            return Ok(spec.clone());
96        }
97
98        // Read file content
99        let content = fs::read_to_string(path).map_err(SpecError::IoError)?;
100
101        // Determine format and parse
102        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        // Cache the spec
121        self.cache.insert(path.to_path_buf(), spec.clone());
122
123        Ok(spec)
124    }
125
126    /// Save a spec to a file
127    ///
128    /// Automatically determines the format (YAML or Markdown) based on file extension.
129    /// Defaults to YAML if no extension is provided.
130    ///
131    /// # Arguments
132    /// * `spec` - The spec to save
133    /// * `path` - Path where the spec should be saved
134    ///
135    /// # Returns
136    /// Ok if successful, or an error if saving fails
137    pub fn save_spec(&mut self, spec: &Spec, path: &Path) -> Result<(), SpecError> {
138        // Determine format and serialize
139        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            // Default to YAML
153            YamlParser::serialize(spec)?
154        };
155
156        // Create parent directories if needed
157        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        // Write file
164        fs::write(path, content).map_err(SpecError::IoError)?;
165
166        // Update cache
167        self.cache.insert(path.to_path_buf(), spec.clone());
168
169        Ok(())
170    }
171
172    /// Invalidate cache for a specific spec
173    ///
174    /// # Arguments
175    /// * `path` - Path to the spec file to invalidate
176    pub fn invalidate_cache(&mut self, path: &Path) {
177        self.cache.remove(path);
178    }
179
180    /// Clear all cached specs
181    pub fn clear_cache(&mut self) {
182        self.cache.clear();
183    }
184
185    /// Get the number of cached specs
186    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        // Create a test spec
241        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        // Save the spec
259        manager.save_spec(&spec, &spec_path).unwrap();
260        assert!(spec_path.exists());
261
262        // Load the spec
263        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        // Load again - should use cache
361        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        // Create nested directory structure
398        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        // Save specs at different levels
423        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        // Discover all specs
434        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}