agpm_cli/metadata/
extractor.rs

1//! Extract dependency metadata from resource files.
2//!
3//! This module handles the extraction of transitive dependency information
4//! from resource files. Supports YAML frontmatter in Markdown files and
5//! JSON fields in JSON configuration files.
6//!
7//! # Template Support
8//!
9//! When a `ProjectConfig` is provided, frontmatter is rendered as a Tera template
10//! before parsing. This allows dependency paths to reference project variables:
11//!
12//! ```yaml
13//! dependencies:
14//!   snippets:
15//!     - path: standards/{{ agpm.project.language }}-guide.md
16//! ```
17
18use 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
26/// Metadata extractor for resource files.
27///
28/// Extracts dependency information embedded in resource files:
29/// - Markdown files (.md): YAML frontmatter between `---` delimiters
30/// - JSON files (.json): `dependencies` field in the JSON structure
31/// - Other files: No dependencies supported
32pub struct MetadataExtractor;
33
34impl MetadataExtractor {
35    /// Extract dependency metadata from a file's content.
36    ///
37    /// Uses operation-scoped context for warning deduplication when provided.
38    ///
39    /// # Arguments
40    /// * `path` - Path to the file (used to determine file type)
41    /// * `content` - Content of the file
42    /// * `variant_inputs` - Optional template variables (contains project config and any overrides)
43    /// * `context` - Optional operation context for warning deduplication
44    ///
45    /// # Returns
46    /// * `DependencyMetadata` - Extracted metadata (may be empty)
47    ///
48    /// # Template Support
49    ///
50    /// If `variant_inputs` is provided, frontmatter is rendered as a Tera template
51    /// before parsing, allowing references like:
52    /// `{{ project.language }}` or `{{ config.model }}`
53    ///
54    /// # Examples
55    ///
56    /// ```rust,no_run
57    /// use agpm_cli::core::OperationContext;
58    /// use agpm_cli::metadata::MetadataExtractor;
59    /// use std::path::Path;
60    ///
61    /// let ctx = OperationContext::new();
62    /// let path = Path::new("agent.md");
63    /// let content = "---\ndependencies:\n  agents:\n    - path: helper.md\n---\n# Agent";
64    ///
65    /// let metadata = MetadataExtractor::extract(
66    ///     path,
67    ///     content,
68    ///     None,
69    ///     Some(&ctx)
70    /// ).unwrap();
71    /// ```
72    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                // Scripts and other files don't support embedded dependencies
85                Ok(DependencyMetadata::default())
86            }
87        }
88    }
89
90    /// Extract YAML frontmatter from Markdown content.
91    ///
92    /// Uses the unified frontmatter parser with templating support to extract
93    /// dependency metadata from YAML frontmatter.
94    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        // Convert MarkdownMetadata to DependencyMetadata
109        if let Some(ref markdown_metadata) = result.data {
110            // Extract dependencies from both root-level and agpm section
111            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            // Validate resource types if we successfully parsed metadata
126            Self::validate_resource_types(&dependency_metadata, path)?;
127            Ok(dependency_metadata)
128        } else {
129            Ok(DependencyMetadata::default())
130        }
131    }
132
133    /// Extract dependencies field from JSON content.
134    ///
135    /// Looks for a `dependencies` field in the top-level JSON object.
136    /// Uses unified templating logic to respect per-resource templating settings.
137    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        // Use unified templating logic - always template to catch syntax errors
144        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            // The dependencies field should match our expected structure
152            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                    // Validate resource types (catch tool names used as types)
159                    Self::validate_resource_types(&metadata, path)?;
160                    Ok(metadata)
161                }
162                Err(e) => {
163                    // Only warn once per file to avoid spam during transitive dependency resolution
164                    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    /// Validate that resource type names are correct (not tool names).
190    ///
191    /// Common mistake: using tool names (claude-code, opencode) as section headers
192    /// instead of resource types (agents, snippets, commands).
193    ///
194    /// # Arguments
195    /// * `metadata` - The metadata to validate
196    /// * `file_path` - Path to the file being validated (for error messages)
197    ///
198    /// # Returns
199    /// * `Ok(())` if validation passes
200    /// * `Err` with helpful error message if tool names detected
201    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        // Check both root-level and nested dependencies
207        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                        // Specific error for tool name confusion
212                        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                    // Generic error for unknown types
227                    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    /// Extract metadata from file content without knowing the file type.
241    ///
242    /// Tries to detect the format automatically.
243    pub fn extract_auto(content: &str) -> Result<DependencyMetadata> {
244        use std::path::PathBuf;
245
246        // Try YAML frontmatter first (for Markdown)
247        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        // Try JSON format
260        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        // No metadata found
269        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        // Should parse successfully but have no dependencies
425        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        // With the new frontmatter parser, malformed YAML is handled gracefully
443        // and returns default metadata instead of erroring
444        let metadata = result?;
445        // Should have no dependencies due to parsing failure
446        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        // Verify tool fields are preserved
472        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        // Should succeed but return empty metadata due to unknown field
495        let metadata = result?;
496        // With deny_unknown_fields, the parsing fails and we get empty metadata
497        assert!(!metadata.has_dependencies());
498        Ok(())
499    }
500
501    #[test]
502    fn test_template_frontmatter_with_project_vars() {
503        // Create a project config
504        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        // Convert project config to variant_inputs
510        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        // Markdown with templated dependency path
515        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        // Check that templates were resolved
536        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        // Create a project config with only one variable
546        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        // Convert project config to variant_inputs
551        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        // Template references undefined variable (should error with helpful message)
556        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        // Should error on undefined variable
570        assert!(result.is_err());
571        let error_msg = format!("{}", result.unwrap_err());
572        assert!(error_msg.contains("Failed to render frontmatter template"));
573        // Tera error messages indicate undefined variables, but don't specifically suggest "default" filter
574        assert!(error_msg.contains("Variable") && error_msg.contains("not found"));
575    }
576
577    #[test]
578    fn test_template_frontmatter_with_default_filter() {
579        // Create a project config with only one variable
580        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        // Convert project config to variant_inputs
585        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        // Use default filter for undefined variable (recommended pattern)
590        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        // Default filter provides fallback value
608        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        // Create a project config
615        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        // Convert project config to variant_inputs
620        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        // JSON with templated dependency path
625        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        // Check that template was resolved
646        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        // Create a project config
653        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        // Convert project config to variant_inputs
658        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        // Content without template syntax - should work normally
663        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        // Path should remain unchanged
679        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        // Test that dependency paths in frontmatter are templated correctly
688        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        // Convert project config to variant_inputs
705        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        // Should have dependencies
715        assert!(metadata.dependencies.is_some(), "Should have dependencies");
716        let deps = metadata.dependencies.unwrap();
717
718        // Should have agents key
719        assert!(deps.contains_key("agents"), "Should have agents dependencies");
720        let agents = &deps["agents"];
721
722        // Should have one agent dependency
723        assert_eq!(agents.len(), 1, "Should have one agent dependency");
724
725        // Path should be templated (not contain template syntax)
726        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        // YAML using tool name 'opencode' instead of resource type 'agents'
740        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        // JSON using tool name 'claude-code' instead of resource type 'snippets'
760        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        // Using a completely unknown resource type
780        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        // All valid resource types should pass
799        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        // Create an operation context
820        let ctx = OperationContext::new();
821
822        let path = PathBuf::from("test-file.md");
823        let different_path = PathBuf::from("different-file.md");
824
825        // First call should return true (first warning)
826        assert!(ctx.should_warn_file(&path));
827
828        // Second call should return false (already warned)
829        assert!(!ctx.should_warn_file(&path));
830
831        // Third call should also return false
832        assert!(!ctx.should_warn_file(&path));
833
834        // Different file should still warn
835        assert!(ctx.should_warn_file(&different_path));
836    }
837
838    #[test]
839    fn test_context_isolation() {
840        use std::path::PathBuf;
841
842        // Two separate contexts should be isolated
843        let ctx1 = OperationContext::new();
844        let ctx2 = OperationContext::new();
845        let path = PathBuf::from("test-isolation.md");
846
847        // Both contexts should warn the first time
848        assert!(ctx1.should_warn_file(&path));
849        assert!(ctx2.should_warn_file(&path));
850
851        // Both should deduplicate independently
852        assert!(!ctx1.should_warn_file(&path));
853        assert!(!ctx2.should_warn_file(&path));
854    }
855}