use anyhow::{Context, Result};
use serde_json::{Map, Value as JsonValue};
use std::collections::HashMap;
use std::path::Path;
use tera::{Context as TeraContext, Tera};
use crate::manifest::{DependencyMetadata, ProjectConfig};
pub struct MetadataExtractor;
impl MetadataExtractor {
pub fn extract(
path: &Path,
content: &str,
project_config: Option<&ProjectConfig>,
) -> Result<DependencyMetadata> {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"md" => Self::extract_markdown_frontmatter(content, project_config, path),
"json" => Self::extract_json_field(content, project_config, path),
_ => {
Ok(DependencyMetadata::default())
}
}
}
fn extract_markdown_frontmatter(
content: &str,
project_config: Option<&ProjectConfig>,
path: &Path,
) -> Result<DependencyMetadata> {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return Ok(DependencyMetadata::default());
}
let search_start = if content.starts_with("---\n") {
4
} else {
5
};
let end_pattern = if content.contains("\r\n") {
"\r\n---\r\n"
} else {
"\n---\n"
};
if let Some(end_pos) = content[search_start..].find(end_pattern) {
let frontmatter = &content[search_start..search_start + end_pos];
let templating_disabled = if let Some(_config) = project_config {
Self::is_templating_disabled_yaml(frontmatter)
} else {
false
};
let templated_frontmatter = if let Some(config) = project_config {
if templating_disabled {
tracing::debug!("Templating disabled via agpm.templating field in frontmatter");
frontmatter.to_string()
} else {
Self::template_content(frontmatter, config, path)?
}
} else {
frontmatter.to_string()
};
match serde_yaml::from_str::<DependencyMetadata>(&templated_frontmatter) {
Ok(metadata) => {
Self::validate_resource_types(&metadata, path)?;
Ok(metadata)
}
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("unknown field") {
tracing::warn!(
"Warning: YAML frontmatter contains unknown field(s): {}. \
Supported fields are: path, version, tool",
e
);
eprintln!(
"Warning: YAML frontmatter contains unknown field(s).\n\
Supported fields in dependencies are:\n\
- path: Path to the dependency file (required)\n\
- version: Version constraint (optional)\n\
- tool: Target tool (optional: claude-code, opencode, agpm)\n\
\nError: {}",
e
);
} else {
tracing::warn!("Warning: Unable to parse YAML frontmatter: {}", e);
eprintln!("Warning: Unable to parse YAML frontmatter: {}", e);
}
Ok(DependencyMetadata::default())
}
}
} else {
Ok(DependencyMetadata::default())
}
}
fn extract_json_field(
content: &str,
project_config: Option<&ProjectConfig>,
path: &Path,
) -> Result<DependencyMetadata> {
let templating_disabled = if let Some(_config) = project_config {
Self::is_templating_disabled_json(content)
} else {
false
};
let templated_content = if let Some(config) = project_config {
if templating_disabled {
tracing::debug!("Templating disabled via agpm.templating field in JSON");
content.to_string()
} else {
Self::template_content(content, config, path)?
}
} else {
content.to_string()
};
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::<HashMap<String, Vec<crate::manifest::DependencySpec>>>(
deps.clone(),
) {
Ok(dependencies) => {
let metadata = DependencyMetadata {
dependencies: Some(dependencies),
};
Self::validate_resource_types(&metadata, path)?;
Ok(metadata)
}
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("unknown field") {
tracing::warn!(
"Warning: JSON dependencies contain unknown field(s): {}. \
Supported fields are: path, version, tool",
e
);
eprintln!(
"Warning: JSON dependencies contain unknown field(s).\n\
Supported fields in dependencies are:\n\
- path: Path to the dependency file (required)\n\
- version: Version constraint (optional)\n\
- tool: Target tool (optional: claude-code, opencode, agpm)\n\
\nError: {}",
e
);
} else {
tracing::warn!("Warning: Unable to parse dependencies field: {}", e);
eprintln!("Warning: Unable to parse dependencies field: {}", e);
}
Ok(DependencyMetadata::default())
}
}
} else {
Ok(DependencyMetadata::default())
}
}
fn is_templating_disabled_yaml(frontmatter: &str) -> bool {
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
value
.get("agpm")
.and_then(|agpm| agpm.get("templating"))
.and_then(|v| v.as_bool())
.map(|b| !b)
.unwrap_or(true) } else {
true }
}
fn is_templating_disabled_json(content: &str) -> bool {
if let Ok(json) = serde_json::from_str::<JsonValue>(content) {
json.get("agpm")
.and_then(|agpm| agpm.get("templating"))
.and_then(|v| v.as_bool())
.map(|b| !b)
.unwrap_or(true) } else {
true }
}
fn template_content(
content: &str,
project_config: &ProjectConfig,
path: &Path,
) -> Result<String> {
if !content.contains("{{") && !content.contains("{%") {
return Ok(content.to_string());
}
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
let mut context = TeraContext::new();
let mut agpm = Map::new();
agpm.insert("project".to_string(), project_config.to_json_value());
context.insert("agpm", &agpm);
tera.render_str(content, &context).map_err(|e| {
let error_details = Self::format_tera_error(&e);
anyhow::Error::new(e).context(format!(
"Failed to render frontmatter template in '{}'.\n\
Error details:\n{}\n\n\
Hint: Use {{{{ var | default(value=\"fallback\") }}}} for optional variables",
path.display(),
error_details
))
})
}
fn format_tera_error(error: &tera::Error) -> String {
use std::error::Error;
let mut messages = Vec::new();
let mut all_messages = vec![error.to_string()];
let mut current_error: Option<&dyn Error> = error.source();
while let Some(err) = current_error {
all_messages.push(err.to_string());
current_error = err.source();
}
for msg in all_messages {
let cleaned = msg
.replace("while rendering '__tera_one_off'", "")
.replace("Failed to render '__tera_one_off'", "Template rendering failed")
.replace("Failed to parse '__tera_one_off'", "Template syntax error")
.replace("'__tera_one_off'", "template")
.trim()
.to_string();
if !cleaned.is_empty()
&& cleaned != "Template rendering failed"
&& cleaned != "Template syntax error"
{
messages.push(cleaned);
}
}
if !messages.is_empty() {
messages.join("\n → ")
} else {
"Template syntax error (see details above)".to_string()
}
}
fn validate_resource_types(metadata: &DependencyMetadata, file_path: &Path) -> Result<()> {
const VALID_RESOURCE_TYPES: &[&str] =
&["agents", "commands", "snippets", "hooks", "mcp-servers", "scripts"];
const TOOL_NAMES: &[&str] = &["claude-code", "opencode", "agpm"];
if let Some(ref dependencies) = metadata.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(", ")
);
} else {
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"))
&& 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"))
&& metadata.has_dependencies()
{
return Ok(metadata);
}
Ok(DependencyMetadata::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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).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).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).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).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).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).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).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_malformed_yaml() {
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);
assert!(result.is_ok());
let metadata = result.unwrap();
assert!(metadata.dependencies.is_none());
}
#[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).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() {
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);
assert!(result.is_ok());
let metadata = result.unwrap();
assert!(!metadata.has_dependencies());
}
#[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 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(&project_config)).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 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(&project_config));
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Failed to render frontmatter template"));
assert!(error_msg.contains("default")); }
#[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 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(&project_config)).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 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(&project_config)).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 content = r#"---
dependencies:
snippets:
- path: standards/plain-guide.md
---
# My Agent"#;
let path = Path::new("agent.md");
let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).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_opt_out_via_agpm_field() {
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 content = r#"---
agpm:
templating: false
dependencies:
snippets:
- path: standards/{{ agpm.project.language }}-guide.md
---
# My Agent"#;
let path = Path::new("agent.md");
let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["snippets"].len(), 1);
assert_eq!(deps["snippets"][0].path, "standards/{{ agpm.project.language }}-guide.md");
}
#[test]
fn test_template_transitive_dep_path() {
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 path = PathBuf::from("agents/main.md");
let result = MetadataExtractor::extract(&path, content, Some(&config));
assert!(result.is_ok(), "Should extract metadata: {:?}", result.err());
let metadata = result.unwrap();
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");
}
#[test]
fn test_template_opt_out_json() {
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 content = r#"{
"agpm": {
"templating": false
},
"events": ["UserPromptSubmit"],
"dependencies": {
"scripts": [
{ "path": "scripts/{{ agpm.project.tool }}.js" }
]
}
}"#;
let path = Path::new("hook.json");
let metadata = MetadataExtractor::extract(path, content, Some(&project_config)).unwrap();
assert!(metadata.has_dependencies());
let deps = metadata.dependencies.unwrap();
assert_eq!(deps["scripts"].len(), 1);
assert_eq!(deps["scripts"][0].path, "scripts/{{ agpm.project.tool }}.js");
}
#[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);
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);
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);
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() {
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");
let result = MetadataExtractor::extract(path, content, None);
assert!(result.is_ok());
}
}