rgen_core/
resolver.rs

1use anyhow::{Context, Result};
2use glob::glob;
3use std::path::PathBuf;
4
5use crate::cache::{CacheManager, CachedPack};
6use crate::lockfile::LockfileManager;
7
8/// Template resolver for rpack:template syntax
9#[derive(Debug, Clone)]
10pub struct TemplateResolver {
11    cache_manager: CacheManager,
12    lockfile_manager: LockfileManager,
13}
14
15/// Resolved template source
16#[derive(Debug, Clone)]
17pub struct TemplateSource {
18    pub pack_id: String,
19    pub template_path: PathBuf,
20    pub pack: CachedPack,
21    pub manifest: Option<crate::rpack::RpackManifest>,
22}
23
24/// Template search result
25#[derive(Debug, Clone)]
26pub struct TemplateSearchResult {
27    pub pack_id: String,
28    pub template_path: PathBuf,
29    pub pack_name: String,
30    pub pack_description: String,
31}
32
33impl TemplateResolver {
34    /// Create a new template resolver
35    pub fn new(cache_manager: CacheManager, lockfile_manager: LockfileManager) -> Self {
36        Self {
37            cache_manager,
38            lockfile_manager,
39        }
40    }
41
42    /// Resolve a template reference in the format "pack_id:template_path"
43    pub fn resolve(&self, template_ref: &str) -> Result<TemplateSource> {
44        let (pack_id, template_path) = self.parse_template_ref(template_ref)?;
45
46        // Get pack from lockfile
47        let lock_entry = self
48            .lockfile_manager
49            .get(&pack_id)?
50            .with_context(|| format!("Pack '{}' not found in lockfile", pack_id))?;
51
52        // Load cached pack
53        let cached_pack = self
54            .cache_manager
55            .load_cached(&pack_id, &lock_entry.version)
56            .with_context(|| format!("Pack '{}' not found in cache", pack_id))?;
57
58        // Resolve template path
59        let full_template_path = self.resolve_template_path(&cached_pack, &template_path)?;
60
61        // Verify template exists
62        if !full_template_path.exists() {
63            anyhow::bail!(
64                "Template '{}' not found in pack '{}'",
65                template_path,
66                pack_id
67            );
68        }
69
70        let manifest = cached_pack.manifest.clone();
71
72        Ok(TemplateSource {
73            pack_id,
74            template_path: full_template_path,
75            pack: cached_pack,
76            manifest,
77        })
78    }
79
80    /// Parse a template reference into pack ID and template path
81    fn parse_template_ref(&self, template_ref: &str) -> Result<(String, String)> {
82        let parts: Vec<&str> = template_ref.split(':').collect();
83
84        if parts.len() != 2 {
85            anyhow::bail!(
86                "Invalid template reference format: '{}'. Expected 'pack_id:template_path'",
87                template_ref
88            );
89        }
90
91        let pack_id = parts[0].to_string();
92        let template_path = parts[1].to_string();
93
94        if pack_id.is_empty() {
95            anyhow::bail!("Empty pack ID in template reference: '{}'", template_ref);
96        }
97
98        if template_path.is_empty() {
99            anyhow::bail!(
100                "Empty template path in template reference: '{}'",
101                template_ref
102            );
103        }
104
105        Ok((pack_id, template_path))
106    }
107
108    /// Resolve template path relative to pack directory
109    fn resolve_template_path(
110        &self, cached_pack: &CachedPack, template_path: &str,
111    ) -> Result<PathBuf> {
112        // Start with templates directory
113        let mut full_path = cached_pack.path.join("templates");
114
115        // Add template path components
116        for component in template_path.split('/') {
117            if component == ".." {
118                anyhow::bail!("Template path cannot contain '..': {}", template_path);
119            }
120            if component.is_empty() {
121                continue;
122            }
123            full_path = full_path.join(component);
124        }
125
126        Ok(full_path)
127    }
128
129    /// Search for templates across all installed packs
130    pub fn search_templates(&self, query: Option<&str>) -> Result<Vec<TemplateSearchResult>> {
131        let installed_packs = self.lockfile_manager.installed_packs()?;
132        let mut results = Vec::new();
133
134        for (pack_id, lock_entry) in installed_packs {
135            if let Ok(cached_pack) = self
136                .cache_manager
137                .load_cached(&pack_id, &lock_entry.version)
138            {
139                let pack_templates = self.find_templates_in_pack(&cached_pack)?;
140
141                for template_path in pack_templates {
142                    let template_name = template_path
143                        .file_name()
144                        .and_then(|n| n.to_str())
145                        .unwrap_or("unknown");
146
147                    // Filter by query if provided
148                    if let Some(query) = query {
149                        let query_lower = query.to_lowercase();
150                        if !template_name.to_lowercase().contains(&query_lower) {
151                            continue;
152                        }
153                    }
154
155                    results.push(TemplateSearchResult {
156                        pack_id: pack_id.clone(),
157                        template_path: template_path.clone(),
158                        pack_name: cached_pack
159                            .manifest
160                            .as_ref()
161                            .map(|m| m.metadata.name.clone())
162                            .unwrap_or_else(|| pack_id.clone()),
163                        pack_description: cached_pack
164                            .manifest
165                            .as_ref()
166                            .map(|m| m.metadata.description.clone())
167                            .unwrap_or_else(|| "No description".to_string()),
168                    });
169                }
170            }
171        }
172
173        // Sort by pack name, then template name
174        results.sort_by(|a, b| {
175            a.pack_name
176                .cmp(&b.pack_name)
177                .then_with(|| a.template_path.cmp(&b.template_path))
178        });
179
180        Ok(results)
181    }
182
183    /// Find all templates in a pack using manifest discovery
184    fn find_templates_in_pack(&self, cached_pack: &CachedPack) -> Result<Vec<PathBuf>> {
185        if let Some(manifest) = &cached_pack.manifest {
186            manifest.discover_templates(&cached_pack.path)
187        } else {
188            // Fallback to default convention if no manifest
189            let conventions = crate::rpack::PackConventions::default();
190            let mut templates = Vec::new();
191
192            for pattern in conventions.template_patterns {
193                let full_pattern = cached_pack.path.join(pattern);
194                for entry in glob(&full_pattern.to_string_lossy())? {
195                    templates.push(entry?);
196                }
197            }
198
199            templates.sort();
200            Ok(templates)
201        }
202    }
203
204    /// Get available templates for a specific pack
205    pub fn get_pack_templates(&self, pack_id: &str) -> Result<Vec<String>> {
206        let lock_entry = self
207            .lockfile_manager
208            .get(pack_id)?
209            .with_context(|| format!("Pack '{}' not found in lockfile", pack_id))?;
210
211        let cached_pack = self
212            .cache_manager
213            .load_cached(pack_id, &lock_entry.version)
214            .with_context(|| format!("Pack '{}' not found in cache", pack_id))?;
215
216        // Use manifest discovery to find templates
217        let templates = self.find_templates_in_pack(&cached_pack)?;
218        let templates_dir = cached_pack.path.join("templates");
219        let mut template_paths = Vec::new();
220
221        for template_path in templates {
222            // Get relative path from templates directory
223            let relative_path = template_path
224                .strip_prefix(&templates_dir)
225                .context("Failed to get relative template path")?;
226
227            template_paths.push(relative_path.to_string_lossy().to_string());
228        }
229
230        // Sort for consistent output
231        template_paths.sort();
232
233        Ok(template_paths)
234    }
235
236    /// Get template information including frontmatter
237    pub fn get_template_info(&self, template_ref: &str) -> Result<TemplateInfo> {
238        let template_source = self.resolve(template_ref)?;
239
240        // Read template content
241        let content = std::fs::read_to_string(&template_source.template_path)
242            .context("Failed to read template file")?;
243
244        // Parse frontmatter if present
245        let (frontmatter, template_content) = self.parse_frontmatter(&content)?;
246
247        Ok(TemplateInfo {
248            pack_id: template_source.pack_id,
249            template_path: template_source.template_path,
250            frontmatter,
251            content: template_content,
252            pack_info: template_source.manifest.map(|m| m.metadata),
253        })
254    }
255
256    /// Parse frontmatter from template content
257    fn parse_frontmatter(&self, content: &str) -> Result<(Option<serde_yaml::Value>, String)> {
258        use gray_matter::Matter;
259
260        let matter = Matter::<gray_matter::engine::YAML>::new();
261        let parsed = matter.parse(content)?;
262
263        let frontmatter = parsed.data.map(|data: serde_yaml::Value| data);
264        let content = parsed.content;
265
266        Ok((frontmatter, content))
267    }
268}
269
270/// Template information
271#[derive(Debug, Clone)]
272pub struct TemplateInfo {
273    pub pack_id: String,
274    pub template_path: PathBuf,
275    pub frontmatter: Option<serde_yaml::Value>,
276    pub content: String,
277    pub pack_info: Option<crate::rpack::RpackMetadata>,
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::fs;
284    use tempfile::TempDir;
285
286    #[test]
287    fn test_parse_template_ref() {
288        let temp_dir = TempDir::new().unwrap();
289        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
290        let lockfile_manager = LockfileManager::new(temp_dir.path());
291        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
292
293        let (pack_id, template_path) = resolver
294            .parse_template_ref("io.rgen.test:main.tmpl")
295            .unwrap();
296        assert_eq!(pack_id, "io.rgen.test");
297        assert_eq!(template_path, "main.tmpl");
298    }
299
300    #[test]
301    fn test_parse_template_ref_invalid() {
302        let temp_dir = TempDir::new().unwrap();
303        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
304        let lockfile_manager = LockfileManager::new(temp_dir.path());
305        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
306
307        // Invalid format
308        assert!(resolver.parse_template_ref("invalid").is_err());
309
310        // Empty pack ID
311        assert!(resolver.parse_template_ref(":template.tmpl").is_err());
312
313        // Empty template path
314        assert!(resolver.parse_template_ref("pack:").is_err());
315    }
316
317    #[test]
318    fn test_resolve_template_path() {
319        let temp_dir = TempDir::new().unwrap();
320        let pack_dir = temp_dir.path().join("pack");
321        let templates_dir = pack_dir.join("templates");
322        fs::create_dir_all(&templates_dir).unwrap();
323
324        let cached_pack = CachedPack {
325            id: "io.rgen.test".to_string(),
326            version: "1.0.0".to_string(),
327            path: pack_dir,
328            sha256: "abc123".to_string(),
329            manifest: None,
330        };
331
332        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
333        let lockfile_manager = LockfileManager::new(temp_dir.path());
334        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
335
336        let resolved_path = resolver
337            .resolve_template_path(&cached_pack, "main.tmpl")
338            .unwrap();
339        assert_eq!(resolved_path, templates_dir.join("main.tmpl"));
340    }
341
342    #[test]
343    fn test_resolve_template_path_security() {
344        let temp_dir = TempDir::new().unwrap();
345        let pack_dir = temp_dir.path().join("pack");
346        let cached_pack = CachedPack {
347            id: "io.rgen.test".to_string(),
348            version: "1.0.0".to_string(),
349            path: pack_dir,
350            sha256: "abc123".to_string(),
351            manifest: None,
352        };
353
354        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
355        let lockfile_manager = LockfileManager::new(temp_dir.path());
356        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
357
358        // Should reject path traversal
359        assert!(resolver
360            .resolve_template_path(&cached_pack, "../outside.tmpl")
361            .is_err());
362    }
363
364    #[test]
365    fn test_template_resolver_new() {
366        let temp_dir = TempDir::new().unwrap();
367        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
368        let lockfile_manager = LockfileManager::new(temp_dir.path());
369
370        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
371
372        // Resolver should be created successfully
373        assert!(resolver.cache_manager.cache_dir().exists());
374    }
375
376    #[test]
377    fn test_resolve_template_path_nested() {
378        let temp_dir = TempDir::new().unwrap();
379        let pack_dir = temp_dir.path().join("pack");
380        let templates_dir = pack_dir.join("templates");
381        fs::create_dir_all(&templates_dir).unwrap();
382
383        let cached_pack = CachedPack {
384            id: "io.rgen.test".to_string(),
385            version: "1.0.0".to_string(),
386            path: pack_dir,
387            sha256: "abc123".to_string(),
388            manifest: None,
389        };
390
391        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
392        let lockfile_manager = LockfileManager::new(temp_dir.path());
393        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
394
395        let resolved_path = resolver
396            .resolve_template_path(&cached_pack, "nested/sub.tmpl")
397            .unwrap();
398        assert_eq!(resolved_path, templates_dir.join("nested").join("sub.tmpl"));
399    }
400
401    #[test]
402    fn test_resolve_template_path_empty_components() {
403        let temp_dir = TempDir::new().unwrap();
404        let pack_dir = temp_dir.path().join("pack");
405        let templates_dir = pack_dir.join("templates");
406        fs::create_dir_all(&templates_dir).unwrap();
407
408        let cached_pack = CachedPack {
409            id: "io.rgen.test".to_string(),
410            version: "1.0.0".to_string(),
411            path: pack_dir,
412            sha256: "abc123".to_string(),
413            manifest: None,
414        };
415
416        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
417        let lockfile_manager = LockfileManager::new(temp_dir.path());
418        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
419
420        // Should handle empty path components
421        let resolved_path = resolver
422            .resolve_template_path(&cached_pack, "a//b/")
423            .unwrap();
424        assert_eq!(resolved_path, templates_dir.join("a").join("b"));
425    }
426
427    #[test]
428    fn test_parse_frontmatter_basic() {
429        let temp_dir = TempDir::new().unwrap();
430        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
431        let lockfile_manager = LockfileManager::new(temp_dir.path());
432        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
433
434        let content = r#"---
435to: "output.txt"
436vars:
437  name: "Test"
438---
439Hello {{ name }}
440"#;
441
442        let (frontmatter, template_content) = resolver.parse_frontmatter(content).unwrap();
443
444        assert!(frontmatter.is_some());
445        assert!(template_content.contains("Hello {{ name }}"));
446    }
447
448    #[test]
449    fn test_parse_frontmatter_no_frontmatter() {
450        let temp_dir = TempDir::new().unwrap();
451        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
452        let lockfile_manager = LockfileManager::new(temp_dir.path());
453        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
454
455        let content = "Hello World";
456
457        let (frontmatter, template_content) = resolver.parse_frontmatter(content).unwrap();
458
459        assert!(frontmatter.is_none());
460        assert_eq!(template_content, "Hello World");
461    }
462
463    #[test]
464    fn test_find_templates_in_pack_with_manifest() {
465        let temp_dir = TempDir::new().unwrap();
466        let pack_dir = temp_dir.path().join("pack");
467        let templates_dir = pack_dir.join("templates");
468        fs::create_dir_all(&templates_dir).unwrap();
469
470        // Create template files
471        fs::write(templates_dir.join("main.tmpl"), "template1").unwrap();
472        fs::write(templates_dir.join("sub.tmpl"), "template2").unwrap();
473
474        let manifest = crate::rpack::RpackManifest {
475            metadata: crate::rpack::RpackMetadata {
476                id: "io.rgen.test".to_string(),
477                name: "test-pack".to_string(),
478                version: "1.0.0".to_string(),
479                description: "Test pack".to_string(),
480                license: "MIT".to_string(),
481                rgen_compat: "1.0.0".to_string(),
482            },
483            dependencies: std::collections::BTreeMap::new(),
484            templates: crate::rpack::TemplatesConfig {
485                patterns: vec![
486                    "templates/main.tmpl".to_string(),
487                    "templates/sub.tmpl".to_string(),
488                ],
489                includes: vec![],
490            },
491            macros: crate::rpack::MacrosConfig::default(),
492            rdf: crate::rpack::RdfConfig {
493                base: None,
494                prefixes: std::collections::BTreeMap::new(),
495                patterns: vec![],
496                inline: vec![],
497            },
498            queries: crate::rpack::QueriesConfig::default(),
499            shapes: crate::rpack::ShapesConfig::default(),
500            preset: crate::rpack::PresetConfig::default(),
501        };
502
503        let cached_pack = CachedPack {
504            id: "io.rgen.test".to_string(),
505            version: "1.0.0".to_string(),
506            path: pack_dir,
507            sha256: "abc123".to_string(),
508            manifest: Some(manifest),
509        };
510
511        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
512        let lockfile_manager = LockfileManager::new(temp_dir.path());
513        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
514
515        let templates = resolver.find_templates_in_pack(&cached_pack).unwrap();
516
517        assert_eq!(templates.len(), 2);
518        assert!(templates.iter().any(|t| t.ends_with("main.tmpl")));
519        assert!(templates.iter().any(|t| t.ends_with("sub.tmpl")));
520    }
521
522    #[test]
523    fn test_find_templates_in_pack_without_manifest() {
524        let temp_dir = TempDir::new().unwrap();
525        let pack_dir = temp_dir.path().join("pack");
526        let templates_dir = pack_dir.join("templates");
527        fs::create_dir_all(&templates_dir).unwrap();
528
529        // Create template files
530        fs::write(templates_dir.join("main.tmpl"), "template1").unwrap();
531        fs::write(templates_dir.join("sub.tmpl"), "template2").unwrap();
532
533        let cached_pack = CachedPack {
534            id: "io.rgen.test".to_string(),
535            version: "1.0.0".to_string(),
536            path: pack_dir,
537            sha256: "abc123".to_string(),
538            manifest: None,
539        };
540
541        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
542        let lockfile_manager = LockfileManager::new(temp_dir.path());
543        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
544
545        let templates = resolver.find_templates_in_pack(&cached_pack).unwrap();
546
547        assert_eq!(templates.len(), 2);
548        assert!(templates.iter().any(|t| t.ends_with("main.tmpl")));
549        assert!(templates.iter().any(|t| t.ends_with("sub.tmpl")));
550    }
551
552    #[test]
553    fn test_search_templates_empty() {
554        let temp_dir = TempDir::new().unwrap();
555        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
556        let lockfile_manager = LockfileManager::new(temp_dir.path());
557        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
558
559        let results = resolver.search_templates(None).unwrap();
560        assert!(results.is_empty());
561    }
562
563    #[test]
564    fn test_get_pack_templates_nonexistent_pack() {
565        let temp_dir = TempDir::new().unwrap();
566        let cache_manager = CacheManager::with_dir(temp_dir.path().join("cache")).unwrap();
567        let lockfile_manager = LockfileManager::new(temp_dir.path());
568        let resolver = TemplateResolver::new(cache_manager, lockfile_manager);
569
570        // Should fail for nonexistent pack
571        assert!(resolver.get_pack_templates("nonexistent.pack").is_err());
572    }
573}