use anyhow::{Context, Result};
use serde_json::Value as JsonValue;
use std::path::Path;
use crate::core::OperationContext;
use crate::manifest::{DependencyMetadata, dependency_spec::AgpmMetadata};
use crate::markdown::frontmatter::FrontmatterParser;
pub struct MetadataExtractor;
impl MetadataExtractor {
pub fn extract(
path: &Path,
content: &str,
variant_inputs: Option<&serde_json::Value>,
context: Option<&OperationContext>,
) -> Result<DependencyMetadata> {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"md" => Self::extract_markdown_frontmatter(content, variant_inputs, path, context),
"json" => Self::extract_json_field(content, variant_inputs, path, context),
_ => {
Ok(DependencyMetadata::default())
}
}
}
fn extract_markdown_frontmatter(
content: &str,
variant_inputs: Option<&serde_json::Value>,
path: &Path,
context: Option<&OperationContext>,
) -> Result<DependencyMetadata> {
let mut parser = FrontmatterParser::new();
let result = parser.parse_with_templating::<crate::markdown::MarkdownMetadata>(
content,
variant_inputs,
path,
context,
)?;
if let Some(ref markdown_metadata) = result.data {
let root_dependencies = markdown_metadata.dependencies.clone();
let agpm_dependencies =
markdown_metadata.get_agpm_metadata().and_then(|agpm| agpm.dependencies);
let dependency_metadata = DependencyMetadata::new(
root_dependencies,
Some(AgpmMetadata {
templating: markdown_metadata
.get_agpm_metadata()
.and_then(|agpm| agpm.templating),
dependencies: agpm_dependencies,
}),
);
Self::validate_resource_types(&dependency_metadata, path)?;
Ok(dependency_metadata)
} else {
Ok(DependencyMetadata::default())
}
}
fn extract_json_field(
content: &str,
variant_inputs: Option<&serde_json::Value>,
path: &Path,
context: Option<&OperationContext>,
) -> Result<DependencyMetadata> {
let mut parser = FrontmatterParser::new();
let templated_content = parser.apply_templating(content, variant_inputs, path)?;
let json: JsonValue = serde_json::from_str(&templated_content)
.with_context(|| "Failed to parse JSON content")?;
if let Some(deps) = json.get("dependencies") {
match serde_json::from_value::<
std::collections::BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
>(deps.clone())
{
Ok(dependencies) => {
let metadata = DependencyMetadata::new(Some(dependencies), None);
Self::validate_resource_types(&metadata, path)?;
Ok(metadata)
}
Err(e) => {
if let Some(ctx) = context {
if ctx.should_warn_file(path) {
eprintln!(
"Warning: Unable to parse dependencies field 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",
path.display(),
e
);
}
}
Ok(DependencyMetadata::default())
}
}
} else {
Ok(DependencyMetadata::default())
}
}
fn validate_resource_types(metadata: &DependencyMetadata, file_path: &Path) -> Result<()> {
const VALID_RESOURCE_TYPES: &[&str] =
&["agents", "commands", "snippets", "hooks", "mcp-servers", "scripts", "skills"];
const TOOL_NAMES: &[&str] = &["claude-code", "opencode", "agpm"];
if let Some(dependencies) = metadata.get_dependencies() {
for resource_type in dependencies.keys() {
if !VALID_RESOURCE_TYPES.contains(&resource_type.as_str()) {
if TOOL_NAMES.contains(&resource_type.as_str()) {
anyhow::bail!(
"Invalid resource type '{}' in dependencies section of '{}'.\n\n\
You used a tool name ('{}') as a section header, but AGPM expects resource types.\n\n\
✗ Wrong:\n dependencies:\n {}:\n - path: ...\n\n\
✓ Correct:\n dependencies:\n agents: # or snippets, commands, etc.\n - path: ...\n tool: {} # Specify tool here\n\n\
Valid resource types: {}",
resource_type,
file_path.display(),
resource_type,
resource_type,
resource_type,
VALID_RESOURCE_TYPES.join(", ")
);
}
anyhow::bail!(
"Unknown resource type '{}' in dependencies section of '{}'.\n\
Valid resource types: {}",
resource_type,
file_path.display(),
VALID_RESOURCE_TYPES.join(", ")
);
}
}
}
Ok(())
}
pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
use std::path::PathBuf;
if (content.starts_with("---\n") || content.starts_with("---\r\n"))
&& let Ok(metadata) = Self::extract_markdown_frontmatter(
content,
None,
&PathBuf::from("unknown.md"),
None,
)
&& metadata.has_dependencies()
{
return Ok(metadata);
}
if content.trim_start().starts_with('{')
&& let Ok(metadata) =
Self::extract_json_field(content, None, &PathBuf::from("unknown.json"), None)
&& metadata.has_dependencies()
{
return Ok(metadata);
}
Ok(DependencyMetadata::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::ProjectConfig;
#[test]
fn test_extract_markdown_frontmatter() {
let content = r#"---
dependencies:
agents:
- path: agents/helper.md
version: v1.0.0
- path: agents/reviewer.md
snippets:
- path: snippets/utils.md
---
# My Command
This is the command documentation."#;
let path = Path::new("command.md");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["agents"].len(), 2);
assert_eq!(deps["snippets"].len(), 1);
assert_eq!(deps["agents"][0].path, "agents/helper.md");
assert_eq!(deps["agents"][0].version, Some("v1.0.0".to_string()));
}
#[test]
fn test_extract_markdown_no_frontmatter() {
let content = r#"# My Command
This is a command without frontmatter."#;
let path = Path::new("command.md");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_extract_json_dependencies() {
let content = r#"{
"events": ["UserPromptSubmit"],
"type": "command",
"command": ".claude/scripts/test.js",
"dependencies": {
"scripts": [
{ "path": "scripts/test-runner.sh", "version": "v1.0.0" },
{ "path": "scripts/validator.py" }
],
"agents": [
{ "path": "agents/code-analyzer.md", "version": "~1.2.0" }
]
}
}"#;
let path = Path::new("hook.json");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["scripts"].len(), 2);
assert_eq!(deps["agents"].len(), 1);
assert_eq!(deps["scripts"][0].path, "scripts/test-runner.sh");
assert_eq!(deps["scripts"][0].version, Some("v1.0.0".to_string()));
}
#[test]
fn test_extract_json_no_dependencies() {
let content = r#"{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"]
}"#;
let path = Path::new("mcp.json");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_extract_script_file() {
let content = r#"#!/bin/bash
echo "This is a script file"
# Scripts don't support dependencies"#;
let path = Path::new("script.sh");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_extract_auto_markdown() {
let content = r#"---
dependencies:
agents:
- path: agents/test.md
---
# Content"#;
let metadata = MetadataExtractor::extract_auto(content).unwrap();
assert!(metadata.has_dependencies());
assert_eq!(metadata.dependency_count(), 1);
}
#[test]
fn test_extract_auto_json() {
let content = r#"{
"dependencies": {
"snippets": [
{ "path": "snippets/test.md" }
]
}
}"#;
let metadata = MetadataExtractor::extract_auto(content).unwrap();
assert!(metadata.has_dependencies());
assert_eq!(metadata.dependency_count(), 1);
}
#[test]
fn test_windows_line_endings() {
let content = "---\r\ndependencies:\r\n agents:\r\n - path: agents/test.md\r\n---\r\n\r\n# Content";
let path = Path::new("command.md");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["agents"].len(), 1);
assert_eq!(deps["agents"][0].path, "agents/test.md");
}
#[test]
fn test_empty_dependencies() {
let content = r#"---
dependencies:
---
# Content"#;
let path = Path::new("command.md");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_malformed_yaml() -> Result<(), Box<dyn std::error::Error>> {
let content = r#"---
dependencies:
agents:
- path: agents/test.md
version: missing dash
---
# Content"#;
let path = Path::new("command.md");
let result = MetadataExtractor::extract(path, content, None, None);
let metadata = result?;
assert!(!metadata.has_dependencies());
Ok(())
}
#[test]
fn test_extract_with_tool_field() {
let content = r#"---
dependencies:
agents:
- path: agents/backend.md
version: v1.0.0
tool: opencode
- path: agents/frontend.md
tool: claude-code
---
# Command with multi-tool dependencies"#;
let path = Path::new("command.md");
let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["agents"].len(), 2);
assert_eq!(deps["agents"][0].path, "agents/backend.md");
assert_eq!(deps["agents"][0].tool, Some("opencode".to_string()));
assert_eq!(deps["agents"][1].path, "agents/frontend.md");
assert_eq!(deps["agents"][1].tool, Some("claude-code".to_string()));
}
#[test]
fn test_extract_unknown_field_warning() -> Result<(), Box<dyn std::error::Error>> {
let content = r#"---
dependencies:
agents:
- path: agents/test.md
version: v1.0.0
invalid_field: should_warn
---
# Content"#;
let path = Path::new("command.md");
let result = MetadataExtractor::extract(path, content, None, None);
let metadata = result?;
assert!(!metadata.has_dependencies());
Ok(())
}
#[test]
fn test_template_frontmatter_with_project_vars() {
let mut config_map = toml::map::Map::new();
config_map.insert("language".to_string(), toml::Value::String("rust".into()));
config_map.insert("framework".to_string(), toml::Value::String("tokio".into()));
let project_config = ProjectConfig::from(config_map);
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 = r#"---
agpm:
templating: true
dependencies:
snippets:
- path: standards/{{ agpm.project.language }}-guide.md
version: v1.0.0
commands:
- path: configs/{{ agpm.project.framework }}-setup.md
---
# My Agent"#;
let path = Path::new("agent.md");
let metadata =
MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["snippets"].len(), 1);
assert_eq!(deps["snippets"][0].path, "standards/rust-guide.md");
assert_eq!(deps["commands"].len(), 1);
assert_eq!(deps["commands"][0].path, "configs/tokio-setup.md");
}
#[test]
fn test_template_frontmatter_with_missing_vars() {
let mut config_map = toml::map::Map::new();
config_map.insert("language".to_string(), toml::Value::String("rust".into()));
let project_config = ProjectConfig::from(config_map);
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 = r#"---
agpm:
templating: true
dependencies:
snippets:
- path: standards/{{ agpm.project.language }}-{{ agpm.project.undefined }}-guide.md
---
# My Agent"#;
let path = Path::new("agent.md");
let result = MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None);
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Failed to render frontmatter template"));
assert!(error_msg.contains("Variable") && error_msg.contains("not found"));
}
#[test]
fn test_template_frontmatter_with_default_filter() {
let mut config_map = toml::map::Map::new();
config_map.insert("language".to_string(), toml::Value::String("rust".into()));
let project_config = ProjectConfig::from(config_map);
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 = r#"---
agpm:
templating: true
dependencies:
snippets:
- path: standards/{{ agpm.project.language }}-{{ agpm.project.style | default(value="standard") }}-guide.md
---
# My Agent"#;
let path = Path::new("agent.md");
let metadata =
MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["snippets"].len(), 1);
assert_eq!(deps["snippets"][0].path, "standards/rust-standard-guide.md");
}
#[test]
fn test_template_json_dependencies() {
let mut config_map = toml::map::Map::new();
config_map.insert("tool".to_string(), toml::Value::String("linter".into()));
let project_config = ProjectConfig::from(config_map);
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 = r#"{
"events": ["UserPromptSubmit"],
"command": "node",
"agpm": {
"templating": true
},
"dependencies": {
"scripts": [
{ "path": "scripts/{{ agpm.project.tool }}.js", "version": "v1.0.0" }
]
}
}"#;
let path = Path::new("hook.json");
let metadata =
MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["scripts"].len(), 1);
assert_eq!(deps["scripts"][0].path, "scripts/linter.js");
}
#[test]
fn test_template_with_no_template_syntax() {
let mut config_map = toml::map::Map::new();
config_map.insert("language".to_string(), toml::Value::String("rust".into()));
let project_config = ProjectConfig::from(config_map);
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 = r#"---
dependencies:
snippets:
- path: standards/plain-guide.md
---
# My Agent"#;
let path = Path::new("agent.md");
let metadata =
MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["snippets"].len(), 1);
assert_eq!(deps["snippets"][0].path, "standards/plain-guide.md");
}
#[test]
fn test_template_transitive_dep_path() -> Result<(), Box<dyn std::error::Error>> {
use std::path::PathBuf;
let content = r#"---
agpm:
templating: true
dependencies:
agents:
- path: agents/{{ agpm.project.language }}-helper.md
version: v1.0.0
---
# Main Agent
"#;
let mut config_map = toml::map::Map::new();
config_map.insert("language".to_string(), toml::Value::String("rust".to_string()));
let config = ProjectConfig::from(config_map);
let mut variant_inputs = serde_json::Map::new();
variant_inputs.insert("project".to_string(), config.to_json_value());
let variant_inputs_value = serde_json::Value::Object(variant_inputs);
let path = PathBuf::from("agents/main.md");
let result = MetadataExtractor::extract(&path, content, Some(&variant_inputs_value), None);
let metadata = result.context("Should extract metadata")?;
assert!(metadata.dependencies.is_some(), "Should have dependencies");
let deps = metadata.dependencies.unwrap();
assert!(deps.contains_key("agents"), "Should have agents dependencies");
let agents = &deps["agents"];
assert_eq!(agents.len(), 1, "Should have one agent dependency");
let dep_path = &agents[0].path;
assert_eq!(
dep_path, "agents/rust-helper.md",
"Path should be templated to rust-helper, got: {}",
dep_path
);
assert!(!dep_path.contains("{{"), "Path should not contain template syntax");
assert!(!dep_path.contains("}}"), "Path should not contain template syntax");
Ok(())
}
#[test]
fn test_validate_tool_name_as_resource_type_yaml() {
let content = r#"---
dependencies:
opencode:
- path: agents/helper.md
---
# Command"#;
let path = Path::new("command.md");
let result = MetadataExtractor::extract(path, content, None, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid resource type 'opencode'"));
assert!(err_msg.contains("tool name"));
assert!(err_msg.contains("agents:"));
}
#[test]
fn test_validate_tool_name_as_resource_type_json() {
let content = r#"{
"dependencies": {
"claude-code": [
{ "path": "snippets/helper.md" }
]
}
}"#;
let path = Path::new("hook.json");
let result = MetadataExtractor::extract(path, content, None, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Invalid resource type 'claude-code'"));
assert!(err_msg.contains("tool name"));
}
#[test]
fn test_validate_unknown_resource_type() {
let content = r#"---
dependencies:
foobar:
- path: something/test.md
---
# Command"#;
let path = Path::new("command.md");
let result = MetadataExtractor::extract(path, content, None, None);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown resource type 'foobar'"));
assert!(err_msg.contains("Valid resource types"));
}
#[test]
fn test_validate_correct_resource_types() -> anyhow::Result<()> {
let content = r#"---
dependencies:
agents:
- path: agents/helper.md
snippets:
- path: snippets/util.md
commands:
- path: commands/deploy.md
---
# Command"#;
let path = Path::new("command.md");
MetadataExtractor::extract(path, content, None, None)?;
Ok(())
}
#[test]
fn test_warning_deduplication_with_context() {
use std::path::PathBuf;
let ctx = OperationContext::new();
let path = PathBuf::from("test-file.md");
let different_path = PathBuf::from("different-file.md");
assert!(ctx.should_warn_file(&path));
assert!(!ctx.should_warn_file(&path));
assert!(!ctx.should_warn_file(&path));
assert!(ctx.should_warn_file(&different_path));
}
#[test]
fn test_context_isolation() {
use std::path::PathBuf;
let ctx1 = OperationContext::new();
let ctx2 = OperationContext::new();
let path = PathBuf::from("test-isolation.md");
assert!(ctx1.should_warn_file(&path));
assert!(ctx2.should_warn_file(&path));
assert!(!ctx1.should_warn_file(&path));
assert!(!ctx2.should_warn_file(&path));
}
}