1use 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
67struct RawFrontmatter;
73
74impl Engine for RawFrontmatter {
75 fn parse(content: &str) -> Result<Pod, gray_matter::Error> {
76 Ok(Pod::String(content.to_string()))
78 }
79}
80
81#[derive(Debug, Clone)]
86pub struct ParsedFrontmatter<T> {
87 pub data: Option<T>,
89
90 pub content: String,
92
93 pub raw_frontmatter: Option<String>,
95
96 pub templated: bool,
98}
99
100impl<T> ParsedFrontmatter<T> {
101 pub fn has_frontmatter(&self) -> bool {
103 self.raw_frontmatter.is_some()
104 }
105}
106
107pub struct FrontmatterTemplating;
109
110impl FrontmatterTemplating {
111 pub fn build_template_context(project_config: &ProjectConfig) -> TeraContext {
122 let mut context = TeraContext::new();
123
124 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 context.insert("project", &project_config.to_json_value());
131
132 context
133 }
134
135 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 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 pub fn build_template_context_from_variant_inputs(
176 variant_inputs: &serde_json::Value,
177 ) -> TeraContext {
178 let mut context = TeraContext::new();
179
180 if let Some(obj) = variant_inputs.as_object() {
182 let mut agpm = serde_json::Map::new();
183
184 for (key, value) in obj {
185 context.insert(key, value);
187 agpm.insert(key.clone(), value.clone());
189 }
190
191 context.insert("agpm", &agpm);
192 }
193
194 context
195 }
196}
197
198pub 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 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 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 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 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 let raw_frontmatter_text = self.extract_raw_frontmatter(content);
285 let content_without_frontmatter = self.strip_frontmatter(content);
286
287 let (templated_frontmatter, was_templated) = if let Some(raw_fm) =
289 raw_frontmatter_text.as_ref()
290 {
291 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 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 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 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 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 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 pub fn has_frontmatter(&self, content: &str) -> bool {
410 if let Ok(result) = self.raw_matter.parse::<String>(content) {
412 result.data.is_some()
413 } else {
414 false
415 }
416 }
417
418 pub fn strip_frontmatter(&self, content: &str) -> String {
426 self.raw_matter
428 .parse::<String>(content)
429 .map(|result| result.content)
430 .unwrap_or_else(|_| content.to_string())
431 }
432
433 pub fn extract_raw_frontmatter(&self, content: &str) -> Option<String> {
441 match self.raw_matter.parse::<String>(content) {
443 Ok(result) => {
444 result.data.filter(|frontmatter_text| !frontmatter_text.is_empty())
446 }
447 Err(_) => None,
448 }
449 }
450
451 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 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 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 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 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 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 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 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 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}