1use 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
26pub struct MetadataExtractor;
33
34impl MetadataExtractor {
35 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 Ok(DependencyMetadata::default())
63 }
64 }
65 }
66
67 fn extract_markdown_frontmatter(
72 content: &str,
73 project_config: Option<&ProjectConfig>,
74 path: &Path,
75 ) -> Result<DependencyMetadata> {
76 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
78 return Ok(DependencyMetadata::default());
79 }
80
81 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 let templating_disabled = if let Some(_config) = project_config {
99 Self::is_templating_disabled_yaml(frontmatter)
100 } else {
101 false
102 };
103
104 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 match serde_yaml::from_str::<DependencyMetadata>(&templated_frontmatter) {
118 Ok(metadata) => {
119 Self::validate_resource_types(&metadata, path)?;
121 Ok(metadata)
122 }
123 Err(e) => {
124 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 Ok(DependencyMetadata::default())
151 }
152 }
153
154 fn extract_json_field(
159 content: &str,
160 project_config: Option<&ProjectConfig>,
161 path: &Path,
162 ) -> Result<DependencyMetadata> {
163 let templating_disabled = if let Some(_config) = project_config {
165 Self::is_templating_disabled_json(content)
166 } else {
167 false
168 };
169
170 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 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 Self::validate_resource_types(&metadata, path)?;
196 Ok(metadata)
197 }
198 Err(e) => {
199 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 fn is_templating_disabled_yaml(frontmatter: &str) -> bool {
233 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) } else {
242 true }
244 }
245
246 fn is_templating_disabled_json(content: &str) -> bool {
251 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) } else {
259 true }
261 }
262
263 fn template_content(
285 content: &str,
286 project_config: &ProjectConfig,
287 path: &Path,
288 ) -> Result<String> {
289 if !content.contains("{{") && !content.contains("{%") {
291 return Ok(content.to_string());
292 }
293
294 let mut tera = Tera::default();
295 tera.autoescape_on(vec![]); let mut context = TeraContext::new();
298
299 let mut agpm = Map::new();
301 agpm.insert("project".to_string(), project_config.to_json_value());
302 context.insert("agpm", &agpm);
303
304 tera.render_str(content, &context).map_err(|e| {
306 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 fn format_tera_error(error: &tera::Error) -> String {
333 use std::error::Error;
334
335 let mut messages = Vec::new();
336
337 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 for msg in all_messages {
347 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 if !cleaned.is_empty()
358 && cleaned != "Template rendering failed"
359 && cleaned != "Template syntax error"
360 {
361 messages.push(cleaned);
362 }
363 }
364
365 if !messages.is_empty() {
367 messages.join("\n → ")
368 } else {
369 "Template syntax error (see details above)".to_string()
371 }
372 }
373
374 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 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 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 pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
429 use std::path::PathBuf;
430
431 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 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 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 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 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 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 assert!(result.is_ok());
674 let metadata = result.unwrap();
675 assert!(!metadata.has_dependencies());
677 }
678
679 #[test]
680 fn test_template_frontmatter_with_project_vars() {
681 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 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 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 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 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 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")); }
742
743 #[test]
744 fn test_template_frontmatter_with_default_filter() {
745 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(metadata.dependencies.is_some(), "Should have dependencies");
889 let deps = metadata.dependencies.unwrap();
890
891 assert!(deps.contains_key("agents"), "Should have agents dependencies");
893 let agents = &deps["agents"];
894
895 assert_eq!(agents.len(), 1, "Should have one agent dependency");
897
898 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 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 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 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 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 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 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 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}