agpm_cli/manifest/
dependency_spec.rs

1//! Transitive dependency specifications for resources.
2//!
3//! This module defines the structures used to represent transitive dependencies
4//! that resources can declare within their files (via YAML frontmatter or JSON fields).
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Dependency specification without the source field.
10///
11/// Used within resource files to declare dependencies on other resources
12/// from the same source repository. The source is implicit and inherited
13/// from the resource that declares the dependency.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct DependencySpec {
16    /// Path to the dependency file within the source repository.
17    ///
18    /// This can be either:
19    /// - A specific file path: `"agents/helper.md"`
20    /// - A glob pattern: `"agents/*.md"`, `"agents/**/review*.md"`
21    pub path: String,
22
23    /// Optional version constraint for the dependency.
24    ///
25    /// If not specified, the version of the declaring resource is used.
26    /// Supports the same version formats as manifest dependencies:
27    /// - Exact version: `"v1.0.0"`
28    /// - Latest: `"latest"`
29    /// - Branch: `"main"`
30    /// - Commit: `"abc123..."`
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub version: Option<String>,
33}
34
35/// Metadata extracted from resource files.
36///
37/// This structure represents the dependency information that can be
38/// embedded within resource files themselves, either as YAML frontmatter
39/// in Markdown files or as JSON fields in JSON configuration files.
40#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
41pub struct DependencyMetadata {
42    /// Maps resource type to list of dependency specifications.
43    ///
44    /// The keys are resource types: "agents", "snippets", "commands",
45    /// "scripts", "hooks", "mcp-servers".
46    ///
47    /// Example:
48    /// ```yaml
49    /// dependencies:
50    ///   agents:
51    ///     - path: agents/helper.md
52    ///       version: v1.0.0
53    ///   snippets:
54    ///     - path: snippets/utils.md
55    /// ```
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
58}
59
60impl DependencyMetadata {
61    /// Check if this metadata contains any dependencies.
62    pub fn has_dependencies(&self) -> bool {
63        self.dependencies
64            .as_ref()
65            .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
66    }
67
68    /// Get the total count of dependencies.
69    pub fn dependency_count(&self) -> usize {
70        self.dependencies.as_ref().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
71    }
72
73    /// Merge another metadata into this one.
74    ///
75    /// Used when combining dependencies from multiple sources.
76    pub fn merge(&mut self, other: Self) {
77        if let Some(other_deps) = other.dependencies {
78            let deps = self.dependencies.get_or_insert_with(HashMap::new);
79            for (resource_type, specs) in other_deps {
80                deps.entry(resource_type).or_default().extend(specs);
81            }
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_dependency_spec_serialization() {
92        let spec = DependencySpec {
93            path: "agents/helper.md".to_string(),
94            version: Some("v1.0.0".to_string()),
95        };
96
97        let yaml = serde_yaml::to_string(&spec).unwrap();
98        assert!(yaml.contains("path: agents/helper.md"));
99        assert!(yaml.contains("version: v1.0.0"));
100
101        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
102        assert_eq!(spec, deserialized);
103    }
104
105    #[test]
106    fn test_dependency_metadata_has_dependencies() {
107        let mut metadata = DependencyMetadata::default();
108        assert!(!metadata.has_dependencies());
109
110        metadata.dependencies = Some(HashMap::new());
111        assert!(!metadata.has_dependencies());
112
113        let mut deps = HashMap::new();
114        deps.insert("agents".to_string(), vec![]);
115        metadata.dependencies = Some(deps);
116        assert!(!metadata.has_dependencies());
117
118        let mut deps = HashMap::new();
119        deps.insert(
120            "agents".to_string(),
121            vec![DependencySpec {
122                path: "test.md".to_string(),
123                version: None,
124            }],
125        );
126        metadata.dependencies = Some(deps);
127        assert!(metadata.has_dependencies());
128    }
129
130    #[test]
131    fn test_dependency_metadata_merge() {
132        let mut metadata1 = DependencyMetadata::default();
133        let mut deps1 = HashMap::new();
134        deps1.insert(
135            "agents".to_string(),
136            vec![DependencySpec {
137                path: "agent1.md".to_string(),
138                version: None,
139            }],
140        );
141        metadata1.dependencies = Some(deps1);
142
143        let mut metadata2 = DependencyMetadata::default();
144        let mut deps2 = HashMap::new();
145        deps2.insert(
146            "agents".to_string(),
147            vec![DependencySpec {
148                path: "agent2.md".to_string(),
149                version: None,
150            }],
151        );
152        deps2.insert(
153            "snippets".to_string(),
154            vec![DependencySpec {
155                path: "snippet1.md".to_string(),
156                version: Some("v1.0.0".to_string()),
157            }],
158        );
159        metadata2.dependencies = Some(deps2);
160
161        metadata1.merge(metadata2);
162
163        assert_eq!(metadata1.dependency_count(), 3);
164        let deps = metadata1.dependencies.as_ref().unwrap();
165        assert_eq!(deps["agents"].len(), 2);
166        assert_eq!(deps["snippets"].len(), 1);
167    }
168}