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}