ricecoder_generation/templates/
discovery.rs

1//! Template and boilerplate discovery and metadata parsing
2//!
3//! Scans template and boilerplate directories, parses metadata, and returns available resources.
4
5use crate::models::{
6    Boilerplate, BoilerplateDiscoveryResult, BoilerplateMetadata, BoilerplateSource, Template,
7    TemplateMetadata,
8};
9use crate::templates::error::{BoilerplateError, TemplateError};
10use crate::templates::loader::TemplateLoader;
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// Discovers available templates in project and global scopes
16pub struct TemplateDiscovery {
17    loader: TemplateLoader,
18}
19
20impl TemplateDiscovery {
21    /// Create a new template discovery service
22    pub fn new() -> Self {
23        Self {
24            loader: TemplateLoader::new(),
25        }
26    }
27
28    /// Discover all available templates
29    ///
30    /// Searches in both project and global scopes, with project templates
31    /// taking precedence over global templates with the same name.
32    ///
33    /// # Arguments
34    /// * `project_root` - Root directory of the project
35    ///
36    /// # Returns
37    /// Vector of discovered templates with metadata
38    pub fn discover(&mut self, project_root: &Path) -> Result<DiscoveryResult, TemplateError> {
39        let mut search_paths = Vec::new();
40        let mut templates = Vec::new();
41
42        // Search global templates
43        let global_dir = self.get_global_templates_dir();
44        if global_dir.exists() {
45            search_paths.push(global_dir.clone());
46            let global_templates = self.loader.load_global_templates()?;
47            templates.extend(global_templates);
48        }
49
50        // Search project templates (override global ones)
51        let project_dir = project_root.join(".ricecoder").join("templates");
52        if project_dir.exists() {
53            search_paths.push(project_dir.clone());
54            let project_templates = self.loader.load_project_templates(project_root)?;
55
56            // Create a map for deduplication
57            let mut template_map: std::collections::HashMap<String, Template> =
58                templates.into_iter().map(|t| (t.id.clone(), t)).collect();
59
60            // Override with project templates
61            for template in project_templates {
62                template_map.insert(template.id.clone(), template);
63            }
64
65            templates = template_map.into_values().collect();
66        }
67
68        Ok(DiscoveryResult {
69            templates,
70            search_paths,
71        })
72    }
73
74    /// Discover templates by language
75    ///
76    /// # Arguments
77    /// * `project_root` - Root directory of the project
78    /// * `language` - Programming language to filter by (e.g., "rs", "ts", "py")
79    ///
80    /// # Returns
81    /// Vector of templates for the specified language
82    pub fn discover_by_language(
83        &mut self,
84        project_root: &Path,
85        language: &str,
86    ) -> Result<Vec<Template>, TemplateError> {
87        let result = self.discover(project_root)?;
88        Ok(result
89            .templates
90            .into_iter()
91            .filter(|t| t.language == language)
92            .collect())
93    }
94
95    /// Discover templates by name pattern
96    ///
97    /// # Arguments
98    /// * `project_root` - Root directory of the project
99    /// * `pattern` - Name pattern to match (case-insensitive substring match)
100    ///
101    /// # Returns
102    /// Vector of templates matching the pattern
103    pub fn discover_by_name(
104        &mut self,
105        project_root: &Path,
106        pattern: &str,
107    ) -> Result<Vec<Template>, TemplateError> {
108        let result = self.discover(project_root)?;
109        let pattern_lower = pattern.to_lowercase();
110        Ok(result
111            .templates
112            .into_iter()
113            .filter(|t| t.name.to_lowercase().contains(&pattern_lower))
114            .collect())
115    }
116
117    /// Parse template metadata from file
118    ///
119    /// Looks for metadata in template comments or a separate metadata file.
120    ///
121    /// # Arguments
122    /// * `template_path` - Path to the template file
123    ///
124    /// # Returns
125    /// Template metadata or error
126    pub fn parse_metadata(&self, template_path: &Path) -> Result<TemplateMetadata, TemplateError> {
127        let content = fs::read_to_string(template_path).map_err(TemplateError::IoError)?;
128
129        // Extract metadata from template comments
130        let mut metadata = TemplateMetadata {
131            description: None,
132            version: None,
133            author: None,
134        };
135
136        // Parse first few lines for metadata comments
137        for line in content.lines().take(10) {
138            if line.contains("@description") {
139                if let Some(desc) = line.split("@description").nth(1) {
140                    metadata.description = Some(desc.trim().to_string());
141                }
142            } else if line.contains("@version") {
143                if let Some(ver) = line.split("@version").nth(1) {
144                    metadata.version = Some(ver.trim().to_string());
145                }
146            } else if line.contains("@author") {
147                if let Some(auth) = line.split("@author").nth(1) {
148                    metadata.author = Some(auth.trim().to_string());
149                }
150            }
151        }
152
153        Ok(metadata)
154    }
155
156    /// Validate template structure
157    ///
158    /// Checks that the template file exists and is readable.
159    ///
160    /// # Arguments
161    /// * `template_path` - Path to the template file
162    ///
163    /// # Returns
164    /// Ok if valid, error otherwise
165    pub fn validate_template(&self, template_path: &Path) -> Result<(), TemplateError> {
166        if !template_path.exists() {
167            return Err(TemplateError::NotFound(format!(
168                "Template not found: {}",
169                template_path.display()
170            )));
171        }
172
173        if !template_path.is_file() {
174            return Err(TemplateError::ValidationFailed(format!(
175                "Template is not a file: {}",
176                template_path.display()
177            )));
178        }
179
180        // Try to read the file
181        fs::read_to_string(template_path).map_err(TemplateError::IoError)?;
182
183        Ok(())
184    }
185
186    /// Get the global templates directory path
187    fn get_global_templates_dir(&self) -> PathBuf {
188        if let Ok(home) = std::env::var("HOME") {
189            PathBuf::from(home).join(".ricecoder").join("templates")
190        } else if let Ok(home) = std::env::var("USERPROFILE") {
191            // Windows
192            PathBuf::from(home).join(".ricecoder").join("templates")
193        } else {
194            PathBuf::from(".ricecoder/templates")
195        }
196    }
197}
198
199impl Default for TemplateDiscovery {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205/// Result of template discovery
206#[derive(Debug, Clone)]
207pub struct DiscoveryResult {
208    /// Discovered templates
209    pub templates: Vec<Template>,
210    /// Paths that were searched
211    pub search_paths: Vec<PathBuf>,
212}
213
214/// Discovers available boilerplates in project and global scopes
215///
216/// Implements precedence: project scope > global scope
217/// When a boilerplate exists in both locations, the project-scoped version is used.
218pub struct BoilerplateDiscovery;
219
220impl BoilerplateDiscovery {
221    /// Discover all available boilerplates
222    ///
223    /// Searches in both project and global scopes, with project boilerplates
224    /// taking precedence over global boilerplates with the same name.
225    ///
226    /// # Arguments
227    /// * `project_root` - Root directory of the project
228    ///
229    /// # Returns
230    /// Result containing discovered boilerplates and search paths
231    ///
232    /// # Requirements
233    /// - Requirement 4.1: Search `~/.ricecoder/boilerplates`
234    /// - Requirement 4.2: Search `./.ricecoder/boilerplates`
235    /// - Requirement 4.3: Apply precedence (project > global)
236    pub fn discover(project_root: &Path) -> Result<BoilerplateDiscoveryResult, BoilerplateError> {
237        let mut search_paths = Vec::new();
238        let mut boilerplate_map: HashMap<String, BoilerplateMetadata> = HashMap::new();
239
240        // Search global boilerplates first
241        let global_dir = Self::get_global_boilerplates_dir();
242        if global_dir.exists() {
243            search_paths.push(global_dir.clone());
244            let global_boilerplates = Self::scan_boilerplate_directory(&global_dir, true)?;
245            for bp in global_boilerplates {
246                boilerplate_map.insert(bp.id.clone(), bp);
247            }
248        }
249
250        // Search project boilerplates (override global ones with same name)
251        let project_dir = project_root.join(".ricecoder").join("boilerplates");
252        if project_dir.exists() {
253            search_paths.push(project_dir.clone());
254            let project_boilerplates = Self::scan_boilerplate_directory(&project_dir, false)?;
255            for bp in project_boilerplates {
256                boilerplate_map.insert(bp.id.clone(), bp);
257            }
258        }
259
260        let boilerplates: Vec<BoilerplateMetadata> = boilerplate_map.into_values().collect();
261
262        Ok(BoilerplateDiscoveryResult {
263            boilerplates,
264            search_paths,
265        })
266    }
267
268    /// Discover boilerplates by language
269    ///
270    /// # Arguments
271    /// * `project_root` - Root directory of the project
272    /// * `language` - Programming language to filter by (e.g., "rust", "typescript")
273    ///
274    /// # Returns
275    /// Vector of boilerplates for the specified language
276    pub fn discover_by_language(
277        project_root: &Path,
278        language: &str,
279    ) -> Result<Vec<BoilerplateMetadata>, BoilerplateError> {
280        let result = Self::discover(project_root)?;
281        Ok(result
282            .boilerplates
283            .into_iter()
284            .filter(|bp| bp.language.to_lowercase() == language.to_lowercase())
285            .collect())
286    }
287
288    /// Discover boilerplates by name pattern
289    ///
290    /// # Arguments
291    /// * `project_root` - Root directory of the project
292    /// * `pattern` - Name pattern to match (case-insensitive substring match)
293    ///
294    /// # Returns
295    /// Vector of boilerplates matching the pattern
296    pub fn discover_by_name(
297        project_root: &Path,
298        pattern: &str,
299    ) -> Result<Vec<BoilerplateMetadata>, BoilerplateError> {
300        let result = Self::discover(project_root)?;
301        let pattern_lower = pattern.to_lowercase();
302        Ok(result
303            .boilerplates
304            .into_iter()
305            .filter(|bp| bp.name.to_lowercase().contains(&pattern_lower))
306            .collect())
307    }
308
309    /// Validate boilerplate structure
310    ///
311    /// Checks that the boilerplate directory contains required files and structure.
312    ///
313    /// # Arguments
314    /// * `boilerplate_path` - Path to the boilerplate directory
315    ///
316    /// # Returns
317    /// Ok if valid, error otherwise
318    pub fn validate_boilerplate(boilerplate_path: &Path) -> Result<(), BoilerplateError> {
319        if !boilerplate_path.exists() {
320            return Err(BoilerplateError::NotFound(format!(
321                "Boilerplate not found: {}",
322                boilerplate_path.display()
323            )));
324        }
325
326        if !boilerplate_path.is_dir() {
327            return Err(BoilerplateError::InvalidStructure(format!(
328                "Boilerplate is not a directory: {}",
329                boilerplate_path.display()
330            )));
331        }
332
333        // Check for boilerplate.yaml or boilerplate.json metadata file
334        let yaml_path = boilerplate_path.join("boilerplate.yaml");
335        let json_path = boilerplate_path.join("boilerplate.json");
336
337        if !yaml_path.exists() && !json_path.exists() {
338            return Err(BoilerplateError::InvalidStructure(format!(
339                "Boilerplate missing metadata file (boilerplate.yaml or boilerplate.json): {}",
340                boilerplate_path.display()
341            )));
342        }
343
344        Ok(())
345    }
346
347    /// Parse boilerplate metadata from directory
348    ///
349    /// # Arguments
350    /// * `boilerplate_path` - Path to the boilerplate directory
351    ///
352    /// # Returns
353    /// Boilerplate metadata or error
354    pub fn parse_metadata(boilerplate_path: &Path) -> Result<Boilerplate, BoilerplateError> {
355        Self::validate_boilerplate(boilerplate_path)?;
356
357        // Try to read boilerplate.yaml first, then boilerplate.json
358        let yaml_path = boilerplate_path.join("boilerplate.yaml");
359        let json_path = boilerplate_path.join("boilerplate.json");
360
361        if yaml_path.exists() {
362            let content = fs::read_to_string(&yaml_path).map_err(BoilerplateError::IoError)?;
363            serde_yaml::from_str(&content)
364                .map_err(|e| BoilerplateError::InvalidStructure(format!("Invalid YAML: {}", e)))
365        } else if json_path.exists() {
366            let content = fs::read_to_string(&json_path).map_err(BoilerplateError::IoError)?;
367            serde_json::from_str(&content)
368                .map_err(|e| BoilerplateError::InvalidStructure(format!("Invalid JSON: {}", e)))
369        } else {
370            Err(BoilerplateError::InvalidStructure(
371                "No boilerplate metadata file found".to_string(),
372            ))
373        }
374    }
375
376    /// Scan a boilerplate directory and return metadata for all boilerplates
377    ///
378    /// # Arguments
379    /// * `directory` - Directory to scan
380    /// * `is_global` - Whether this is a global directory
381    ///
382    /// # Returns
383    /// Vector of boilerplate metadata
384    fn scan_boilerplate_directory(
385        directory: &Path,
386        is_global: bool,
387    ) -> Result<Vec<BoilerplateMetadata>, BoilerplateError> {
388        let mut boilerplates = Vec::new();
389
390        if !directory.exists() {
391            return Ok(boilerplates);
392        }
393
394        for entry in fs::read_dir(directory).map_err(BoilerplateError::IoError)? {
395            let entry = entry.map_err(BoilerplateError::IoError)?;
396            let path = entry.path();
397
398            if path.is_dir() {
399                // Try to parse boilerplate metadata
400                if let Ok(boilerplate) = Self::parse_metadata(&path) {
401                    let source = if is_global {
402                        BoilerplateSource::Global(path.clone())
403                    } else {
404                        BoilerplateSource::Project(path.clone())
405                    };
406
407                    let metadata = BoilerplateMetadata {
408                        id: boilerplate.id,
409                        name: boilerplate.name,
410                        description: boilerplate.description,
411                        language: boilerplate.language,
412                        source,
413                    };
414
415                    boilerplates.push(metadata);
416                }
417            }
418        }
419
420        Ok(boilerplates)
421    }
422
423    /// Get the global boilerplates directory path
424    fn get_global_boilerplates_dir() -> PathBuf {
425        if let Ok(home) = std::env::var("HOME") {
426            PathBuf::from(home).join(".ricecoder").join("boilerplates")
427        } else if let Ok(home) = std::env::var("USERPROFILE") {
428            // Windows
429            PathBuf::from(home).join(".ricecoder").join("boilerplates")
430        } else {
431            PathBuf::from(".ricecoder/boilerplates")
432        }
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use std::fs;
440    use tempfile::TempDir;
441
442    #[test]
443    fn test_discover_templates() {
444        let temp_dir = TempDir::new().unwrap();
445        let templates_dir = temp_dir.path().join(".ricecoder").join("templates");
446        fs::create_dir_all(&templates_dir).unwrap();
447
448        fs::write(
449            templates_dir.join("struct.rs.tmpl"),
450            "pub struct {{Name}} {}",
451        )
452        .unwrap();
453        fs::write(templates_dir.join("impl.rs.tmpl"), "impl {{Name}} {}").unwrap();
454
455        let mut discovery = TemplateDiscovery::new();
456        let result = discovery.discover(temp_dir.path()).unwrap();
457
458        assert_eq!(result.templates.len(), 2);
459        assert!(result.search_paths.iter().any(|p| p.ends_with("templates")));
460    }
461
462    #[test]
463    fn test_discover_by_language() {
464        let temp_dir = TempDir::new().unwrap();
465        let templates_dir = temp_dir.path().join(".ricecoder").join("templates");
466        fs::create_dir_all(&templates_dir).unwrap();
467
468        fs::write(
469            templates_dir.join("struct.rs.tmpl"),
470            "pub struct {{Name}} {}",
471        )
472        .unwrap();
473        fs::write(
474            templates_dir.join("component.ts.tmpl"),
475            "export const {{Name}} = () => {}",
476        )
477        .unwrap();
478
479        let mut discovery = TemplateDiscovery::new();
480        let rust_templates = discovery
481            .discover_by_language(temp_dir.path(), "rs")
482            .unwrap();
483
484        assert_eq!(rust_templates.len(), 1);
485        assert_eq!(rust_templates[0].language, "rs");
486    }
487
488    #[test]
489    fn test_discover_by_name() {
490        let temp_dir = TempDir::new().unwrap();
491        let templates_dir = temp_dir.path().join(".ricecoder").join("templates");
492        fs::create_dir_all(&templates_dir).unwrap();
493
494        fs::write(
495            templates_dir.join("struct.rs.tmpl"),
496            "pub struct {{Name}} {}",
497        )
498        .unwrap();
499        fs::write(templates_dir.join("impl.rs.tmpl"), "impl {{Name}} {}").unwrap();
500
501        let mut discovery = TemplateDiscovery::new();
502        let struct_templates = discovery
503            .discover_by_name(temp_dir.path(), "struct")
504            .unwrap();
505
506        assert_eq!(struct_templates.len(), 1);
507        assert_eq!(struct_templates[0].id, "struct");
508    }
509
510    #[test]
511    fn test_validate_template() {
512        let temp_dir = TempDir::new().unwrap();
513        let template_path = temp_dir.path().join("test.rs.tmpl");
514        fs::write(&template_path, "pub struct {{Name}} {}").unwrap();
515
516        let discovery = TemplateDiscovery::new();
517        assert!(discovery.validate_template(&template_path).is_ok());
518    }
519
520    #[test]
521    fn test_validate_nonexistent_template() {
522        let discovery = TemplateDiscovery::new();
523        let result = discovery.validate_template(Path::new("/nonexistent/template.rs.tmpl"));
524
525        assert!(result.is_err());
526    }
527
528    #[test]
529    fn test_parse_metadata() {
530        let temp_dir = TempDir::new().unwrap();
531        let template_path = temp_dir.path().join("test.rs.tmpl");
532
533        let content = "// @description A test template\n// @version 1.0.0\n// @author Test Author\npub struct {{Name}} {}";
534        fs::write(&template_path, content).unwrap();
535
536        let discovery = TemplateDiscovery::new();
537        let metadata = discovery.parse_metadata(&template_path).unwrap();
538
539        assert!(metadata.description.is_some());
540        assert!(metadata.version.is_some());
541        assert!(metadata.author.is_some());
542    }
543
544    // Boilerplate discovery tests
545
546    #[test]
547    fn test_validate_boilerplate_missing_metadata() {
548        let temp_dir = TempDir::new().unwrap();
549        let bp_dir = temp_dir.path().join("test-bp");
550        fs::create_dir_all(&bp_dir).unwrap();
551
552        let result = BoilerplateDiscovery::validate_boilerplate(&bp_dir);
553        assert!(result.is_err());
554    }
555
556    #[test]
557    fn test_validate_boilerplate_with_yaml() {
558        let temp_dir = TempDir::new().unwrap();
559        let bp_dir = temp_dir.path().join("test-bp");
560        fs::create_dir_all(&bp_dir).unwrap();
561        fs::write(bp_dir.join("boilerplate.yaml"), "id: test\nname: Test").unwrap();
562
563        let result = BoilerplateDiscovery::validate_boilerplate(&bp_dir);
564        assert!(result.is_ok());
565    }
566
567    #[test]
568    fn test_validate_boilerplate_with_json() {
569        let temp_dir = TempDir::new().unwrap();
570        let bp_dir = temp_dir.path().join("test-bp");
571        fs::create_dir_all(&bp_dir).unwrap();
572        fs::write(
573            bp_dir.join("boilerplate.json"),
574            r#"{"id":"test","name":"Test"}"#,
575        )
576        .unwrap();
577
578        let result = BoilerplateDiscovery::validate_boilerplate(&bp_dir);
579        assert!(result.is_ok());
580    }
581
582    #[test]
583    fn test_discover_boilerplate_precedence() {
584        let temp_dir = TempDir::new().unwrap();
585        let project_dir = temp_dir.path().join(".ricecoder").join("boilerplates");
586        fs::create_dir_all(&project_dir).unwrap();
587
588        // Create a project boilerplate
589        let project_bp = project_dir.join("my-bp");
590        fs::create_dir_all(&project_bp).unwrap();
591        let project_metadata = r#"
592id: my-bp
593name: My Boilerplate
594description: Project version
595language: rust
596files: []
597dependencies: []
598scripts: []
599"#;
600        fs::write(project_bp.join("boilerplate.yaml"), project_metadata).unwrap();
601
602        let result = BoilerplateDiscovery::discover(temp_dir.path()).unwrap();
603
604        // Should find the project boilerplate
605        assert!(result.boilerplates.iter().any(|bp| bp.id == "my-bp"));
606
607        // Verify it's marked as project source
608        let bp = result
609            .boilerplates
610            .iter()
611            .find(|bp| bp.id == "my-bp")
612            .unwrap();
613        assert!(matches!(bp.source, BoilerplateSource::Project(_)));
614    }
615
616    #[test]
617    fn test_discover_boilerplate_by_language() {
618        let temp_dir = TempDir::new().unwrap();
619        let project_dir = temp_dir.path().join(".ricecoder").join("boilerplates");
620        fs::create_dir_all(&project_dir).unwrap();
621
622        // Create Rust boilerplate
623        let rust_bp = project_dir.join("rust-bp");
624        fs::create_dir_all(&rust_bp).unwrap();
625        let rust_metadata = r#"
626id: rust-bp
627name: Rust Boilerplate
628description: Rust project
629language: rust
630files: []
631dependencies: []
632scripts: []
633"#;
634        fs::write(rust_bp.join("boilerplate.yaml"), rust_metadata).unwrap();
635
636        // Create TypeScript boilerplate
637        let ts_bp = project_dir.join("ts-bp");
638        fs::create_dir_all(&ts_bp).unwrap();
639        let ts_metadata = r#"
640id: ts-bp
641name: TypeScript Boilerplate
642description: TypeScript project
643language: typescript
644files: []
645dependencies: []
646scripts: []
647"#;
648        fs::write(ts_bp.join("boilerplate.yaml"), ts_metadata).unwrap();
649
650        let result = BoilerplateDiscovery::discover_by_language(temp_dir.path(), "rust").unwrap();
651        assert_eq!(result.len(), 1);
652        assert_eq!(result[0].id, "rust-bp");
653    }
654
655    #[test]
656    fn test_discover_boilerplate_by_name() {
657        let temp_dir = TempDir::new().unwrap();
658        let project_dir = temp_dir.path().join(".ricecoder").join("boilerplates");
659        fs::create_dir_all(&project_dir).unwrap();
660
661        // Create boilerplate
662        let bp = project_dir.join("my-awesome-bp");
663        fs::create_dir_all(&bp).unwrap();
664        let metadata = r#"
665id: my-awesome-bp
666name: My Awesome Boilerplate
667description: Test
668language: rust
669files: []
670dependencies: []
671scripts: []
672"#;
673        fs::write(bp.join("boilerplate.yaml"), metadata).unwrap();
674
675        let result = BoilerplateDiscovery::discover_by_name(temp_dir.path(), "awesome").unwrap();
676        assert_eq!(result.len(), 1);
677        assert_eq!(result[0].id, "my-awesome-bp");
678    }
679}