use anyhow::{Context, Result};
use serde_json::Value as JsonValue;
use std::path::Path;
use crate::manifest::DependencyMetadata;
pub struct MetadataExtractor;
impl MetadataExtractor {
pub fn extract(path: &Path, content: &str) -> Result<DependencyMetadata> {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"md" => Self::extract_markdown_frontmatter(content),
"json" => Self::extract_json_field(content),
_ => {
Ok(DependencyMetadata::default())
}
}
}
fn extract_markdown_frontmatter(content: &str) -> 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];
match serde_yaml::from_str::<DependencyMetadata>(frontmatter) {
Ok(metadata) => Ok(metadata),
Err(e) => {
tracing::warn!("Warning: Unable to parse YAML frontmatter: {}", e);
Ok(DependencyMetadata::default())
}
}
} else {
Ok(DependencyMetadata::default())
}
}
fn extract_json_field(content: &str) -> Result<DependencyMetadata> {
let json: JsonValue =
serde_json::from_str(content).with_context(|| "Failed to parse JSON content")?;
if let Some(deps) = json.get("dependencies") {
let dependencies = serde_json::from_value(deps.clone())
.with_context(|| "Failed to parse dependencies field")?;
Ok(DependencyMetadata {
dependencies: Some(dependencies),
})
} else {
Ok(DependencyMetadata::default())
}
}
pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
if (content.starts_with("---\n") || content.starts_with("---\r\n"))
&& let Ok(metadata) = Self::extract_markdown_frontmatter(content)
&& metadata.has_dependencies()
{
return Ok(metadata);
}
if content.trim_start().starts_with('{')
&& let Ok(metadata) = Self::extract_json_field(content)
&& 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).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).unwrap();
assert!(!metadata.has_dependencies());
}
#[test]
fn test_extract_json_dependencies() {
let content = r#"{
"events": ["UserPromptSubmit"],
"type": "command",
"command": ".claude/agpm/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).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).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).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).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).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);
assert!(result.is_ok());
let metadata = result.unwrap();
assert!(metadata.dependencies.is_none());
}
}