agpm_cli/metadata/
extractor.rs

1//! Extract dependency metadata from resource files.
2//!
3//! This module handles the extraction of transitive dependency information
4//! from resource files. Supports YAML frontmatter in Markdown files and
5//! JSON fields in JSON configuration files.
6//!
7//! # Template Support
8//!
9//! When a `ProjectConfig` is provided, frontmatter is rendered as a Tera template
10//! before parsing. This allows dependency paths to reference project variables:
11//!
12//! ```yaml
13//! dependencies:
14//!   snippets:
15//!     - path: standards/{{ agpm.project.language }}-guide.md
16//! ```
17
18use anyhow::{Context, Result};
19use serde_json::{Map, Value as JsonValue};
20use std::collections::HashMap;
21use std::path::Path;
22use tera::{Context as TeraContext, Tera};
23
24use crate::manifest::{DependencyMetadata, ProjectConfig};
25
26/// Metadata extractor for resource files.
27///
28/// Extracts dependency information embedded in resource files:
29/// - Markdown files (.md): YAML frontmatter between `---` delimiters
30/// - JSON files (.json): `dependencies` field in the JSON structure
31/// - Other files: No dependencies supported
32pub struct MetadataExtractor;
33
34impl MetadataExtractor {
35    /// Extract dependency metadata from a file's content.
36    ///
37    /// # Arguments
38    /// * `path` - Path to the file (used to determine file type)
39    /// * `content` - Content of the file
40    /// * `project_config` - Optional project configuration for template rendering
41    ///
42    /// # Returns
43    /// * `DependencyMetadata` - Extracted metadata (may be empty)
44    ///
45    /// # Template Support
46    ///
47    /// If `project_config` is provided, frontmatter is rendered as a Tera template
48    /// before parsing, allowing references to project variables like:
49    /// `{{ agpm.project.language }}`
50    pub fn extract(
51        path: &Path,
52        content: &str,
53        project_config: Option<&ProjectConfig>,
54    ) -> Result<DependencyMetadata> {
55        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
56
57        match extension {
58            "md" => Self::extract_markdown_frontmatter(content, project_config, path),
59            "json" => Self::extract_json_field(content, project_config, path),
60            _ => {
61                // Scripts and other files don't support embedded dependencies
62                Ok(DependencyMetadata::default())
63            }
64        }
65    }
66
67    /// Extract YAML frontmatter from Markdown content.
68    ///
69    /// Looks for content between `---` delimiters at the start of the file.
70    /// Uses two-phase extraction to respect per-resource templating settings.
71    fn extract_markdown_frontmatter(
72        content: &str,
73        project_config: Option<&ProjectConfig>,
74        path: &Path,
75    ) -> Result<DependencyMetadata> {
76        // Check if content starts with frontmatter delimiter
77        if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
78            return Ok(DependencyMetadata::default());
79        }
80
81        // Find the end of frontmatter
82        let search_start = if content.starts_with("---\n") {
83            4
84        } else {
85            5
86        };
87
88        let end_pattern = if content.contains("\r\n") {
89            "\r\n---\r\n"
90        } else {
91            "\n---\n"
92        };
93
94        if let Some(end_pos) = content[search_start..].find(end_pattern) {
95            let frontmatter = &content[search_start..search_start + end_pos];
96
97            // Phase 1: Check if templating is disabled via agpm.templating field
98            let templating_disabled = if let Some(_config) = project_config {
99                Self::is_templating_disabled_yaml(frontmatter)
100            } else {
101                false
102            };
103
104            // Phase 2: Template the frontmatter if config available and not disabled
105            let templated_frontmatter = if let Some(config) = project_config {
106                if templating_disabled {
107                    tracing::debug!("Templating disabled via agpm.templating field in frontmatter");
108                    frontmatter.to_string()
109                } else {
110                    Self::template_content(frontmatter, config, path)?
111                }
112            } else {
113                frontmatter.to_string()
114            };
115
116            // Parse YAML frontmatter
117            match serde_yaml::from_str::<DependencyMetadata>(&templated_frontmatter) {
118                Ok(metadata) => {
119                    // Validate resource types (catch tool names used as types)
120                    Self::validate_resource_types(&metadata, path)?;
121                    Ok(metadata)
122                }
123                Err(e) => {
124                    // Provide detailed error message for common issues
125                    let error_msg = e.to_string();
126                    if error_msg.contains("unknown field") {
127                        tracing::warn!(
128                            "Warning: YAML frontmatter contains unknown field(s): {}. \
129                            Supported fields are: path, version, tool",
130                            e
131                        );
132                        eprintln!(
133                            "Warning: YAML frontmatter contains unknown field(s).\n\
134                            Supported fields in dependencies are:\n\
135                            - path: Path to the dependency file (required)\n\
136                            - version: Version constraint (optional)\n\
137                            - tool: Target tool (optional: claude-code, opencode, agpm)\n\
138                            \nError: {}",
139                            e
140                        );
141                    } else {
142                        tracing::warn!("Warning: Unable to parse YAML frontmatter: {}", e);
143                        eprintln!("Warning: Unable to parse YAML frontmatter: {}", e);
144                    }
145                    Ok(DependencyMetadata::default())
146                }
147            }
148        } else {
149            // No closing delimiter found
150            Ok(DependencyMetadata::default())
151        }
152    }
153
154    /// Extract dependencies field from JSON content.
155    ///
156    /// Looks for a `dependencies` field in the top-level JSON object.
157    /// Uses two-phase extraction to respect per-resource templating settings.
158    fn extract_json_field(
159        content: &str,
160        project_config: Option<&ProjectConfig>,
161        path: &Path,
162    ) -> Result<DependencyMetadata> {
163        // Phase 1: Check if templating is disabled via agpm.templating field
164        let templating_disabled = if let Some(_config) = project_config {
165            Self::is_templating_disabled_json(content)
166        } else {
167            false
168        };
169
170        // Phase 2: Template the content if config available and not disabled
171        let templated_content = if let Some(config) = project_config {
172            if templating_disabled {
173                tracing::debug!("Templating disabled via agpm.templating field in JSON");
174                content.to_string()
175            } else {
176                Self::template_content(content, config, path)?
177            }
178        } else {
179            content.to_string()
180        };
181
182        let json: JsonValue = serde_json::from_str(&templated_content)
183            .with_context(|| "Failed to parse JSON content")?;
184
185        if let Some(deps) = json.get("dependencies") {
186            // The dependencies field should match our expected structure
187            match serde_json::from_value::<HashMap<String, Vec<crate::manifest::DependencySpec>>>(
188                deps.clone(),
189            ) {
190                Ok(dependencies) => {
191                    let metadata = DependencyMetadata {
192                        dependencies: Some(dependencies),
193                    };
194                    // Validate resource types (catch tool names used as types)
195                    Self::validate_resource_types(&metadata, path)?;
196                    Ok(metadata)
197                }
198                Err(e) => {
199                    // Provide detailed error message for common issues
200                    let error_msg = e.to_string();
201                    if error_msg.contains("unknown field") {
202                        tracing::warn!(
203                            "Warning: JSON dependencies contain unknown field(s): {}. \
204                            Supported fields are: path, version, tool",
205                            e
206                        );
207                        eprintln!(
208                            "Warning: JSON dependencies contain unknown field(s).\n\
209                            Supported fields in dependencies are:\n\
210                            - path: Path to the dependency file (required)\n\
211                            - version: Version constraint (optional)\n\
212                            - tool: Target tool (optional: claude-code, opencode, agpm)\n\
213                            \nError: {}",
214                            e
215                        );
216                    } else {
217                        tracing::warn!("Warning: Unable to parse dependencies field: {}", e);
218                        eprintln!("Warning: Unable to parse dependencies field: {}", e);
219                    }
220                    Ok(DependencyMetadata::default())
221                }
222            }
223        } else {
224            Ok(DependencyMetadata::default())
225        }
226    }
227
228    /// Check if templating is disabled in YAML frontmatter.
229    ///
230    /// Parses the YAML to check for `agpm.templating: false` field.
231    /// Templating is opt-in: disabled by default unless explicitly set to true.
232    fn is_templating_disabled_yaml(frontmatter: &str) -> bool {
233        // Try to parse as raw YAML value to check agpm.templating field
234        if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
235            value
236                .get("agpm")
237                .and_then(|agpm| agpm.get("templating"))
238                .and_then(|v| v.as_bool())
239                .map(|b| !b)
240                .unwrap_or(true) // Opt-in: disabled by default
241        } else {
242            true // Opt-in: disabled by default
243        }
244    }
245
246    /// Check if templating is disabled in JSON content.
247    ///
248    /// Parses the JSON to check for `agpm.templating: false` field.
249    /// Templating is opt-in: disabled by default unless explicitly set to true.
250    fn is_templating_disabled_json(content: &str) -> bool {
251        // Try to parse JSON to check agpm.templating field
252        if let Ok(json) = serde_json::from_str::<JsonValue>(content) {
253            json.get("agpm")
254                .and_then(|agpm| agpm.get("templating"))
255                .and_then(|v| v.as_bool())
256                .map(|b| !b)
257                .unwrap_or(true) // Opt-in: disabled by default
258        } else {
259            true // Opt-in: disabled by default
260        }
261    }
262
263    /// Template content using project variables.
264    ///
265    /// Renders the content as a Tera template with project variables available
266    /// under `agpm.project.*`.
267    ///
268    /// # Arguments
269    ///
270    /// * `content` - The content to template
271    /// * `project_config` - Project configuration containing template variables
272    ///
273    /// # Returns
274    ///
275    /// Templated content string, or an error if templating fails
276    ///
277    /// # Error Handling
278    ///
279    /// If a template variable is undefined, returns an error with a helpful message.
280    /// Use Tera's `default` filter for optional variables:
281    /// ```yaml
282    /// path: standards/{{ agpm.project.language | default(value="generic") }}-guide.md
283    /// ```
284    fn template_content(
285        content: &str,
286        project_config: &ProjectConfig,
287        path: &Path,
288    ) -> Result<String> {
289        // Only template if content contains template syntax
290        if !content.contains("{{") && !content.contains("{%") {
291            return Ok(content.to_string());
292        }
293
294        let mut tera = Tera::default();
295        tera.autoescape_on(vec![]); // Disable autoescaping for raw content
296
297        let mut context = TeraContext::new();
298
299        // Build agpm.project context (same structure as content templates)
300        let mut agpm = Map::new();
301        agpm.insert("project".to_string(), project_config.to_json_value());
302        context.insert("agpm", &agpm);
303
304        // Render template - errors (including undefined vars) are returned to caller
305        tera.render_str(content, &context).map_err(|e| {
306            // Extract detailed error information from Tera error
307            let error_details = Self::format_tera_error(&e);
308
309            anyhow::Error::new(e).context(format!(
310                "Failed to render frontmatter template in '{}'.\n\
311                 Error details:\n{}\n\n\
312                 Hint: Use {{{{ var | default(value=\"fallback\") }}}} for optional variables",
313                path.display(),
314                error_details
315            ))
316        })
317    }
318
319    /// Format a Tera error with detailed information about what went wrong.
320    ///
321    /// Tera errors can contain various types of issues:
322    /// - Missing variables (e.g., "Variable `foo` not found")
323    /// - Syntax errors (e.g., "Unexpected end of template")
324    /// - Filter/function errors (e.g., "Filter `unknown` not found")
325    ///
326    /// This function extracts the root cause and formats it in a user-friendly way,
327    /// filtering out unhelpful internal template names like '__tera_one_off'.
328    ///
329    /// # Arguments
330    ///
331    /// * `error` - The Tera error to format
332    fn format_tera_error(error: &tera::Error) -> String {
333        use std::error::Error;
334
335        let mut messages = Vec::new();
336
337        // Walk the entire error chain and collect all messages
338        let mut all_messages = vec![error.to_string()];
339        let mut current_error: Option<&dyn Error> = error.source();
340        while let Some(err) = current_error {
341            all_messages.push(err.to_string());
342            current_error = err.source();
343        }
344
345        // Process messages to extract useful information
346        for msg in all_messages {
347            // Clean up the message by removing internal template names
348            let cleaned = msg
349                .replace("while rendering '__tera_one_off'", "")
350                .replace("Failed to render '__tera_one_off'", "Template rendering failed")
351                .replace("Failed to parse '__tera_one_off'", "Template syntax error")
352                .replace("'__tera_one_off'", "template")
353                .trim()
354                .to_string();
355
356            // Only keep non-empty, useful messages
357            if !cleaned.is_empty()
358                && cleaned != "Template rendering failed"
359                && cleaned != "Template syntax error"
360            {
361                messages.push(cleaned);
362            }
363        }
364
365        // If we got useful messages, return them
366        if !messages.is_empty() {
367            messages.join("\n  → ")
368        } else {
369            // Fallback: extract just the error kind
370            "Template syntax error (see details above)".to_string()
371        }
372    }
373
374    /// Validate that resource type names are correct (not tool names).
375    ///
376    /// Common mistake: using tool names (claude-code, opencode) as section headers
377    /// instead of resource types (agents, snippets, commands).
378    ///
379    /// # Arguments
380    /// * `metadata` - The metadata to validate
381    /// * `file_path` - Path to the file being validated (for error messages)
382    ///
383    /// # Returns
384    /// * `Ok(())` if validation passes
385    /// * `Err` with helpful error message if tool names detected
386    fn validate_resource_types(metadata: &DependencyMetadata, file_path: &Path) -> Result<()> {
387        const VALID_RESOURCE_TYPES: &[&str] =
388            &["agents", "commands", "snippets", "hooks", "mcp-servers", "scripts"];
389        const TOOL_NAMES: &[&str] = &["claude-code", "opencode", "agpm"];
390
391        if let Some(ref dependencies) = metadata.dependencies {
392            for resource_type in dependencies.keys() {
393                if !VALID_RESOURCE_TYPES.contains(&resource_type.as_str()) {
394                    if TOOL_NAMES.contains(&resource_type.as_str()) {
395                        // Specific error for tool name confusion
396                        anyhow::bail!(
397                            "Invalid resource type '{}' in dependencies section of '{}'.\n\n\
398                            You used a tool name ('{}') as a section header, but AGPM expects resource types.\n\n\
399                            ✗ Wrong:\n  dependencies:\n    {}:\n      - path: ...\n\n\
400                            ✓ Correct:\n  dependencies:\n    agents:  # or snippets, commands, etc.\n      - path: ...\n        tool: {}  # Specify tool here\n\n\
401                            Valid resource types: {}",
402                            resource_type,
403                            file_path.display(),
404                            resource_type,
405                            resource_type,
406                            resource_type,
407                            VALID_RESOURCE_TYPES.join(", ")
408                        );
409                    } else {
410                        // Generic error for unknown types
411                        anyhow::bail!(
412                            "Unknown resource type '{}' in dependencies section of '{}'.\n\
413                            Valid resource types: {}",
414                            resource_type,
415                            file_path.display(),
416                            VALID_RESOURCE_TYPES.join(", ")
417                        );
418                    }
419                }
420            }
421        }
422        Ok(())
423    }
424
425    /// Extract metadata from file content without knowing the file type.
426    ///
427    /// Tries to detect the format automatically.
428    pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
429        use std::path::PathBuf;
430
431        // Try YAML frontmatter first (for Markdown)
432        if (content.starts_with("---\n") || content.starts_with("---\r\n"))
433            && let Ok(metadata) =
434                Self::extract_markdown_frontmatter(content, None, &PathBuf::from("unknown.md"))
435            && metadata.has_dependencies()
436        {
437            return Ok(metadata);
438        }
439
440        // Try JSON format
441        if content.trim_start().starts_with('{')
442            && let Ok(metadata) =
443                Self::extract_json_field(content, None, &PathBuf::from("unknown.json"))
444            && metadata.has_dependencies()
445        {
446            return Ok(metadata);
447        }
448
449        // No metadata found
450        Ok(DependencyMetadata::default())
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_extract_markdown_frontmatter() {
460        let content = r#"---
461dependencies:
462  agents:
463    - path: agents/helper.md
464      version: v1.0.0
465    - path: agents/reviewer.md
466  snippets:
467    - path: snippets/utils.md
468---
469
470# My Command
471
472This is the command documentation."#;
473
474        let path = Path::new("command.md");
475        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
476
477        assert!(metadata.has_dependencies());
478        let deps = metadata.dependencies.unwrap();
479        assert_eq!(deps["agents"].len(), 2);
480        assert_eq!(deps["snippets"].len(), 1);
481        assert_eq!(deps["agents"][0].path, "agents/helper.md");
482        assert_eq!(deps["agents"][0].version, Some("v1.0.0".to_string()));
483    }
484
485    #[test]
486    fn test_extract_markdown_no_frontmatter() {
487        let content = r#"# My Command
488
489This is a command without frontmatter."#;
490
491        let path = Path::new("command.md");
492        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
493
494        assert!(!metadata.has_dependencies());
495    }
496
497    #[test]
498    fn test_extract_json_dependencies() {
499        let content = r#"{
500  "events": ["UserPromptSubmit"],
501  "type": "command",
502  "command": ".claude/scripts/test.js",
503  "dependencies": {
504    "scripts": [
505      { "path": "scripts/test-runner.sh", "version": "v1.0.0" },
506      { "path": "scripts/validator.py" }
507    ],
508    "agents": [
509      { "path": "agents/code-analyzer.md", "version": "~1.2.0" }
510    ]
511  }
512}"#;
513
514        let path = Path::new("hook.json");
515        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
516
517        assert!(metadata.has_dependencies());
518        let deps = metadata.dependencies.unwrap();
519        assert_eq!(deps["scripts"].len(), 2);
520        assert_eq!(deps["agents"].len(), 1);
521        assert_eq!(deps["scripts"][0].path, "scripts/test-runner.sh");
522        assert_eq!(deps["scripts"][0].version, Some("v1.0.0".to_string()));
523    }
524
525    #[test]
526    fn test_extract_json_no_dependencies() {
527        let content = r#"{
528  "command": "npx",
529  "args": ["-y", "@modelcontextprotocol/server-github"]
530}"#;
531
532        let path = Path::new("mcp.json");
533        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
534
535        assert!(!metadata.has_dependencies());
536    }
537
538    #[test]
539    fn test_extract_script_file() {
540        let content = r#"#!/bin/bash
541echo "This is a script file"
542# Scripts don't support dependencies"#;
543
544        let path = Path::new("script.sh");
545        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
546
547        assert!(!metadata.has_dependencies());
548    }
549
550    #[test]
551    fn test_extract_auto_markdown() {
552        let content = r#"---
553dependencies:
554  agents:
555    - path: agents/test.md
556---
557
558# Content"#;
559
560        let metadata = MetadataExtractor::extract_auto(content).unwrap();
561        assert!(metadata.has_dependencies());
562        assert_eq!(metadata.dependency_count(), 1);
563    }
564
565    #[test]
566    fn test_extract_auto_json() {
567        let content = r#"{
568  "dependencies": {
569    "snippets": [
570      { "path": "snippets/test.md" }
571    ]
572  }
573}"#;
574
575        let metadata = MetadataExtractor::extract_auto(content).unwrap();
576        assert!(metadata.has_dependencies());
577        assert_eq!(metadata.dependency_count(), 1);
578    }
579
580    #[test]
581    fn test_windows_line_endings() {
582        let content = "---\r\ndependencies:\r\n  agents:\r\n    - path: agents/test.md\r\n---\r\n\r\n# Content";
583
584        let path = Path::new("command.md");
585        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
586
587        assert!(metadata.has_dependencies());
588        let deps = metadata.dependencies.unwrap();
589        assert_eq!(deps["agents"].len(), 1);
590        assert_eq!(deps["agents"][0].path, "agents/test.md");
591    }
592
593    #[test]
594    fn test_empty_dependencies() {
595        let content = r#"---
596dependencies:
597---
598
599# Content"#;
600
601        let path = Path::new("command.md");
602        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
603
604        // Should parse successfully but have no dependencies
605        assert!(!metadata.has_dependencies());
606    }
607
608    #[test]
609    fn test_malformed_yaml() {
610        let content = r#"---
611dependencies:
612  agents:
613    - path: agents/test.md
614    version: missing dash
615---
616
617# Content"#;
618
619        let path = Path::new("command.md");
620        let result = MetadataExtractor::extract(path, content, None);
621
622        // Should succeed but return empty metadata (with warning logged)
623        assert!(result.is_ok());
624        let metadata = result.unwrap();
625        assert!(metadata.dependencies.is_none());
626    }
627
628    #[test]
629    fn test_extract_with_tool_field() {
630        let content = r#"---
631dependencies:
632  agents:
633    - path: agents/backend.md
634      version: v1.0.0
635      tool: opencode
636    - path: agents/frontend.md
637      tool: claude-code
638---
639
640# Command with multi-tool dependencies"#;
641
642        let path = Path::new("command.md");
643        let metadata = MetadataExtractor::extract(path, content, None).unwrap();
644
645        assert!(metadata.has_dependencies());
646        let deps = metadata.dependencies.unwrap();
647        assert_eq!(deps["agents"].len(), 2);
648
649        // Verify tool fields are preserved
650        assert_eq!(deps["agents"][0].path, "agents/backend.md");
651        assert_eq!(deps["agents"][0].tool, Some("opencode".to_string()));
652
653        assert_eq!(deps["agents"][1].path, "agents/frontend.md");
654        assert_eq!(deps["agents"][1].tool, Some("claude-code".to_string()));
655    }
656
657    #[test]
658    fn test_extract_unknown_field_warning() {
659        let content = r#"---
660dependencies:
661  agents:
662    - path: agents/test.md
663      version: v1.0.0
664      invalid_field: should_warn
665---
666
667# Content"#;
668
669        let path = Path::new("command.md");
670        let result = MetadataExtractor::extract(path, content, None);
671
672        // Should succeed but return empty metadata due to unknown field
673        assert!(result.is_ok());
674        let metadata = result.unwrap();
675        // With deny_unknown_fields, the parsing fails and we get empty metadata
676        assert!(!metadata.has_dependencies());
677    }
678
679    #[test]
680    fn test_template_frontmatter_with_project_vars() {
681        // Create a project config
682        let mut config_map = toml::map::Map::new();
683        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
684        config_map.insert("framework".to_string(), toml::Value::String("tokio".into()));
685        let project_config = ProjectConfig::from(config_map);
686
687        // Markdown with templated dependency path
688        let content = r#"---
689agpm:
690  templating: true
691dependencies:
692  snippets:
693    - path: standards/{{ agpm.project.language }}-guide.md
694      version: v1.0.0
695  commands:
696    - path: configs/{{ agpm.project.framework }}-setup.md
697---
698
699# My Agent"#;
700
701        let path = Path::new("agent.md");
702        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
703
704        assert!(metadata.has_dependencies());
705        let deps = metadata.dependencies.unwrap();
706
707        // Check that templates were resolved
708        assert_eq!(deps["snippets"].len(), 1);
709        assert_eq!(deps["snippets"][0].path, "standards/rust-guide.md");
710
711        assert_eq!(deps["commands"].len(), 1);
712        assert_eq!(deps["commands"][0].path, "configs/tokio-setup.md");
713    }
714
715    #[test]
716    fn test_template_frontmatter_with_missing_vars() {
717        // Create a project config with only one variable
718        let mut config_map = toml::map::Map::new();
719        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
720        let project_config = ProjectConfig::from(config_map);
721
722        // Template references undefined variable (should error with helpful message)
723        let content = r#"---
724agpm:
725  templating: true
726dependencies:
727  snippets:
728    - path: standards/{{ agpm.project.language }}-{{ agpm.project.undefined }}-guide.md
729---
730
731# My Agent"#;
732
733        let path = Path::new("agent.md");
734        let result = MetadataExtractor::extract(path, content, Some(&project_config));
735
736        // Should error on undefined variable
737        assert!(result.is_err());
738        let error_msg = format!("{}", result.unwrap_err());
739        assert!(error_msg.contains("Failed to render frontmatter template"));
740        assert!(error_msg.contains("default")); // Suggests using default filter
741    }
742
743    #[test]
744    fn test_template_frontmatter_with_default_filter() {
745        // Create a project config with only one variable
746        let mut config_map = toml::map::Map::new();
747        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
748        let project_config = ProjectConfig::from(config_map);
749
750        // Use default filter for undefined variable (recommended pattern)
751        let content = r#"---
752agpm:
753  templating: true
754dependencies:
755  snippets:
756    - path: standards/{{ agpm.project.language }}-{{ agpm.project.style | default(value="standard") }}-guide.md
757---
758
759# My Agent"#;
760
761        let path = Path::new("agent.md");
762        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
763
764        assert!(metadata.has_dependencies());
765        let deps = metadata.dependencies.unwrap();
766
767        // Default filter provides fallback value
768        assert_eq!(deps["snippets"].len(), 1);
769        assert_eq!(deps["snippets"][0].path, "standards/rust-standard-guide.md");
770    }
771
772    #[test]
773    fn test_template_json_dependencies() {
774        // Create a project config
775        let mut config_map = toml::map::Map::new();
776        config_map.insert("tool".to_string(), toml::Value::String("linter".into()));
777        let project_config = ProjectConfig::from(config_map);
778
779        // JSON with templated dependency path
780        let content = r#"{
781  "events": ["UserPromptSubmit"],
782  "command": "node",
783  "agpm": {
784    "templating": true
785  },
786  "dependencies": {
787    "scripts": [
788      { "path": "scripts/{{ agpm.project.tool }}.js", "version": "v1.0.0" }
789    ]
790  }
791}"#;
792
793        let path = Path::new("hook.json");
794        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
795
796        assert!(metadata.has_dependencies());
797        let deps = metadata.dependencies.unwrap();
798
799        // Check that template was resolved
800        assert_eq!(deps["scripts"].len(), 1);
801        assert_eq!(deps["scripts"][0].path, "scripts/linter.js");
802    }
803
804    #[test]
805    fn test_template_with_no_template_syntax() {
806        // Create a project config
807        let mut config_map = toml::map::Map::new();
808        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
809        let project_config = ProjectConfig::from(config_map);
810
811        // Content without template syntax - should work normally
812        let content = r#"---
813dependencies:
814  snippets:
815    - path: standards/plain-guide.md
816---
817
818# My Agent"#;
819
820        let path = Path::new("agent.md");
821        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
822
823        assert!(metadata.has_dependencies());
824        let deps = metadata.dependencies.unwrap();
825
826        // Path should remain unchanged
827        assert_eq!(deps["snippets"].len(), 1);
828        assert_eq!(deps["snippets"][0].path, "standards/plain-guide.md");
829    }
830
831    #[test]
832    fn test_template_opt_out_via_agpm_field() {
833        // Create a project config
834        let mut config_map = toml::map::Map::new();
835        config_map.insert("language".to_string(), toml::Value::String("rust".into()));
836        let project_config = ProjectConfig::from(config_map);
837
838        // Content with template syntax BUT templating disabled via agpm.templating field
839        let content = r#"---
840agpm:
841  templating: false
842dependencies:
843  snippets:
844    - path: standards/{{ agpm.project.language }}-guide.md
845---
846
847# My Agent"#;
848
849        let path = Path::new("agent.md");
850        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
851
852        assert!(metadata.has_dependencies());
853        let deps = metadata.dependencies.unwrap();
854
855        // Template syntax should be preserved (not rendered)
856        assert_eq!(deps["snippets"].len(), 1);
857        assert_eq!(deps["snippets"][0].path, "standards/{{ agpm.project.language }}-guide.md");
858    }
859
860    #[test]
861    fn test_template_transitive_dep_path() {
862        use std::path::PathBuf;
863
864        // Test that dependency paths in frontmatter are templated correctly
865        let content = r#"---
866agpm:
867  templating: true
868dependencies:
869  agents:
870    - path: agents/{{ agpm.project.language }}-helper.md
871      version: v1.0.0
872---
873
874# Main Agent
875"#;
876
877        let mut config_map = toml::map::Map::new();
878        config_map.insert("language".to_string(), toml::Value::String("rust".to_string()));
879        let config = ProjectConfig::from(config_map);
880
881        let path = PathBuf::from("agents/main.md");
882        let result = MetadataExtractor::extract(&path, content, Some(&config));
883
884        assert!(result.is_ok(), "Should extract metadata: {:?}", result.err());
885        let metadata = result.unwrap();
886
887        // Should have dependencies
888        assert!(metadata.dependencies.is_some(), "Should have dependencies");
889        let deps = metadata.dependencies.unwrap();
890
891        // Should have agents key
892        assert!(deps.contains_key("agents"), "Should have agents dependencies");
893        let agents = &deps["agents"];
894
895        // Should have one agent dependency
896        assert_eq!(agents.len(), 1, "Should have one agent dependency");
897
898        // Path should be templated (not contain template syntax)
899        let dep_path = &agents[0].path;
900        assert_eq!(
901            dep_path, "agents/rust-helper.md",
902            "Path should be templated to rust-helper, got: {}",
903            dep_path
904        );
905        assert!(!dep_path.contains("{{"), "Path should not contain template syntax");
906        assert!(!dep_path.contains("}}"), "Path should not contain template syntax");
907    }
908
909    #[test]
910    fn test_template_opt_out_json() {
911        // Create a project config
912        let mut config_map = toml::map::Map::new();
913        config_map.insert("tool".to_string(), toml::Value::String("linter".into()));
914        let project_config = ProjectConfig::from(config_map);
915
916        // JSON with template syntax BUT templating disabled
917        let content = r#"{
918  "agpm": {
919    "templating": false
920  },
921  "events": ["UserPromptSubmit"],
922  "dependencies": {
923    "scripts": [
924      { "path": "scripts/{{ agpm.project.tool }}.js" }
925    ]
926  }
927}"#;
928
929        let path = Path::new("hook.json");
930        let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
931
932        assert!(metadata.has_dependencies());
933        let deps = metadata.dependencies.unwrap();
934
935        // Template syntax should be preserved (not rendered)
936        assert_eq!(deps["scripts"].len(), 1);
937        assert_eq!(deps["scripts"][0].path, "scripts/{{ agpm.project.tool }}.js");
938    }
939
940    #[test]
941    fn test_validate_tool_name_as_resource_type_yaml() {
942        // YAML using tool name 'opencode' instead of resource type 'agents'
943        let content = r#"---
944dependencies:
945  opencode:
946    - path: agents/helper.md
947---
948# Command"#;
949
950        let path = Path::new("command.md");
951        let result = MetadataExtractor::extract(path, content, None);
952
953        assert!(result.is_err());
954        let err_msg = result.unwrap_err().to_string();
955        assert!(err_msg.contains("Invalid resource type 'opencode'"));
956        assert!(err_msg.contains("tool name"));
957        assert!(err_msg.contains("agents:"));
958    }
959
960    #[test]
961    fn test_validate_tool_name_as_resource_type_json() {
962        // JSON using tool name 'claude-code' instead of resource type 'snippets'
963        let content = r#"{
964  "dependencies": {
965    "claude-code": [
966      { "path": "snippets/helper.md" }
967    ]
968  }
969}"#;
970
971        let path = Path::new("hook.json");
972        let result = MetadataExtractor::extract(path, content, None);
973
974        assert!(result.is_err());
975        let err_msg = result.unwrap_err().to_string();
976        assert!(err_msg.contains("Invalid resource type 'claude-code'"));
977        assert!(err_msg.contains("tool name"));
978    }
979
980    #[test]
981    fn test_validate_unknown_resource_type() {
982        // Using a completely unknown resource type
983        let content = r#"---
984dependencies:
985  foobar:
986    - path: something/test.md
987---
988# Command"#;
989
990        let path = Path::new("command.md");
991        let result = MetadataExtractor::extract(path, content, None);
992
993        assert!(result.is_err());
994        let err_msg = result.unwrap_err().to_string();
995        assert!(err_msg.contains("Unknown resource type 'foobar'"));
996        assert!(err_msg.contains("Valid resource types"));
997    }
998
999    #[test]
1000    fn test_validate_correct_resource_types() {
1001        // All valid resource types should pass
1002        let content = r#"---
1003dependencies:
1004  agents:
1005    - path: agents/helper.md
1006  snippets:
1007    - path: snippets/util.md
1008  commands:
1009    - path: commands/deploy.md
1010---
1011# Command"#;
1012
1013        let path = Path::new("command.md");
1014        let result = MetadataExtractor::extract(path, content, None);
1015
1016        assert!(result.is_ok());
1017    }
1018}