1use anyhow::{Context, Result};
19use serde_json::Value as JsonValue;
20use std::path::Path;
21
22use crate::core::OperationContext;
23use crate::manifest::{DependencyMetadata, dependency_spec::AgpmMetadata};
24use crate::markdown::frontmatter::FrontmatterParser;
25
26pub struct MetadataExtractor;
33
34impl MetadataExtractor {
35 pub fn extract(
73 path: &Path,
74 content: &str,
75 variant_inputs: Option<&serde_json::Value>,
76 context: Option<&OperationContext>,
77 ) -> Result<DependencyMetadata> {
78 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
79
80 match extension {
81 "md" => Self::extract_markdown_frontmatter(content, variant_inputs, path, context),
82 "json" => Self::extract_json_field(content, variant_inputs, path, context),
83 _ => {
84 Ok(DependencyMetadata::default())
86 }
87 }
88 }
89
90 fn extract_markdown_frontmatter(
95 content: &str,
96 variant_inputs: Option<&serde_json::Value>,
97 path: &Path,
98 context: Option<&OperationContext>,
99 ) -> Result<DependencyMetadata> {
100 let mut parser = FrontmatterParser::new();
101 let result = parser.parse_with_templating::<crate::markdown::MarkdownMetadata>(
102 content,
103 variant_inputs,
104 path,
105 context,
106 )?;
107
108 if let Some(ref markdown_metadata) = result.data {
110 let root_dependencies = markdown_metadata.dependencies.clone();
112 let agpm_dependencies =
113 markdown_metadata.get_agpm_metadata().and_then(|agpm| agpm.dependencies);
114
115 let dependency_metadata = DependencyMetadata::new(
116 root_dependencies,
117 Some(AgpmMetadata {
118 templating: markdown_metadata
119 .get_agpm_metadata()
120 .and_then(|agpm| agpm.templating),
121 dependencies: agpm_dependencies,
122 }),
123 );
124
125 Self::validate_resource_types(&dependency_metadata, path)?;
127 Ok(dependency_metadata)
128 } else {
129 Ok(DependencyMetadata::default())
130 }
131 }
132
133 fn extract_json_field(
138 content: &str,
139 variant_inputs: Option<&serde_json::Value>,
140 path: &Path,
141 context: Option<&OperationContext>,
142 ) -> Result<DependencyMetadata> {
143 let mut parser = FrontmatterParser::new();
145 let templated_content = parser.apply_templating(content, variant_inputs, path)?;
146
147 let json: JsonValue = serde_json::from_str(&templated_content)
148 .with_context(|| "Failed to parse JSON content")?;
149
150 if let Some(deps) = json.get("dependencies") {
151 match serde_json::from_value::<
153 std::collections::BTreeMap<String, Vec<crate::manifest::DependencySpec>>,
154 >(deps.clone())
155 {
156 Ok(dependencies) => {
157 let metadata = DependencyMetadata::new(Some(dependencies), None);
158 Self::validate_resource_types(&metadata, path)?;
160 Ok(metadata)
161 }
162 Err(e) => {
163 if let Some(ctx) = context {
165 if ctx.should_warn_file(path) {
166 eprintln!(
167 "Warning: Unable to parse dependencies field in '{}'.
168
169The document will be processed without metadata, and any declared dependencies
170will NOT be resolved or installed.
171
172Parse error: {}
173
174For the correct dependency format, see:
175https://github.com/aig787/agpm#transitive-dependencies",
176 path.display(),
177 e
178 );
179 }
180 }
181 Ok(DependencyMetadata::default())
182 }
183 }
184 } else {
185 Ok(DependencyMetadata::default())
186 }
187 }
188
189 fn validate_resource_types(metadata: &DependencyMetadata, file_path: &Path) -> Result<()> {
202 const VALID_RESOURCE_TYPES: &[&str] =
203 &["agents", "commands", "snippets", "hooks", "mcp-servers", "scripts", "skills"];
204 const TOOL_NAMES: &[&str] = &["claude-code", "opencode", "agpm"];
205
206 if let Some(dependencies) = metadata.get_dependencies() {
208 for resource_type in dependencies.keys() {
209 if !VALID_RESOURCE_TYPES.contains(&resource_type.as_str()) {
210 if TOOL_NAMES.contains(&resource_type.as_str()) {
211 anyhow::bail!(
213 "Invalid resource type '{}' in dependencies section of '{}'.\n\n\
214 You used a tool name ('{}') as a section header, but AGPM expects resource types.\n\n\
215 ✗ Wrong:\n dependencies:\n {}:\n - path: ...\n\n\
216 ✓ Correct:\n dependencies:\n agents: # or snippets, commands, etc.\n - path: ...\n tool: {} # Specify tool here\n\n\
217 Valid resource types: {}",
218 resource_type,
219 file_path.display(),
220 resource_type,
221 resource_type,
222 resource_type,
223 VALID_RESOURCE_TYPES.join(", ")
224 );
225 }
226 anyhow::bail!(
228 "Unknown resource type '{}' in dependencies section of '{}'.\n\
229 Valid resource types: {}",
230 resource_type,
231 file_path.display(),
232 VALID_RESOURCE_TYPES.join(", ")
233 );
234 }
235 }
236 }
237 Ok(())
238 }
239
240 pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
244 use std::path::PathBuf;
245
246 if (content.starts_with("---\n") || content.starts_with("---\r\n"))
248 && let Ok(metadata) = Self::extract_markdown_frontmatter(
249 content,
250 None,
251 &PathBuf::from("unknown.md"),
252 None,
253 )
254 && metadata.has_dependencies()
255 {
256 return Ok(metadata);
257 }
258
259 if content.trim_start().starts_with('{')
261 && let Ok(metadata) =
262 Self::extract_json_field(content, None, &PathBuf::from("unknown.json"), None)
263 && metadata.has_dependencies()
264 {
265 return Ok(metadata);
266 }
267
268 Ok(DependencyMetadata::default())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::manifest::ProjectConfig;
277
278 #[test]
279 fn test_extract_markdown_frontmatter() {
280 let content = r#"---
281dependencies:
282 agents:
283 - path: agents/helper.md
284 version: v1.0.0
285 - path: agents/reviewer.md
286 snippets:
287 - path: snippets/utils.md
288---
289
290# My Command
291
292This is the command documentation."#;
293
294 let path = Path::new("command.md");
295 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
296
297 assert!(metadata.has_dependencies());
298 let deps = metadata.dependencies.unwrap();
299 assert_eq!(deps["agents"].len(), 2);
300 assert_eq!(deps["snippets"].len(), 1);
301 assert_eq!(deps["agents"][0].path, "agents/helper.md");
302 assert_eq!(deps["agents"][0].version, Some("v1.0.0".to_string()));
303 }
304
305 #[test]
306 fn test_extract_markdown_no_frontmatter() {
307 let content = r#"# My Command
308
309This is a command without frontmatter."#;
310
311 let path = Path::new("command.md");
312 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
313
314 assert!(!metadata.has_dependencies());
315 }
316
317 #[test]
318 fn test_extract_json_dependencies() {
319 let content = r#"{
320 "events": ["UserPromptSubmit"],
321 "type": "command",
322 "command": ".claude/scripts/test.js",
323 "dependencies": {
324 "scripts": [
325 { "path": "scripts/test-runner.sh", "version": "v1.0.0" },
326 { "path": "scripts/validator.py" }
327 ],
328 "agents": [
329 { "path": "agents/code-analyzer.md", "version": "~1.2.0" }
330 ]
331 }
332}"#;
333
334 let path = Path::new("hook.json");
335 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
336
337 assert!(metadata.has_dependencies());
338 let deps = metadata.dependencies.unwrap();
339 assert_eq!(deps["scripts"].len(), 2);
340 assert_eq!(deps["agents"].len(), 1);
341 assert_eq!(deps["scripts"][0].path, "scripts/test-runner.sh");
342 assert_eq!(deps["scripts"][0].version, Some("v1.0.0".to_string()));
343 }
344
345 #[test]
346 fn test_extract_json_no_dependencies() {
347 let content = r#"{
348 "command": "npx",
349 "args": ["-y", "@modelcontextprotocol/server-github"]
350}"#;
351
352 let path = Path::new("mcp.json");
353 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
354
355 assert!(!metadata.has_dependencies());
356 }
357
358 #[test]
359 fn test_extract_script_file() {
360 let content = r#"#!/bin/bash
361echo "This is a script file"
362# Scripts don't support dependencies"#;
363
364 let path = Path::new("script.sh");
365 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
366
367 assert!(!metadata.has_dependencies());
368 }
369
370 #[test]
371 fn test_extract_auto_markdown() {
372 let content = r#"---
373dependencies:
374 agents:
375 - path: agents/test.md
376---
377
378# Content"#;
379
380 let metadata = MetadataExtractor::extract_auto(content).unwrap();
381 assert!(metadata.has_dependencies());
382 assert_eq!(metadata.dependency_count(), 1);
383 }
384
385 #[test]
386 fn test_extract_auto_json() {
387 let content = r#"{
388 "dependencies": {
389 "snippets": [
390 { "path": "snippets/test.md" }
391 ]
392 }
393}"#;
394
395 let metadata = MetadataExtractor::extract_auto(content).unwrap();
396 assert!(metadata.has_dependencies());
397 assert_eq!(metadata.dependency_count(), 1);
398 }
399
400 #[test]
401 fn test_windows_line_endings() {
402 let content = "---\r\ndependencies:\r\n agents:\r\n - path: agents/test.md\r\n---\r\n\r\n# Content";
403
404 let path = Path::new("command.md");
405 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
406
407 assert!(metadata.has_dependencies());
408 let deps = metadata.dependencies.unwrap();
409 assert_eq!(deps["agents"].len(), 1);
410 assert_eq!(deps["agents"][0].path, "agents/test.md");
411 }
412
413 #[test]
414 fn test_empty_dependencies() {
415 let content = r#"---
416dependencies:
417---
418
419# Content"#;
420
421 let path = Path::new("command.md");
422 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
423
424 assert!(!metadata.has_dependencies());
426 }
427
428 #[test]
429 fn test_malformed_yaml() -> Result<(), Box<dyn std::error::Error>> {
430 let content = r#"---
431dependencies:
432 agents:
433 - path: agents/test.md
434 version: missing dash
435---
436
437# Content"#;
438
439 let path = Path::new("command.md");
440 let result = MetadataExtractor::extract(path, content, None, None);
441
442 let metadata = result?;
445 assert!(!metadata.has_dependencies());
447 Ok(())
448 }
449
450 #[test]
451 fn test_extract_with_tool_field() {
452 let content = r#"---
453dependencies:
454 agents:
455 - path: agents/backend.md
456 version: v1.0.0
457 tool: opencode
458 - path: agents/frontend.md
459 tool: claude-code
460---
461
462# Command with multi-tool dependencies"#;
463
464 let path = Path::new("command.md");
465 let metadata = MetadataExtractor::extract(path, content, None, None).unwrap();
466
467 assert!(metadata.has_dependencies());
468 let deps = metadata.dependencies.unwrap();
469 assert_eq!(deps["agents"].len(), 2);
470
471 assert_eq!(deps["agents"][0].path, "agents/backend.md");
473 assert_eq!(deps["agents"][0].tool, Some("opencode".to_string()));
474
475 assert_eq!(deps["agents"][1].path, "agents/frontend.md");
476 assert_eq!(deps["agents"][1].tool, Some("claude-code".to_string()));
477 }
478
479 #[test]
480 fn test_extract_unknown_field_warning() -> Result<(), Box<dyn std::error::Error>> {
481 let content = r#"---
482dependencies:
483 agents:
484 - path: agents/test.md
485 version: v1.0.0
486 invalid_field: should_warn
487---
488
489# Content"#;
490
491 let path = Path::new("command.md");
492 let result = MetadataExtractor::extract(path, content, None, None);
493
494 let metadata = result?;
496 assert!(!metadata.has_dependencies());
498 Ok(())
499 }
500
501 #[test]
502 fn test_template_frontmatter_with_project_vars() {
503 let mut config_map = toml::map::Map::new();
505 config_map.insert("language".to_string(), toml::Value::String("rust".into()));
506 config_map.insert("framework".to_string(), toml::Value::String("tokio".into()));
507 let project_config = ProjectConfig::from(config_map);
508
509 let mut variant_inputs = serde_json::Map::new();
511 variant_inputs.insert("project".to_string(), project_config.to_json_value());
512 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
513
514 let content = r#"---
516agpm:
517 templating: true
518dependencies:
519 snippets:
520 - path: standards/{{ agpm.project.language }}-guide.md
521 version: v1.0.0
522 commands:
523 - path: configs/{{ agpm.project.framework }}-setup.md
524---
525
526# My Agent"#;
527
528 let path = Path::new("agent.md");
529 let metadata =
530 MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
531
532 assert!(metadata.has_dependencies());
533 let deps = metadata.dependencies.unwrap();
534
535 assert_eq!(deps["snippets"].len(), 1);
537 assert_eq!(deps["snippets"][0].path, "standards/rust-guide.md");
538
539 assert_eq!(deps["commands"].len(), 1);
540 assert_eq!(deps["commands"][0].path, "configs/tokio-setup.md");
541 }
542
543 #[test]
544 fn test_template_frontmatter_with_missing_vars() {
545 let mut config_map = toml::map::Map::new();
547 config_map.insert("language".to_string(), toml::Value::String("rust".into()));
548 let project_config = ProjectConfig::from(config_map);
549
550 let mut variant_inputs = serde_json::Map::new();
552 variant_inputs.insert("project".to_string(), project_config.to_json_value());
553 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
554
555 let content = r#"---
557agpm:
558 templating: true
559dependencies:
560 snippets:
561 - path: standards/{{ agpm.project.language }}-{{ agpm.project.undefined }}-guide.md
562---
563
564# My Agent"#;
565
566 let path = Path::new("agent.md");
567 let result = MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None);
568
569 assert!(result.is_err());
571 let error_msg = format!("{}", result.unwrap_err());
572 assert!(error_msg.contains("Failed to render frontmatter template"));
573 assert!(error_msg.contains("Variable") && error_msg.contains("not found"));
575 }
576
577 #[test]
578 fn test_template_frontmatter_with_default_filter() {
579 let mut config_map = toml::map::Map::new();
581 config_map.insert("language".to_string(), toml::Value::String("rust".into()));
582 let project_config = ProjectConfig::from(config_map);
583
584 let mut variant_inputs = serde_json::Map::new();
586 variant_inputs.insert("project".to_string(), project_config.to_json_value());
587 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
588
589 let content = r#"---
591agpm:
592 templating: true
593dependencies:
594 snippets:
595 - path: standards/{{ agpm.project.language }}-{{ agpm.project.style | default(value="standard") }}-guide.md
596---
597
598# My Agent"#;
599
600 let path = Path::new("agent.md");
601 let metadata =
602 MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
603
604 assert!(metadata.has_dependencies());
605 let deps = metadata.dependencies.unwrap();
606
607 assert_eq!(deps["snippets"].len(), 1);
609 assert_eq!(deps["snippets"][0].path, "standards/rust-standard-guide.md");
610 }
611
612 #[test]
613 fn test_template_json_dependencies() {
614 let mut config_map = toml::map::Map::new();
616 config_map.insert("tool".to_string(), toml::Value::String("linter".into()));
617 let project_config = ProjectConfig::from(config_map);
618
619 let mut variant_inputs = serde_json::Map::new();
621 variant_inputs.insert("project".to_string(), project_config.to_json_value());
622 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
623
624 let content = r#"{
626 "events": ["UserPromptSubmit"],
627 "command": "node",
628 "agpm": {
629 "templating": true
630 },
631 "dependencies": {
632 "scripts": [
633 { "path": "scripts/{{ agpm.project.tool }}.js", "version": "v1.0.0" }
634 ]
635 }
636}"#;
637
638 let path = Path::new("hook.json");
639 let metadata =
640 MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
641
642 assert!(metadata.has_dependencies());
643 let deps = metadata.dependencies.unwrap();
644
645 assert_eq!(deps["scripts"].len(), 1);
647 assert_eq!(deps["scripts"][0].path, "scripts/linter.js");
648 }
649
650 #[test]
651 fn test_template_with_no_template_syntax() {
652 let mut config_map = toml::map::Map::new();
654 config_map.insert("language".to_string(), toml::Value::String("rust".into()));
655 let project_config = ProjectConfig::from(config_map);
656
657 let mut variant_inputs = serde_json::Map::new();
659 variant_inputs.insert("project".to_string(), project_config.to_json_value());
660 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
661
662 let content = r#"---
664dependencies:
665 snippets:
666 - path: standards/plain-guide.md
667---
668
669# My Agent"#;
670
671 let path = Path::new("agent.md");
672 let metadata =
673 MetadataExtractor::extract(path, content, Some(&variant_inputs_value), None).unwrap();
674
675 assert!(metadata.has_dependencies());
676 let deps = metadata.dependencies.unwrap();
677
678 assert_eq!(deps["snippets"].len(), 1);
680 assert_eq!(deps["snippets"][0].path, "standards/plain-guide.md");
681 }
682
683 #[test]
684 fn test_template_transitive_dep_path() -> Result<(), Box<dyn std::error::Error>> {
685 use std::path::PathBuf;
686
687 let content = r#"---
689agpm:
690 templating: true
691dependencies:
692 agents:
693 - path: agents/{{ agpm.project.language }}-helper.md
694 version: v1.0.0
695---
696
697# Main Agent
698"#;
699
700 let mut config_map = toml::map::Map::new();
701 config_map.insert("language".to_string(), toml::Value::String("rust".to_string()));
702 let config = ProjectConfig::from(config_map);
703
704 let mut variant_inputs = serde_json::Map::new();
706 variant_inputs.insert("project".to_string(), config.to_json_value());
707 let variant_inputs_value = serde_json::Value::Object(variant_inputs);
708
709 let path = PathBuf::from("agents/main.md");
710 let result = MetadataExtractor::extract(&path, content, Some(&variant_inputs_value), None);
711
712 let metadata = result.context("Should extract metadata")?;
713
714 assert!(metadata.dependencies.is_some(), "Should have dependencies");
716 let deps = metadata.dependencies.unwrap();
717
718 assert!(deps.contains_key("agents"), "Should have agents dependencies");
720 let agents = &deps["agents"];
721
722 assert_eq!(agents.len(), 1, "Should have one agent dependency");
724
725 let dep_path = &agents[0].path;
727 assert_eq!(
728 dep_path, "agents/rust-helper.md",
729 "Path should be templated to rust-helper, got: {}",
730 dep_path
731 );
732 assert!(!dep_path.contains("{{"), "Path should not contain template syntax");
733 assert!(!dep_path.contains("}}"), "Path should not contain template syntax");
734 Ok(())
735 }
736
737 #[test]
738 fn test_validate_tool_name_as_resource_type_yaml() {
739 let content = r#"---
741dependencies:
742 opencode:
743 - path: agents/helper.md
744---
745# Command"#;
746
747 let path = Path::new("command.md");
748 let result = MetadataExtractor::extract(path, content, None, None);
749
750 assert!(result.is_err());
751 let err_msg = result.unwrap_err().to_string();
752 assert!(err_msg.contains("Invalid resource type 'opencode'"));
753 assert!(err_msg.contains("tool name"));
754 assert!(err_msg.contains("agents:"));
755 }
756
757 #[test]
758 fn test_validate_tool_name_as_resource_type_json() {
759 let content = r#"{
761 "dependencies": {
762 "claude-code": [
763 { "path": "snippets/helper.md" }
764 ]
765 }
766}"#;
767
768 let path = Path::new("hook.json");
769 let result = MetadataExtractor::extract(path, content, None, None);
770
771 assert!(result.is_err());
772 let err_msg = result.unwrap_err().to_string();
773 assert!(err_msg.contains("Invalid resource type 'claude-code'"));
774 assert!(err_msg.contains("tool name"));
775 }
776
777 #[test]
778 fn test_validate_unknown_resource_type() {
779 let content = r#"---
781dependencies:
782 foobar:
783 - path: something/test.md
784---
785# Command"#;
786
787 let path = Path::new("command.md");
788 let result = MetadataExtractor::extract(path, content, None, None);
789
790 assert!(result.is_err());
791 let err_msg = result.unwrap_err().to_string();
792 assert!(err_msg.contains("Unknown resource type 'foobar'"));
793 assert!(err_msg.contains("Valid resource types"));
794 }
795
796 #[test]
797 fn test_validate_correct_resource_types() -> anyhow::Result<()> {
798 let content = r#"---
800dependencies:
801 agents:
802 - path: agents/helper.md
803 snippets:
804 - path: snippets/util.md
805 commands:
806 - path: commands/deploy.md
807---
808# Command"#;
809
810 let path = Path::new("command.md");
811 MetadataExtractor::extract(path, content, None, None)?;
812 Ok(())
813 }
814
815 #[test]
816 fn test_warning_deduplication_with_context() {
817 use std::path::PathBuf;
818
819 let ctx = OperationContext::new();
821
822 let path = PathBuf::from("test-file.md");
823 let different_path = PathBuf::from("different-file.md");
824
825 assert!(ctx.should_warn_file(&path));
827
828 assert!(!ctx.should_warn_file(&path));
830
831 assert!(!ctx.should_warn_file(&path));
833
834 assert!(ctx.should_warn_file(&different_path));
836 }
837
838 #[test]
839 fn test_context_isolation() {
840 use std::path::PathBuf;
841
842 let ctx1 = OperationContext::new();
844 let ctx2 = OperationContext::new();
845 let path = PathBuf::from("test-isolation.md");
846
847 assert!(ctx1.should_warn_file(&path));
849 assert!(ctx2.should_warn_file(&path));
850
851 assert!(!ctx1.should_warn_file(&path));
853 assert!(!ctx2.should_warn_file(&path));
854 }
855}