agpm_cli/markdown/
frontmatter.rs

1//! Frontmatter parsing with grey_matter Engine trait and Tera templating.
2//!
3//! This module provides a custom grey_matter Engine that applies Tera templating
4//! to frontmatter content before parsing it as YAML. This enables dynamic frontmatter
5//! with template variables while maintaining compatibility with standard YAML frontmatter.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use agpm_cli::markdown::frontmatter::FrontmatterParser;
11//! use agpm_cli::manifest::DependencyMetadata;
12//! use agpm_cli::manifest::ProjectConfig;
13//! use std::path::Path;
14//! use std::str::FromStr;
15//! use toml;
16//!
17//! let mut parser = FrontmatterParser::new();
18//! let content = r#"---
19//! dependencies:
20//!   agents:
21//!     - path: helper.md
22//!       version: "{{ project.version }}"
23//! ---
24//! # Content
25//! "#;
26//!
27//! // Create a test project config
28//! let toml_content = r#"
29//! name = "test-project"
30//! version = "1.0.0"
31//! language = "rust"
32//! "#;
33//! let project_config = {
34//!     let value = toml::Value::from_str(toml_content).unwrap();
35//!     if let toml::Value::Table(table) = value {
36//!         ProjectConfig::from(table)
37//!     } else {
38//!         ProjectConfig::default()
39//!     }
40//! };
41//!
42//! let result = parser.parse_with_templating::<DependencyMetadata>(
43//!     content,
44//!     Some(&project_config.to_json_value()),
45//!     Path::new("test.md"),
46//!     None
47//! ).unwrap_or_else(|e| panic!("Failed to parse: {}", e));
48//!
49//! assert!(result.has_frontmatter());
50//! assert!(result.data.is_some());
51//! ```
52
53use anyhow::Result;
54use gray_matter::{
55    Matter, Pod,
56    engine::{Engine, YAML},
57};
58use serde::de::DeserializeOwned;
59use std::fmt::Debug;
60use std::path::Path;
61use tera::Context as TeraContext;
62
63use crate::core::OperationContext;
64use crate::manifest::ProjectConfig;
65use crate::templating::TemplateRenderer;
66
67/// Custom gray_matter engine that returns raw frontmatter text without parsing.
68///
69/// This engine implements the gray_matter Engine trait but simply returns the
70/// raw frontmatter content as a string without any YAML parsing. This allows
71/// us to extract frontmatter text even when the YAML is malformed.
72struct RawFrontmatter;
73
74impl Engine for RawFrontmatter {
75    fn parse(content: &str) -> Result<Pod, gray_matter::Error> {
76        // Just return the raw content as a string without any parsing
77        Ok(Pod::String(content.to_string()))
78    }
79}
80
81/// Result of parsing frontmatter from content.
82///
83/// This struct represents the parsed result from frontmatter extraction,
84/// containing both the structured data and the content without frontmatter.
85#[derive(Debug, Clone)]
86pub struct ParsedFrontmatter<T> {
87    /// The parsed frontmatter data, if any was present and successfully parsed.
88    pub data: Option<T>,
89
90    /// The content with frontmatter removed.
91    pub content: String,
92
93    /// The raw frontmatter string before templating and parsing.
94    pub raw_frontmatter: Option<String>,
95
96    /// Whether templating was applied during parsing.
97    pub templated: bool,
98}
99
100impl<T> ParsedFrontmatter<T> {
101    /// Check if frontmatter was present in the original content.
102    pub fn has_frontmatter(&self) -> bool {
103        self.raw_frontmatter.is_some()
104    }
105}
106
107/// Helper functions for frontmatter templating.
108pub struct FrontmatterTemplating;
109
110impl FrontmatterTemplating {
111    /// Build Tera context for frontmatter templating.
112    ///
113    /// Creates the template context with the agpm.project namespace
114    /// based on the provided project configuration.
115    ///
116    /// # Arguments
117    /// * `project_config` - Project configuration for template variables
118    ///
119    /// # Returns
120    /// * `TeraContext` - Configured template context
121    pub fn build_template_context(project_config: &ProjectConfig) -> TeraContext {
122        let mut context = TeraContext::new();
123
124        // Build agpm.project context (same structure as content templates)
125        let mut agpm = serde_json::Map::new();
126        agpm.insert("project".to_string(), project_config.to_json_value());
127        context.insert("agpm", &agpm);
128
129        // Also provide top-level project namespace for convenience
130        context.insert("project", &project_config.to_json_value());
131
132        context
133    }
134
135    /// Apply Tera templating to frontmatter content.
136    ///
137    /// Always renders the content as a template, even if no template syntax is present.
138    ///
139    /// # Arguments
140    /// * `content` - The frontmatter content to template
141    /// * `project_config` - Project configuration for template variables
142    /// * `template_renderer` - Template renderer to use
143    /// * `file_path` - Path to file for error reporting
144    ///
145    /// # Returns
146    /// * `Result<String>` - Templated content or error
147    pub fn apply_templating(
148        content: &str,
149        project_config: &ProjectConfig,
150        template_renderer: &mut TemplateRenderer,
151        file_path: &Path,
152    ) -> Result<String> {
153        let context = Self::build_template_context(project_config);
154
155        // Always render as template - this handles the case where there's no template syntax
156        template_renderer.render_template(content, &context).map_err(|e| {
157            anyhow::anyhow!(
158                "Failed to render frontmatter template in '{}': {}",
159                file_path.display(),
160                e
161            )
162        })
163    }
164
165    /// Build template context from variant inputs.
166    ///
167    /// Creates a Tera context from variant_inputs, which contains all template
168    /// variables including project config and any overrides.
169    ///
170    /// # Arguments
171    /// * `variant_inputs` - Template variables (project, config, etc.)
172    ///
173    /// # Returns
174    /// * `TeraContext` - Configured template context
175    pub fn build_template_context_from_variant_inputs(
176        variant_inputs: &serde_json::Value,
177    ) -> TeraContext {
178        let mut context = TeraContext::new();
179
180        // Build agpm namespace and top-level keys from variant_inputs
181        if let Some(obj) = variant_inputs.as_object() {
182            let mut agpm = serde_json::Map::new();
183
184            for (key, value) in obj {
185                // Insert at top level
186                context.insert(key, value);
187                // Also add to agpm namespace
188                agpm.insert(key.clone(), value.clone());
189            }
190
191            context.insert("agpm", &agpm);
192        }
193
194        context
195    }
196}
197
198/// Unified frontmatter parser with templating support.
199///
200/// This struct provides a centralized interface for parsing frontmatter from
201/// content using the grey_matter library, with optional Tera templating support.
202/// It handles YAML, TOML, and JSON frontmatter formats automatically.
203pub struct FrontmatterParser {
204    raw_matter: Matter<RawFrontmatter>,
205    yaml_matter: Matter<YAML>,
206    template_renderer: TemplateRenderer,
207}
208
209impl Clone for FrontmatterParser {
210    fn clone(&self) -> Self {
211        Self::new()
212    }
213}
214
215impl Debug for FrontmatterParser {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("FrontmatterParser").finish()
218    }
219}
220
221impl Default for FrontmatterParser {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl FrontmatterParser {
228    /// Create a new frontmatter parser.
229    pub fn new() -> Self {
230        let project_dir = std::env::current_dir().unwrap_or_default();
231        let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)
232            .unwrap_or_else(|_| {
233                // Fallback to disabled renderer if configuration fails
234                TemplateRenderer::new(false, project_dir, None).unwrap()
235            });
236
237        Self {
238            raw_matter: Matter::new(),
239            yaml_matter: Matter::new(),
240            template_renderer,
241        }
242    }
243
244    /// Create a new frontmatter parser with custom project directory.
245    ///
246    /// # Arguments
247    /// * `project_dir` - Project root directory for template rendering
248    pub fn with_project_dir(project_dir: std::path::PathBuf) -> Result<Self> {
249        let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
250
251        Ok(Self {
252            raw_matter: Matter::new(),
253            yaml_matter: Matter::new(),
254            template_renderer,
255        })
256    }
257
258    /// Parse content and extract frontmatter with optional templating.
259    ///
260    /// This method provides the complete parsing pipeline:
261    /// 1. Extract frontmatter using gray_matter
262    /// 2. Apply Tera templating if variant_inputs is provided
263    /// 3. Deserialize the result to the target type
264    ///
265    /// # Arguments
266    /// * `content` - The content to parse
267    /// * `variant_inputs` - Optional template variables (project, config, etc.)
268    /// * `file_path` - Path to the file (used for error reporting)
269    /// * `context` - Optional operation context for warning deduplication
270    ///
271    /// # Returns
272    /// * `ParsedFrontmatter<T>` - The parsed result with data and content
273    pub fn parse_with_templating<T>(
274        &mut self,
275        content: &str,
276        variant_inputs: Option<&serde_json::Value>,
277        file_path: &Path,
278        context: Option<&OperationContext>,
279    ) -> Result<ParsedFrontmatter<T>>
280    where
281        T: DeserializeOwned,
282    {
283        // Step 1: Extract raw frontmatter text first (before any YAML parsing)
284        let raw_frontmatter_text = self.extract_raw_frontmatter(content);
285        let content_without_frontmatter = self.strip_frontmatter(content);
286
287        // Step 2: Always apply templating if frontmatter is present
288        let (templated_frontmatter, was_templated) = if let Some(raw_fm) =
289            raw_frontmatter_text.as_ref()
290        {
291            // Always apply templating to catch invalid Jinja syntax
292            let templated = if let Some(inputs) = variant_inputs {
293                let ctx = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
294                self.template_renderer.render_template(raw_fm, &ctx).map_err(|e| {
295                    anyhow::anyhow!(
296                        "Failed to render frontmatter template in '{}': {}",
297                        file_path.display(),
298                        e
299                    )
300                })?
301            } else {
302                // Even without variant_inputs, render to catch syntax errors
303                let empty_context = TeraContext::new();
304                self.template_renderer.render_template(raw_fm, &empty_context).map_err(|e| {
305                    anyhow::anyhow!(
306                        "Failed to render frontmatter template in '{}': {}",
307                        file_path.display(),
308                        e
309                    )
310                })?
311            };
312            (Some(templated), true)
313        } else {
314            (None, false)
315        };
316
317        // Step 3: Deserialize to target type
318        let parsed_data = if let Some(ref frontmatter) = templated_frontmatter {
319            match serde_yaml::from_str::<T>(frontmatter) {
320                Ok(data) => Some(data),
321                Err(e) => {
322                    // Only warn once per file to avoid spam during transitive dependency resolution
323                    if let Some(ctx) = context {
324                        if ctx.should_warn_file(file_path) {
325                            eprintln!(
326                                "Warning: Unable to parse YAML frontmatter in '{}'.
327
328The document will be processed without metadata, and any declared dependencies
329will NOT be resolved or installed.
330
331Parse error: {}
332
333For the correct dependency format, see:
334https://github.com/aig787/agpm#transitive-dependencies",
335                                file_path.display(),
336                                e
337                            );
338                        }
339                    }
340                    None
341                }
342            }
343        } else {
344            None
345        };
346
347        Ok(ParsedFrontmatter {
348            data: parsed_data,
349            content: content_without_frontmatter,
350            raw_frontmatter: raw_frontmatter_text,
351            templated: was_templated,
352        })
353    }
354
355    /// Simple parse without templating, just extract frontmatter and content.
356    ///
357    /// # Arguments
358    /// * `content` - The content to parse
359    ///
360    /// # Returns
361    /// * `ParsedFrontmatter<T>` - The parsed result with data and content
362    pub fn parse<T>(&self, content: &str) -> Result<ParsedFrontmatter<T>>
363    where
364        T: DeserializeOwned,
365    {
366        let matter_result = self.yaml_matter.parse(content)?;
367
368        let raw_frontmatter = matter_result
369            .data
370            .map(|data: serde_yaml::Value| serde_yaml::to_string(&data).unwrap_or_default());
371
372        let content_without_frontmatter = matter_result.content;
373
374        // Parse the data if frontmatter was present
375        let parsed_data = if let Some(frontmatter) = raw_frontmatter.as_ref() {
376            match serde_yaml::from_str::<T>(frontmatter) {
377                Ok(data) => Some(data),
378                Err(e) => {
379                    eprintln!(
380                        "Warning: Unable to parse YAML frontmatter.
381
382Parse error: {}
383
384The document will be processed without metadata.",
385                        e
386                    );
387                    None
388                }
389            }
390        } else {
391            None
392        };
393
394        Ok(ParsedFrontmatter {
395            data: parsed_data,
396            content: content_without_frontmatter,
397            raw_frontmatter,
398            templated: false,
399        })
400    }
401
402    /// Check if content has frontmatter.
403    ///
404    /// # Arguments
405    /// * `content` - The content to check
406    ///
407    /// # Returns
408    /// * `bool` - True if frontmatter is present
409    pub fn has_frontmatter(&self, content: &str) -> bool {
410        // Use the raw_matter engine to check for frontmatter without YAML parsing
411        if let Ok(result) = self.raw_matter.parse::<String>(content) {
412            result.data.is_some()
413        } else {
414            false
415        }
416    }
417
418    /// Extract just the content without frontmatter.
419    ///
420    /// # Arguments
421    /// * `content` - The content to process
422    ///
423    /// # Returns
424    /// * `String` - Content with frontmatter removed
425    pub fn strip_frontmatter(&self, content: &str) -> String {
426        // Use the raw_matter engine to strip frontmatter without YAML parsing
427        self.raw_matter
428            .parse::<String>(content)
429            .map(|result| result.content)
430            .unwrap_or_else(|_| content.to_string())
431    }
432
433    /// Extract just the raw frontmatter string.
434    ///
435    /// # Arguments
436    /// * `content` - The content to process
437    ///
438    /// # Returns
439    /// * `Option<String>` - Raw frontmatter as YAML string, if present
440    pub fn extract_raw_frontmatter(&self, content: &str) -> Option<String> {
441        // Use the RawFrontmatter engine to extract raw frontmatter text without YAML parsing
442        match self.raw_matter.parse::<String>(content) {
443            Ok(result) => {
444                // The RawFrontmatter engine returns the raw frontmatter in the data field
445                result.data.filter(|frontmatter_text| !frontmatter_text.is_empty())
446            }
447            Err(_) => None,
448        }
449    }
450
451    /// Apply Tera templating to content.
452    ///
453    /// Always renders the content as a template to catch syntax errors.
454    /// If variant_inputs is provided, it's used for template variables.
455    /// Otherwise, renders with an empty context.
456    ///
457    /// # Arguments
458    /// * `content` - The content to template
459    /// * `variant_inputs` - Optional template variables (project, config, etc.)
460    /// * `file_path` - Path to file for error reporting
461    ///
462    /// # Returns
463    /// * `Result<String>` - Templated content or error
464    pub fn apply_templating(
465        &mut self,
466        content: &str,
467        variant_inputs: Option<&serde_json::Value>,
468        file_path: &Path,
469    ) -> Result<String> {
470        if let Some(inputs) = variant_inputs {
471            let context = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
472            self.template_renderer.render_template(content, &context).map_err(|e| {
473                anyhow::anyhow!(
474                    "Failed to render frontmatter template in '{}': {}",
475                    file_path.display(),
476                    e
477                )
478            })
479        } else {
480            // Render with empty context to catch syntax errors
481            let empty_context = TeraContext::new();
482            self.template_renderer.render_template(content, &empty_context).map_err(|e| {
483                anyhow::anyhow!(
484                    "Failed to render frontmatter template in '{}': {}",
485                    file_path.display(),
486                    e
487                )
488            })
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use tempfile::TempDir;
497
498    fn create_test_project_config() -> ProjectConfig {
499        let mut config_map = toml::map::Map::new();
500        config_map.insert("name".to_string(), toml::Value::String("test-project".into()));
501        config_map.insert("version".to_string(), toml::Value::String("1.0.0".into()));
502        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
503        ProjectConfig::from(config_map)
504    }
505
506    #[test]
507    fn test_frontmatter_templating_basic() {
508        let temp_dir = TempDir::new().unwrap();
509        let project_dir = temp_dir.path().to_path_buf();
510        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
511        let project_config = create_test_project_config();
512        let file_path = Path::new("test.md");
513
514        // Convert ProjectConfig to JSON Value for variant_inputs
515        let mut variant_inputs = serde_json::Map::new();
516        variant_inputs.insert("project".to_string(), project_config.to_json_value());
517        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
518
519        // Test simple template variable substitution
520        let content = "name: {{ project.name }}\nversion: {{ project.version }}";
521        let mut parser = FrontmatterParser::new();
522        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
523
524        assert!(result.is_ok());
525        let templated = result.unwrap();
526        assert!(templated.contains("name: test-project"));
527        assert!(templated.contains("version: 1.0.0"));
528    }
529
530    #[test]
531    fn test_frontmatter_templating_no_template_syntax() {
532        let temp_dir = TempDir::new().unwrap();
533        let project_dir = temp_dir.path().to_path_buf();
534        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
535        let project_config = create_test_project_config();
536        let file_path = Path::new("test.md");
537
538        // Convert ProjectConfig to JSON Value for variant_inputs
539        let mut variant_inputs = serde_json::Map::new();
540        variant_inputs.insert("project".to_string(), project_config.to_json_value());
541        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
542
543        // Test plain YAML without template syntax
544        let content = "name: static\nversion: 1.0.0";
545        let mut parser = FrontmatterParser::new();
546        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
547
548        assert!(result.is_ok());
549        let templated = result.unwrap();
550        assert_eq!(templated, content);
551    }
552
553    #[test]
554    fn test_frontmatter_templating_template_error() {
555        let temp_dir = TempDir::new().unwrap();
556        let project_dir = temp_dir.path().to_path_buf();
557        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
558        let project_config = create_test_project_config();
559        let file_path = Path::new("test.md");
560
561        // Convert ProjectConfig to JSON Value for variant_inputs
562        let mut variant_inputs = serde_json::Map::new();
563        variant_inputs.insert("project".to_string(), project_config.to_json_value());
564        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
565
566        // Test template with undefined variable
567        let content = "name: {{ undefined_var }}";
568        let mut parser = FrontmatterParser::new();
569        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
570
571        assert!(result.is_err());
572    }
573
574    #[test]
575    fn test_frontmatter_parser_new() {
576        let parser = FrontmatterParser::new();
577        // Should not panic
578        assert!(parser.has_frontmatter("---\nkey: value\n---\ncontent"));
579        assert!(!parser.has_frontmatter("just content"));
580    }
581
582    #[test]
583    fn test_frontmatter_parser_with_project_dir() {
584        let temp_dir = TempDir::new().unwrap();
585        let parser = FrontmatterParser::with_project_dir(temp_dir.path().to_path_buf());
586        assert!(parser.is_ok());
587    }
588
589    #[test]
590    fn test_parsed_frontmatter_has_frontmatter() {
591        let parsed = ParsedFrontmatter::<serde_yaml::Value> {
592            data: None,
593            content: "content".to_string(),
594            raw_frontmatter: Some("key: value".to_string()),
595            templated: false,
596        };
597        assert!(parsed.has_frontmatter());
598
599        let parsed_no_fm = ParsedFrontmatter::<serde_yaml::Value> {
600            data: None,
601            content: "content".to_string(),
602            raw_frontmatter: None,
603            templated: false,
604        };
605        assert!(!parsed_no_fm.has_frontmatter());
606    }
607}