use anyhow::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,
}
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).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).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).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(ref frontmatter) = templated_frontmatter {
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,
})
}
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,
})
}
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 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).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).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() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
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);
assert!(result.is_ok());
let templated = result.unwrap();
assert!(templated.contains("name: test-project"));
assert!(templated.contains("version: 1.0.0"));
}
#[test]
fn test_frontmatter_templating_no_template_syntax() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
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);
assert!(result.is_ok());
let templated = result.unwrap();
assert_eq!(templated, content);
}
#[test]
fn test_frontmatter_templating_template_error() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().to_path_buf();
let _template_renderer = TemplateRenderer::new(true, project_dir.clone(), None).unwrap();
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());
}
#[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() {
let temp_dir = TempDir::new().unwrap();
let parser = FrontmatterParser::with_project_dir(temp_dir.path().to_path_buf());
assert!(parser.is_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,
};
assert!(parsed.has_frontmatter());
let parsed_no_fm = ParsedFrontmatter::<serde_yaml::Value> {
data: None,
content: "content".to_string(),
raw_frontmatter: None,
templated: false,
};
assert!(!parsed_no_fm.has_frontmatter());
}
}