use anyhow::{Context, Result};
use gray_matter::{
Matter, Pod,
engine::{Engine, YAML},
};
use serde::de::DeserializeOwned;
use std::fmt::Debug;
use std::path::Path;
use tera::Context as TeraContext;
use crate::core::OperationContext;
use crate::manifest::ProjectConfig;
use crate::templating::TemplateRenderer;
struct RawFrontmatter;
impl Engine for RawFrontmatter {
fn parse(content: &str) -> Result<Pod, gray_matter::Error> {
Ok(Pod::String(content.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct ParsedFrontmatter<T> {
pub data: Option<T>,
pub content: String,
pub raw_frontmatter: Option<String>,
pub templated: bool,
pub rendered_frontmatter: Option<RenderedFrontmatter>,
pub boundaries: Option<FrontmatterBoundaries>,
}
#[derive(Debug, Clone)]
pub struct RenderedFrontmatter {
pub content: String,
pub line_offset: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct FrontmatterBoundaries {
pub start: usize,
pub end: usize,
}
impl<T> ParsedFrontmatter<T> {
pub fn has_frontmatter(&self) -> bool {
self.raw_frontmatter.is_some()
}
}
pub struct FrontmatterTemplating;
impl FrontmatterTemplating {
pub fn build_template_context(project_config: &ProjectConfig) -> TeraContext {
let mut context = TeraContext::new();
let mut agpm = serde_json::Map::new();
agpm.insert("project".to_string(), project_config.to_json_value());
context.insert("agpm", &agpm);
context.insert("project", &project_config.to_json_value());
context
}
pub fn apply_templating(
content: &str,
project_config: &ProjectConfig,
template_renderer: &mut TemplateRenderer,
file_path: &Path,
) -> Result<String> {
let context = Self::build_template_context(project_config);
template_renderer.render_template(content, &context, None).map_err(|e| {
anyhow::anyhow!(
"Failed to render frontmatter template in '{}': {}",
file_path.display(),
e
)
})
}
pub fn build_template_context_from_variant_inputs(
variant_inputs: &serde_json::Value,
) -> TeraContext {
let mut context = TeraContext::new();
if let Some(obj) = variant_inputs.as_object() {
let mut agpm = serde_json::Map::new();
for (key, value) in obj {
context.insert(key, value);
agpm.insert(key.clone(), value.clone());
}
context.insert("agpm", &agpm);
}
context
}
}
pub struct FrontmatterParser {
raw_matter: Matter<RawFrontmatter>,
yaml_matter: Matter<YAML>,
template_renderer: TemplateRenderer,
}
impl Clone for FrontmatterParser {
fn clone(&self) -> Self {
Self::new()
}
}
impl Debug for FrontmatterParser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FrontmatterParser").finish()
}
}
impl Default for FrontmatterParser {
fn default() -> Self {
Self::new()
}
}
impl FrontmatterParser {
pub fn new() -> Self {
let project_dir = std::env::current_dir().unwrap_or_default();
let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)
.unwrap_or_else(|_| {
TemplateRenderer::new(false, project_dir, None).unwrap()
});
Self {
raw_matter: Matter::new(),
yaml_matter: Matter::new(),
template_renderer,
}
}
pub fn with_project_dir(project_dir: std::path::PathBuf) -> Result<Self> {
let template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
Ok(Self {
raw_matter: Matter::new(),
yaml_matter: Matter::new(),
template_renderer,
})
}
pub fn parse_with_templating<T>(
&mut self,
content: &str,
variant_inputs: Option<&serde_json::Value>,
file_path: &Path,
context: Option<&OperationContext>,
) -> Result<ParsedFrontmatter<T>>
where
T: DeserializeOwned,
{
let raw_frontmatter_text = self.extract_raw_frontmatter(content);
let content_without_frontmatter = self.strip_frontmatter(content);
let (templated_frontmatter, was_templated) = if let Some(raw_fm) =
raw_frontmatter_text.as_ref()
{
let templated = if let Some(inputs) = variant_inputs {
let ctx = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
self.template_renderer.render_template(raw_fm, &ctx, None).map_err(|e| {
anyhow::anyhow!(
"Failed to render frontmatter template in '{}': {}",
file_path.display(),
e
)
})?
} else {
let empty_context = TeraContext::new();
self.template_renderer.render_template(raw_fm, &empty_context, None).map_err(
|e| {
anyhow::anyhow!(
"Failed to render frontmatter template in '{}': {}",
file_path.display(),
e
)
},
)?
};
(Some(templated), true)
} else {
(None, false)
};
let parsed_data = if let Some(frontmatter) = templated_frontmatter {
#[allow(clippy::needless_borrow)]
match serde_yaml::from_str::<T>(&frontmatter) {
Ok(data) => Some(data),
Err(e) => {
if let Some(ctx) = context {
if ctx.should_warn_file(file_path) {
eprintln!(
"Warning: Unable to parse YAML frontmatter in '{}'.
The document will be processed without metadata, and any declared dependencies
will NOT be resolved or installed.
Parse error: {}
For the correct dependency format, see:
https://github.com/aig787/agpm#transitive-dependencies",
file_path.display(),
e
);
}
}
None
}
}
} else {
None
};
Ok(ParsedFrontmatter {
data: parsed_data,
content: content_without_frontmatter,
raw_frontmatter: raw_frontmatter_text,
templated: was_templated,
rendered_frontmatter: None,
boundaries: self.get_frontmatter_boundaries(content),
})
}
pub fn parse<T>(&self, content: &str) -> Result<ParsedFrontmatter<T>>
where
T: DeserializeOwned,
{
let matter_result = self.yaml_matter.parse(content)?;
let raw_frontmatter = matter_result
.data
.map(|data: serde_yaml::Value| serde_yaml::to_string(&data).unwrap_or_default());
let content_without_frontmatter = matter_result.content;
let parsed_data = if let Some(frontmatter) = raw_frontmatter.as_ref() {
match serde_yaml::from_str::<T>(frontmatter) {
Ok(data) => Some(data),
Err(e) => {
eprintln!(
"Warning: Unable to parse YAML frontmatter.
Parse error: {}
The document will be processed without metadata.",
e
);
None
}
}
} else {
None
};
Ok(ParsedFrontmatter {
data: parsed_data,
content: content_without_frontmatter,
raw_frontmatter,
templated: false,
rendered_frontmatter: None,
boundaries: self.get_frontmatter_boundaries(content),
})
}
pub fn has_frontmatter(&self, content: &str) -> bool {
if let Ok(result) = self.raw_matter.parse::<String>(content) {
result.data.is_some()
} else {
false
}
}
pub fn strip_frontmatter(&self, content: &str) -> String {
self.raw_matter
.parse::<String>(content)
.map(|result| result.content)
.unwrap_or_else(|_| content.to_string())
}
pub fn extract_raw_frontmatter(&self, content: &str) -> Option<String> {
match self.raw_matter.parse::<String>(content) {
Ok(result) => {
result.data.filter(|frontmatter_text| !frontmatter_text.is_empty())
}
Err(_) => None,
}
}
pub fn get_frontmatter_boundaries(&self, content: &str) -> Option<FrontmatterBoundaries> {
let first_delim = content.find("---")?;
if !content[..first_delim].trim().is_empty() {
return None;
}
let after_first_delim = first_delim + 3;
let first_line_end = content[after_first_delim..]
.find('\n')
.map(|pos| after_first_delim + pos + 1)
.unwrap_or(content.len());
let closing_delim_start = content[first_line_end..].find("---")?;
let closing_delim_pos = first_line_end + closing_delim_start;
let after_closing = closing_delim_pos + 3;
let end_pos = content[after_closing..]
.find('\n')
.map(|pos| after_closing + pos + 1)
.unwrap_or(content.len());
Some(FrontmatterBoundaries {
start: first_delim,
end: end_pos,
})
}
pub fn replace_frontmatter(
&self,
original_content: &str,
rendered_frontmatter: &str,
boundaries: FrontmatterBoundaries,
) -> String {
let before = &original_content[..boundaries.start];
let after = &original_content[boundaries.end..];
format!("{}---\n{}\n---\n{}", before, rendered_frontmatter.trim(), after)
}
pub fn parse_rendered_content<T>(
&self,
rendered_content: &str,
file_path: &Path,
) -> Result<ParsedFrontmatter<T>>
where
T: DeserializeOwned,
{
let matter_result = self.yaml_matter.parse(rendered_content).with_context(|| {
format!("Failed to extract frontmatter from '{}'", file_path.display())
})?;
let rendered_frontmatter = if matter_result.data.is_some() {
let frontmatter_start = rendered_content.find("---").unwrap_or(0);
let lines_before = rendered_content[..frontmatter_start].lines().count();
Some(RenderedFrontmatter {
content: serde_yaml::to_string(&matter_result.data.as_ref().unwrap())?,
line_offset: lines_before,
})
} else {
None
};
let parsed_data = matter_result
.data
.map(|yaml_value| {
serde_yaml::from_value::<T>(yaml_value)
.with_context(|| "Failed to deserialize frontmatter YAML")
})
.transpose()?;
Ok(ParsedFrontmatter {
data: parsed_data,
content: matter_result.content,
raw_frontmatter: rendered_frontmatter.as_ref().map(|rf| rf.content.clone()), templated: true, rendered_frontmatter,
boundaries: self.get_frontmatter_boundaries(rendered_content),
})
}
pub fn apply_templating(
&mut self,
content: &str,
variant_inputs: Option<&serde_json::Value>,
file_path: &Path,
) -> Result<String> {
if let Some(inputs) = variant_inputs {
let context = FrontmatterTemplating::build_template_context_from_variant_inputs(inputs);
self.template_renderer.render_template(content, &context, None).map_err(|e| {
anyhow::anyhow!(
"Failed to render frontmatter template in '{}': {}",
file_path.display(),
e
)
})
} else {
let empty_context = TeraContext::new();
self.template_renderer.render_template(content, &empty_context, None).map_err(|e| {
anyhow::anyhow!(
"Failed to render frontmatter template in '{}': {}",
file_path.display(),
e
)
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_project_config() -> ProjectConfig {
let mut config_map = toml::map::Map::new();
config_map.insert("name".to_string(), toml::Value::String("test-project".into()));
config_map.insert("version".to_string(), toml::Value::String("1.0.0".into()));
config_map.insert("language".to_string(), toml::Value::String("rust".into()));
ProjectConfig::from(config_map)
}
#[test]
fn test_frontmatter_templating_basic() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
let project_config = create_test_project_config();
let file_path = Path::new("test.md");
let mut variant_inputs = serde_json::Map::new();
variant_inputs.insert("project".to_string(), project_config.to_json_value());
let variant_inputs_value = serde_json::Value::Object(variant_inputs);
let content = "name: {{ project.name }}\nversion: {{ project.version }}";
let mut parser = FrontmatterParser::new();
let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
let templated = result?;
assert!(templated.contains("name: test-project"));
assert!(templated.contains("version: 1.0.0"));
Ok(())
}
#[test]
fn test_frontmatter_templating_no_template_syntax() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
let project_config = create_test_project_config();
let file_path = Path::new("test.md");
let mut variant_inputs = serde_json::Map::new();
variant_inputs.insert("project".to_string(), project_config.to_json_value());
let variant_inputs_value = serde_json::Value::Object(variant_inputs);
let content = "name: static\nversion: 1.0.0";
let mut parser = FrontmatterParser::new();
let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
let templated = result?;
assert_eq!(templated, content);
Ok(())
}
#[test]
fn test_frontmatter_templating_template_error() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None)?;
let project_config = create_test_project_config();
let file_path = Path::new("test.md");
let mut variant_inputs = serde_json::Map::new();
variant_inputs.insert("project".to_string(), project_config.to_json_value());
let variant_inputs_value = serde_json::Value::Object(variant_inputs);
let content = "name: {{ undefined_var }}";
let mut parser = FrontmatterParser::new();
let result = parser.apply_templating(content, Some(&variant_inputs_value), file_path);
assert!(result.is_err());
Ok(())
}
#[test]
fn test_frontmatter_parser_new() {
let parser = FrontmatterParser::new();
assert!(parser.has_frontmatter("---\nkey: value\n---\ncontent"));
assert!(!parser.has_frontmatter("just content"));
}
#[test]
fn test_frontmatter_parser_with_project_dir() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
FrontmatterParser::with_project_dir(temp_dir.path().to_path_buf())?;
Ok(())
}
#[test]
fn test_parsed_frontmatter_has_frontmatter() {
let parsed = ParsedFrontmatter::<serde_yaml::Value> {
data: None,
content: "content".to_string(),
raw_frontmatter: Some("key: value".to_string()),
templated: false,
rendered_frontmatter: None,
boundaries: None,
};
assert!(parsed.has_frontmatter());
let parsed_no_fm = ParsedFrontmatter::<serde_yaml::Value> {
data: None,
content: "content".to_string(),
raw_frontmatter: None,
templated: false,
rendered_frontmatter: None,
boundaries: None,
};
assert!(!parsed_no_fm.has_frontmatter());
}
#[test]
fn test_parse_rendered_content() -> Result<(), Box<dyn std::error::Error>> {
let parser = FrontmatterParser::new();
let file_path = Path::new("test.md");
let rendered_content = r#"---
name: test-agent
description: A test agent
version: 1.0.0
---
# Test Agent Content
This is the content of the agent.
"#;
let parsed =
parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
assert!(parsed.has_frontmatter());
assert!(parsed.data.is_some());
assert!(parsed.rendered_frontmatter.is_some());
assert!(parsed.templated); assert!(parsed.raw_frontmatter.is_some());
let rendered_fm = parsed.rendered_frontmatter.unwrap();
assert_eq!(rendered_fm.line_offset, 0); assert!(rendered_fm.content.contains("name: test-agent"));
Ok(())
}
#[test]
fn test_parse_rendered_content_no_frontmatter() -> Result<(), Box<dyn std::error::Error>> {
let parser = FrontmatterParser::new();
let file_path = Path::new("test.md");
let rendered_content = r#"# Just Content
This is content without frontmatter.
"#;
let parsed =
parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
assert!(!parsed.has_frontmatter());
assert!(parsed.data.is_none());
assert!(parsed.rendered_frontmatter.is_none());
assert!(parsed.templated); Ok(())
}
#[test]
fn test_parse_rendered_content_with_preface() -> Result<(), Box<dyn std::error::Error>> {
let parser = FrontmatterParser::new();
let file_path = Path::new("test.md");
let rendered_content = r#"<!-- This is a comment line -->
---
name: test-agent
version: 1.0.0
---
# Content
"#;
let yaml_matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let matter_result = yaml_matter.parse::<serde_yaml::Value>(rendered_content);
if matter_result.is_ok() && matter_result.unwrap().data.is_some() {
let parsed =
parser.parse_rendered_content::<serde_yaml::Value>(rendered_content, file_path)?;
assert!(parsed.has_frontmatter());
let rendered_fm = parsed.rendered_frontmatter.unwrap();
assert_eq!(rendered_fm.line_offset, 1);
} else {
println!(
"Note: gray_matter doesn't extract frontmatter when there's content before it"
);
println!("This is expected behavior for YAML frontmatter with preceding content");
}
Ok(())
}
}