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::{Context, 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    /// Rendered frontmatter with line offset (for Pass 2 parsing).
100    pub rendered_frontmatter: Option<RenderedFrontmatter>,
101
102    /// Byte boundaries of the frontmatter section (if present).
103    pub boundaries: Option<FrontmatterBoundaries>,
104}
105
106/// Rendered frontmatter with accurate line number information.
107///
108/// This struct represents frontmatter that has been extracted from a fully
109/// rendered file, preserving accurate line number references for error reporting.
110#[derive(Debug, Clone)]
111pub struct RenderedFrontmatter {
112    /// The rendered frontmatter content as YAML string.
113    pub content: String,
114
115    /// Number of lines before frontmatter in the rendered content.
116    /// This helps maintain accurate line number references.
117    pub line_offset: usize,
118}
119
120/// Byte boundaries of frontmatter section in content.
121///
122/// This struct represents the start and end byte positions of the frontmatter
123/// section (including delimiters) in the original content. This enables direct
124/// frontmatter replacement without string splitting and reassembly.
125#[derive(Debug, Clone, Copy)]
126pub struct FrontmatterBoundaries {
127    /// Byte position where frontmatter starts (first `---`).
128    pub start: usize,
129
130    /// Byte position where frontmatter ends (after closing `---` and newline).
131    pub end: usize,
132}
133
134impl<T> ParsedFrontmatter<T> {
135    /// Check if frontmatter was present in the original content.
136    pub fn has_frontmatter(&self) -> bool {
137        self.raw_frontmatter.is_some()
138    }
139}
140
141/// Helper functions for frontmatter templating.
142pub struct FrontmatterTemplating;
143
144impl FrontmatterTemplating {
145    /// Build Tera context for frontmatter templating.
146    ///
147    /// Creates the template context with the agpm.project namespace
148    /// based on the provided project configuration.
149    ///
150    /// # Arguments
151    /// * `project_config` - Project configuration for template variables
152    ///
153    /// # Returns
154    /// * `TeraContext` - Configured template context
155    pub fn build_template_context(project_config: &ProjectConfig) -> TeraContext {
156        let mut context = TeraContext::new();
157
158        // Build agpm.project context (same structure as content templates)
159        let mut agpm = serde_json::Map::new();
160        agpm.insert("project".to_string(), project_config.to_json_value());
161        context.insert("agpm", &agpm);
162
163        // Also provide top-level project namespace for convenience
164        context.insert("project", &project_config.to_json_value());
165
166        context
167    }
168
169    /// Apply Tera templating to frontmatter content.
170    ///
171    /// Always renders the content as a template, even if no template syntax is present.
172    ///
173    /// # Arguments
174    /// * `content` - The frontmatter content to template
175    /// * `project_config` - Project configuration for template variables
176    /// * `template_renderer` - Template renderer to use
177    /// * `file_path` - Path to file for error reporting
178    ///
179    /// # Returns
180    /// * `Result<String>` - Templated content or error
181    pub fn apply_templating(
182        content: &str,
183        project_config: &ProjectConfig,
184        template_renderer: &mut TemplateRenderer,
185        file_path: &Path,
186    ) -> Result<String> {
187        let context = Self::build_template_context(project_config);
188
189        // Always render as template - this handles the case where there's no template syntax
190        template_renderer.render_template(content, &context, None).map_err(|e| {
191            anyhow::anyhow!(
192                "Failed to render frontmatter template in '{}': {}",
193                file_path.display(),
194                e
195            )
196        })
197    }
198
199    /// Build template context from variant inputs.
200    ///
201    /// Creates a Tera context from variant_inputs, which contains all template
202    /// variables including project config and any overrides.
203    ///
204    /// # Arguments
205    /// * `variant_inputs` - Template variables (project, config, etc.)
206    ///
207    /// # Returns
208    /// * `TeraContext` - Configured template context
209    pub fn build_template_context_from_variant_inputs(
210        variant_inputs: &serde_json::Value,
211    ) -> TeraContext {
212        let mut context = TeraContext::new();
213
214        // Build agpm namespace and top-level keys from variant_inputs
215        if let Some(obj) = variant_inputs.as_object() {
216            let mut agpm = serde_json::Map::new();
217
218            for (key, value) in obj {
219                // Insert at top level
220                context.insert(key, value);
221                // Also add to agpm namespace
222                agpm.insert(key.clone(), value.clone());
223            }
224
225            context.insert("agpm", &agpm);
226        }
227
228        context
229    }
230}
231
232/// Unified frontmatter parser with templating support.
233///
234/// This struct provides a centralized interface for parsing frontmatter from
235/// content using the grey_matter library, with optional Tera templating support.
236/// It handles YAML, TOML, and JSON frontmatter formats automatically.
237pub struct FrontmatterParser {
238    raw_matter: Matter<RawFrontmatter>,
239    yaml_matter: Matter<YAML>,
240    template_renderer: TemplateRenderer,
241}
242
243impl Clone for FrontmatterParser {
244    fn clone(&self) -> Self {
245        Self::new()
246    }
247}
248
249impl Debug for FrontmatterParser {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("FrontmatterParser").finish()
252    }
253}
254
255impl Default for FrontmatterParser {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl FrontmatterParser {
262    /// Create a new frontmatter parser.
263    pub fn new() -> Self {
264        let project_dir = std::env::current_dir().unwrap_or_default();
265        let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)
266            .unwrap_or_else(|_| {
267                // Fallback to disabled renderer if configuration fails
268                TemplateRenderer::new(false, project_dir, None).unwrap()
269            });
270
271        Self {
272            raw_matter: Matter::new(),
273            yaml_matter: Matter::new(),
274            template_renderer,
275        }
276    }
277
278    /// Create a new frontmatter parser with custom project directory.
279    ///
280    /// # Arguments
281    /// * `project_dir` - Project root directory for template rendering
282    pub fn with_project_dir(project_dir: std::path::PathBuf) -> Result<Self> {
283        let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
284
285        Ok(Self {
286            raw_matter: Matter::new(),
287            yaml_matter: Matter::new(),
288            template_renderer,
289        })
290    }
291
292    /// Parse content and extract frontmatter with optional templating.
293    ///
294    /// This method provides the complete parsing pipeline:
295    /// 1. Extract frontmatter using gray_matter
296    /// 2. Apply Tera templating if variant_inputs is provided
297    /// 3. Deserialize the result to the target type
298    ///
299    /// # Arguments
300    /// * `content` - The content to parse
301    /// * `variant_inputs` - Optional template variables (project, config, etc.)
302    /// * `file_path` - Path to the file (used for error reporting)
303    /// * `context` - Optional operation context for warning deduplication
304    ///
305    /// # Returns
306    /// * `ParsedFrontmatter<T>` - The parsed result with data and content
307    pub fn parse_with_templating<T>(
308        &mut self,
309        content: &str,
310        variant_inputs: Option<&serde_json::Value>,
311        file_path: &Path,
312        context: Option<&OperationContext>,
313    ) -> Result<ParsedFrontmatter<T>>
314    where
315        T: DeserializeOwned,
316    {
317        // Step 1: Extract raw frontmatter text first (before any YAML parsing)
318        let raw_frontmatter_text = self.extract_raw_frontmatter(content);
319        let content_without_frontmatter = self.strip_frontmatter(content);
320
321        // Step 2: Always apply templating if frontmatter is present
322        let (templated_frontmatter, was_templated) = if let Some(raw_fm) =
323            raw_frontmatter_text.as_ref()
324        {
325            // Always apply templating to catch invalid Jinja syntax
326            let templated = if let Some(inputs) = variant_inputs {
327                let ctx = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
328                self.template_renderer.render_template(raw_fm, &ctx, None).map_err(|e| {
329                    anyhow::anyhow!(
330                        "Failed to render frontmatter template in '{}': {}",
331                        file_path.display(),
332                        e
333                    )
334                })?
335            } else {
336                // Even without variant_inputs, render to catch syntax errors
337                let empty_context = TeraContext::new();
338                self.template_renderer.render_template(raw_fm, &empty_context, None).map_err(
339                    |e| {
340                        anyhow::anyhow!(
341                            "Failed to render frontmatter template in '{}': {}",
342                            file_path.display(),
343                            e
344                        )
345                    },
346                )?
347            };
348            (Some(templated), true)
349        } else {
350            (None, false)
351        };
352
353        // Step 3: Deserialize to target type
354        let parsed_data = if let Some(frontmatter) = templated_frontmatter {
355            #[allow(clippy::needless_borrow)]
356            match serde_yaml::from_str::<T>(&frontmatter) {
357                Ok(data) => Some(data),
358                Err(e) => {
359                    // Only warn once per file to avoid spam during transitive dependency resolution
360                    if let Some(ctx) = context {
361                        if ctx.should_warn_file(file_path) {
362                            eprintln!(
363                                "Warning: Unable to parse YAML frontmatter in '{}'.
364
365The document will be processed without metadata, and any declared dependencies
366will NOT be resolved or installed.
367
368Parse error: {}
369
370For the correct dependency format, see:
371https://github.com/aig787/agpm#transitive-dependencies",
372                                file_path.display(),
373                                e
374                            );
375                        }
376                    }
377                    None
378                }
379            }
380        } else {
381            None
382        };
383
384        Ok(ParsedFrontmatter {
385            data: parsed_data,
386            content: content_without_frontmatter,
387            raw_frontmatter: raw_frontmatter_text,
388            templated: was_templated,
389            rendered_frontmatter: None,
390            boundaries: self.get_frontmatter_boundaries(content),
391        })
392    }
393
394    /// Simple parse without templating, just extract frontmatter and content.
395    ///
396    /// # Arguments
397    /// * `content` - The content to parse
398    ///
399    /// # Returns
400    /// * `ParsedFrontmatter<T>` - The parsed result with data and content
401    pub fn parse<T>(&self, content: &str) -> Result<ParsedFrontmatter<T>>
402    where
403        T: DeserializeOwned,
404    {
405        let matter_result = self.yaml_matter.parse(content)?;
406
407        let raw_frontmatter = matter_result
408            .data
409            .map(|data: serde_yaml::Value| serde_yaml::to_string(&data).unwrap_or_default());
410
411        let content_without_frontmatter = matter_result.content;
412
413        // Parse the data if frontmatter was present
414        let parsed_data = if let Some(frontmatter) = raw_frontmatter.as_ref() {
415            match serde_yaml::from_str::<T>(frontmatter) {
416                Ok(data) => Some(data),
417                Err(e) => {
418                    eprintln!(
419                        "Warning: Unable to parse YAML frontmatter.
420
421Parse error: {}
422
423The document will be processed without metadata.",
424                        e
425                    );
426                    None
427                }
428            }
429        } else {
430            None
431        };
432
433        Ok(ParsedFrontmatter {
434            data: parsed_data,
435            content: content_without_frontmatter,
436            raw_frontmatter,
437            templated: false,
438            rendered_frontmatter: None,
439            boundaries: self.get_frontmatter_boundaries(content),
440        })
441    }
442
443    /// Check if content has frontmatter.
444    ///
445    /// # Arguments
446    /// * `content` - The content to check
447    ///
448    /// # Returns
449    /// * `bool` - True if frontmatter is present
450    pub fn has_frontmatter(&self, content: &str) -> bool {
451        // Use the raw_matter engine to check for frontmatter without YAML parsing
452        if let Ok(result) = self.raw_matter.parse::<String>(content) {
453            result.data.is_some()
454        } else {
455            false
456        }
457    }
458
459    /// Extract just the content without frontmatter.
460    ///
461    /// # Arguments
462    /// * `content` - The content to process
463    ///
464    /// # Returns
465    /// * `String` - Content with frontmatter removed
466    pub fn strip_frontmatter(&self, content: &str) -> String {
467        // Use the raw_matter engine to strip frontmatter without YAML parsing
468        self.raw_matter
469            .parse::<String>(content)
470            .map(|result| result.content)
471            .unwrap_or_else(|_| content.to_string())
472    }
473
474    /// Extract just the raw frontmatter string.
475    ///
476    /// # Arguments
477    /// * `content` - The content to process
478    ///
479    /// # Returns
480    /// * `Option<String>` - Raw frontmatter as YAML string, if present
481    pub fn extract_raw_frontmatter(&self, content: &str) -> Option<String> {
482        // Use the RawFrontmatter engine to extract raw frontmatter text without YAML parsing
483        match self.raw_matter.parse::<String>(content) {
484            Ok(result) => {
485                // The RawFrontmatter engine returns the raw frontmatter in the data field
486                result.data.filter(|frontmatter_text| !frontmatter_text.is_empty())
487            }
488            Err(_) => None,
489        }
490    }
491
492    /// Get the byte boundaries of the frontmatter section.
493    ///
494    /// This method finds the start and end byte positions of the frontmatter
495    /// section (including delimiters) in the content. This enables direct
496    /// frontmatter replacement without string splitting and reassembly.
497    ///
498    /// # Arguments
499    /// * `content` - The content to analyze
500    ///
501    /// # Returns
502    /// * `Option<FrontmatterBoundaries>` - Boundary positions if frontmatter exists
503    ///
504    /// # Example
505    /// ```rust,no_run
506    /// use agpm_cli::markdown::frontmatter::FrontmatterParser;
507    ///
508    /// let parser = FrontmatterParser::new();
509    /// let content = "---\nkey: value\n---\n\nBody content";
510    /// let boundaries = parser.get_frontmatter_boundaries(content);
511    /// assert!(boundaries.is_some());
512    /// ```
513    pub fn get_frontmatter_boundaries(&self, content: &str) -> Option<FrontmatterBoundaries> {
514        // Look for opening delimiter
515        let first_delim = content.find("---")?;
516
517        // Frontmatter must start at beginning (possibly after whitespace)
518        if !content[..first_delim].trim().is_empty() {
519            return None;
520        }
521
522        // Find the end of the first line (after opening ---)
523        let after_first_delim = first_delim + 3;
524        let first_line_end = content[after_first_delim..]
525            .find('\n')
526            .map(|pos| after_first_delim + pos + 1)
527            .unwrap_or(content.len());
528
529        // Look for closing delimiter after the first line
530        let closing_delim_start = content[first_line_end..].find("---")?;
531        let closing_delim_pos = first_line_end + closing_delim_start;
532
533        // Find end of closing delimiter line
534        let after_closing = closing_delim_pos + 3;
535        let end_pos = content[after_closing..]
536            .find('\n')
537            .map(|pos| after_closing + pos + 1)
538            .unwrap_or(content.len());
539
540        Some(FrontmatterBoundaries {
541            start: first_delim,
542            end: end_pos,
543        })
544    }
545
546    /// Replace frontmatter section directly using byte boundaries.
547    ///
548    /// This method replaces the frontmatter section in the original content
549    /// with rendered frontmatter, preserving the body content exactly as-is.
550    /// This avoids the error-prone split-and-reassemble pattern.
551    ///
552    /// # Arguments
553    /// * `original_content` - The original content with frontmatter
554    /// * `rendered_frontmatter` - The rendered frontmatter YAML string (without delimiters)
555    /// * `boundaries` - The byte boundaries of the frontmatter section
556    ///
557    /// # Returns
558    /// * `String` - Content with frontmatter replaced, body unchanged
559    ///
560    /// # Example
561    /// ```rust,no_run
562    /// use agpm_cli::markdown::frontmatter::FrontmatterParser;
563    ///
564    /// let parser = FrontmatterParser::new();
565    /// let content = "---\nkey: {{ var }}\n---\n\nBody";
566    /// let boundaries = parser.get_frontmatter_boundaries(content).unwrap();
567    /// let rendered = "key: value";
568    /// let result = parser.replace_frontmatter(content, rendered, boundaries);
569    /// assert_eq!(result, "---\nkey: value\n---\n\nBody");
570    /// ```
571    pub fn replace_frontmatter(
572        &self,
573        original_content: &str,
574        rendered_frontmatter: &str,
575        boundaries: FrontmatterBoundaries,
576    ) -> String {
577        let before = &original_content[..boundaries.start];
578        let after = &original_content[boundaries.end..];
579
580        format!("{}---\n{}\n---\n{}", before, rendered_frontmatter.trim(), after)
581    }
582
583    /// Parse frontmatter from already-rendered full file content (Pass 2).
584    ///
585    /// This method extracts and parses frontmatter from content that has
586    /// already been through full-file template rendering, preserving accurate line numbers.
587    /// This is used for Pass 2 of the two-pass rendering system.
588    ///
589    /// # Arguments
590    /// * `rendered_content` - The fully rendered file content
591    /// * `file_path` - Path to file for error reporting
592    ///
593    /// # Returns
594    /// * `Result<ParsedFrontmatter<T>>` - Parsed result with accurate line numbers
595    pub fn parse_rendered_content<T>(
596        &self,
597        rendered_content: &str,
598        file_path: &Path,
599    ) -> Result<ParsedFrontmatter<T>>
600    where
601        T: DeserializeOwned,
602    {
603        // Extract frontmatter using existing YAML engine
604        let matter_result = self.yaml_matter.parse(rendered_content).with_context(|| {
605            format!("Failed to extract frontmatter from '{}'", file_path.display())
606        })?;
607
608        // Get raw frontmatter for line number tracking
609        let rendered_frontmatter = if matter_result.data.is_some() {
610            // Count lines before frontmatter to get accurate line numbers
611            let frontmatter_start = rendered_content.find("---").unwrap_or(0);
612            let lines_before = rendered_content[..frontmatter_start].lines().count();
613
614            // Store the raw frontmatter with line offset info
615            Some(RenderedFrontmatter {
616                content: serde_yaml::to_string(&matter_result.data.as_ref().unwrap())?,
617                line_offset: lines_before,
618            })
619        } else {
620            None
621        };
622
623        // Parse the structured data
624        let parsed_data = matter_result
625            .data
626            .map(|yaml_value| {
627                serde_yaml::from_value::<T>(yaml_value)
628                    .with_context(|| "Failed to deserialize frontmatter YAML")
629            })
630            .transpose()?;
631
632        Ok(ParsedFrontmatter {
633            data: parsed_data,
634            content: matter_result.content,
635            raw_frontmatter: rendered_frontmatter.as_ref().map(|rf| rf.content.clone()), // Use rendered frontmatter for has_frontmatter check
636            templated: true, // Always true since input is already rendered
637            rendered_frontmatter,
638            boundaries: self.get_frontmatter_boundaries(rendered_content),
639        })
640    }
641
642    /// Apply Tera templating to content.
643    ///
644    /// Always renders the content as a template to catch syntax errors.
645    /// If variant_inputs is provided, it's used for template variables.
646    /// Otherwise, renders with an empty context.
647    ///
648    /// # Arguments
649    /// * `content` - The content to template
650    /// * `variant_inputs` - Optional template variables (project, config, etc.)
651    /// * `file_path` - Path to file for error reporting
652    ///
653    /// # Returns
654    /// * `Result<String>` - Templated content or error
655    pub fn apply_templating(
656        &mut self,
657        content: &str,
658        variant_inputs: Option<&serde_json::Value>,
659        file_path: &Path,
660    ) -> Result<String> {
661        if let Some(inputs) = variant_inputs {
662            let context = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
663            self.template_renderer.render_template(content, &context, None).map_err(|e| {
664                anyhow::anyhow!(
665                    "Failed to render frontmatter template in '{}': {}",
666                    file_path.display(),
667                    e
668                )
669            })
670        } else {
671            // Render with empty context to catch syntax errors
672            let empty_context = TeraContext::new();
673            self.template_renderer.render_template(content, &empty_context, None).map_err(|e| {
674                anyhow::anyhow!(
675                    "Failed to render frontmatter template in '{}': {}",
676                    file_path.display(),
677                    e
678                )
679            })
680        }
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use tempfile::TempDir;
688
689    fn create_test_project_config() -> ProjectConfig {
690        let mut config_map = toml::map::Map::new();
691        config_map.insert("name".to_string(), toml::Value::String("test-project".into()));
692        config_map.insert("version".to_string(), toml::Value::String("1.0.0".into()));
693        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
694        ProjectConfig::from(config_map)
695    }
696
697    #[test]
698    fn test_frontmatter_templating_basic() -> Result<(), Box<dyn std::error::Error>> {
699        let temp_dir = TempDir::new()?;
700        let project_dir = temp_dir.path().to_path_buf();
701        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
702        let project_config = create_test_project_config();
703        let file_path = Path::new("test.md");
704
705        // Convert ProjectConfig to JSON Value for variant_inputs
706        let mut variant_inputs = serde_json::Map::new();
707        variant_inputs.insert("project".to_string(), project_config.to_json_value());
708        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
709
710        // Test simple template variable substitution
711        let content = "name: {{ project.name }}\nversion: {{ project.version }}";
712        let mut parser = FrontmatterParser::new();
713        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
714
715        let templated = result?;
716        assert!(templated.contains("name: test-project"));
717        assert!(templated.contains("version: 1.0.0"));
718        Ok(())
719    }
720
721    #[test]
722    fn test_frontmatter_templating_no_template_syntax() -> Result<(), Box<dyn std::error::Error>> {
723        let temp_dir = TempDir::new()?;
724        let project_dir = temp_dir.path().to_path_buf();
725        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
726        let project_config = create_test_project_config();
727        let file_path = Path::new("test.md");
728
729        // Convert ProjectConfig to JSON Value for variant_inputs
730        let mut variant_inputs = serde_json::Map::new();
731        variant_inputs.insert("project".to_string(), project_config.to_json_value());
732        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
733
734        // Test plain YAML without template syntax
735        let content = "name: static\nversion: 1.0.0";
736        let mut parser = FrontmatterParser::new();
737        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
738
739        let templated = result?;
740        assert_eq!(templated, content);
741        Ok(())
742    }
743
744    #[test]
745    fn test_frontmatter_templating_template_error() -> Result<(), Box<dyn std::error::Error>> {
746        let temp_dir = TempDir::new()?;
747        let project_dir = temp_dir.path().to_path_buf();
748        let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
749        let project_config = create_test_project_config();
750        let file_path = Path::new("test.md");
751
752        // Convert ProjectConfig to JSON Value for variant_inputs
753        let mut variant_inputs = serde_json::Map::new();
754        variant_inputs.insert("project".to_string(), project_config.to_json_value());
755        let variant_inputs_value = serde_json::Value::Object(variant_inputs);
756
757        // Test template with undefined variable
758        let content = "name: {{ undefined_var }}";
759        let mut parser = FrontmatterParser::new();
760        let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
761
762        assert!(result.is_err());
763        Ok(())
764    }
765
766    #[test]
767    fn test_frontmatter_parser_new() {
768        let parser = FrontmatterParser::new();
769        // Should not panic
770        assert!(parser.has_frontmatter("---\nkey: value\n---\ncontent"));
771        assert!(!parser.has_frontmatter("just content"));
772    }
773
774    #[test]
775    fn test_frontmatter_parser_with_project_dir() -> Result<()> {
776        let temp_dir = TempDir::new().unwrap();
777        FrontmatterParser::with_project_dir(temp_dir.path().to_path_buf())?;
778        Ok(())
779    }
780
781    #[test]
782    fn test_parsed_frontmatter_has_frontmatter() {
783        let parsed = ParsedFrontmatter::<serde_yaml::Value> {
784            data: None,
785            content: "content".to_string(),
786            raw_frontmatter: Some("key: value".to_string()),
787            templated: false,
788            rendered_frontmatter: None,
789            boundaries: None,
790        };
791        assert!(parsed.has_frontmatter());
792
793        let parsed_no_fm = ParsedFrontmatter::<serde_yaml::Value> {
794            data: None,
795            content: "content".to_string(),
796            raw_frontmatter: None,
797            templated: false,
798            rendered_frontmatter: None,
799            boundaries: None,
800        };
801        assert!(!parsed_no_fm.has_frontmatter());
802    }
803
804    #[test]
805    fn test_parse_rendered_content() -> Result<(), Box<dyn std::error::Error>> {
806        let parser = FrontmatterParser::new();
807        let file_path = Path::new("test.md");
808
809        // Test with rendered content that has frontmatter
810        let rendered_content = r#"---
811name: test-agent
812description: A test agent
813version: 1.0.0
814---
815
816# Test Agent Content
817
818This is the content of the agent.
819"#;
820
821        let parsed =
822            parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
823        assert!(parsed.has_frontmatter());
824        assert!(parsed.data.is_some());
825        assert!(parsed.rendered_frontmatter.is_some());
826        assert!(parsed.templated); // Should be true for rendered content
827        assert!(parsed.raw_frontmatter.is_some()); // Should be Some for rendered content
828
829        // Check line offset calculation
830        let rendered_fm = parsed.rendered_frontmatter.unwrap();
831        assert_eq!(rendered_fm.line_offset, 0); // No lines before frontmatter
832        assert!(rendered_fm.content.contains("name: test-agent"));
833        Ok(())
834    }
835
836    #[test]
837    fn test_parse_rendered_content_no_frontmatter() -> Result<(), Box<dyn std::error::Error>> {
838        let parser = FrontmatterParser::new();
839        let file_path = Path::new("test.md");
840
841        // Test with content that has no frontmatter
842        let rendered_content = r#"# Just Content
843
844This is content without frontmatter.
845"#;
846
847        let parsed =
848            parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
849        assert!(!parsed.has_frontmatter());
850        assert!(parsed.data.is_none());
851        assert!(parsed.rendered_frontmatter.is_none());
852        assert!(parsed.templated); // Still true since method assumes rendered input
853        Ok(())
854    }
855
856    #[test]
857    fn test_parse_rendered_content_with_preface() -> Result<(), Box<dyn std::error::Error>> {
858        let parser = FrontmatterParser::new();
859        let file_path = Path::new("test.md");
860
861        // Test with content that has lines before frontmatter
862        let rendered_content = r#"<!-- This is a comment line -->
863---
864name: test-agent
865version: 1.0.0
866---
867
868# Content
869"#;
870
871        // First test: Check if gray_matter can parse this
872        let yaml_matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
873        let matter_result = yaml_matter.parse::<serde_yaml::Value>(rendered_content);
874
875        // gray_matter doesn't recognize frontmatter when there's content before it
876        // So we need to handle this case differently
877        if matter_result.is_ok() && matter_result.unwrap().data.is_some() {
878            // If gray_matter can parse it, test parse_rendered_content
879            let parsed =
880                parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
881            assert!(parsed.has_frontmatter());
882
883            // Check line offset calculation - should be 1 line before frontmatter
884            let rendered_fm = parsed.rendered_frontmatter.unwrap();
885            assert_eq!(rendered_fm.line_offset, 1);
886        } else {
887            // If gray_matter can't parse frontmatter with content before it,
888            // that's expected behavior - skip this test case
889            println!(
890                "Note: gray_matter doesn't extract frontmatter when there's content before it"
891            );
892            println!("This is expected behavior for YAML frontmatter with preceding content");
893        }
894        Ok(())
895    }
896}