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)]
15#[serde(deny_unknown_fields)]
16pub struct DependencySpec {
17    /// Path to the dependency file within the source repository.
18    ///
19    /// This can be either:
20    /// - A specific file path: `"agents/helper.md"`
21    /// - A glob pattern: `"agents/*.md"`, `"agents/**/review*.md"`
22    pub path: String,
23
24    /// Optional custom name for the dependency in template context.
25    ///
26    /// If specified, this name will be used as the key when accessing this
27    /// dependency in templates (e.g., `agpm.deps.agents.custom_name`).
28    /// If not specified, the name is derived from the path.
29    ///
30    /// Example:
31    /// ```yaml
32    /// dependencies:
33    ///   agents:
34    ///     - path: "agents/complex-path/helper.md"
35    ///       name: "helper"
36    /// ```
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub name: Option<String>,
39
40    /// Optional version constraint for the dependency.
41    ///
42    /// If not specified, the version of the declaring resource is used.
43    /// Supports the same version formats as manifest dependencies:
44    /// - Exact version: `"v1.0.0"`
45    /// - Latest: `"latest"`
46    /// - Branch: `"main"`
47    /// - Commit: `"abc123..."`
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub version: Option<String>,
50
51    /// Optional tool specification for this dependency.
52    ///
53    /// If not specified, inherits from parent (if parent's tool supports this resource type)
54    /// or falls back to the default tool for this resource type.
55    /// - "claude-code" - Install to `.claude/` directories
56    /// - "opencode" - Install to `.opencode/` directories
57    /// - "agpm" - Install to `.agpm/` directories
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub tool: Option<String>,
60
61    /// Optional flatten flag to control directory structure preservation.
62    ///
63    /// When `true`, only the filename is used for installation (e.g., `nested/dir/file.md` → `file.md`).
64    /// When `false` (default for most resources), the full relative path is preserved.
65    ///
66    /// Default values by resource type:
67    /// - `agents`: `true` (flatten by default)
68    /// - `commands`: `true` (flatten by default)
69    /// - All others: `false` (preserve directory structure)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub flatten: Option<bool>,
72
73    /// Optional flag to control whether the dependency should be installed to disk.
74    ///
75    /// When `false`, the dependency will be resolved, fetched, and its content made available
76    /// in template context via `agpm.deps.<type>.<name>.content`, but the file will not be
77    /// written to the project directory. This is useful for snippet embedding use cases where
78    /// you want to include content inline rather than as a separate file.
79    ///
80    /// See [`crate::templating::ResourceTemplateData`] for details on how content is accessed
81    /// in templates.
82    ///
83    /// Default: `true` (install the file)
84    ///
85    /// Example:
86    /// ```yaml
87    /// dependencies:
88    ///   snippets:
89    ///     - path: "snippets/rust-best-practices.md"
90    ///       install: false  # Don't create a separate file
91    ///       name: "best_practices"
92    /// ```
93    /// Then use in template: `{{ agpm.deps.snippets.best_practices.content }}`
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub install: Option<bool>,
96}
97
98/// Metadata extracted from resource files.
99///
100/// This structure represents the dependency information that can be
101/// embedded within resource files themselves, either as YAML frontmatter
102/// in Markdown files or as JSON fields in JSON configuration files.
103#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
104pub struct DependencyMetadata {
105    /// Maps resource type to list of dependency specifications.
106    ///
107    /// The keys are resource types: "agents", "snippets", "commands",
108    /// "scripts", "hooks", "mcp-servers".
109    ///
110    /// Example:
111    /// ```yaml
112    /// dependencies:
113    ///   agents:
114    ///     - path: agents/helper.md
115    ///       version: v1.0.0
116    ///   snippets:
117    ///     - path: snippets/utils.md
118    /// ```
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
121}
122
123impl DependencyMetadata {
124    /// Check if this metadata contains any dependencies.
125    pub fn has_dependencies(&self) -> bool {
126        self.dependencies
127            .as_ref()
128            .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
129    }
130
131    /// Get the total count of dependencies.
132    pub fn dependency_count(&self) -> usize {
133        self.dependencies.as_ref().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
134    }
135
136    /// Merge another metadata into this one.
137    ///
138    /// Used when combining dependencies from multiple sources.
139    pub fn merge(&mut self, other: Self) {
140        if let Some(other_deps) = other.dependencies {
141            let deps = self.dependencies.get_or_insert_with(HashMap::new);
142            for (resource_type, specs) in other_deps {
143                deps.entry(resource_type).or_default().extend(specs);
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_dependency_spec_serialization() {
155        let spec = DependencySpec {
156            path: "agents/helper.md".to_string(),
157            name: None,
158            version: Some("v1.0.0".to_string()),
159            tool: None,
160            flatten: None,
161            install: None,
162        };
163
164        let yaml = serde_yaml::to_string(&spec).unwrap();
165        assert!(yaml.contains("path: agents/helper.md"));
166        assert!(yaml.contains("version: v1.0.0"));
167
168        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
169        assert_eq!(spec, deserialized);
170    }
171
172    #[test]
173    fn test_dependency_spec_with_tool() {
174        let spec = DependencySpec {
175            path: "agents/helper.md".to_string(),
176            name: None,
177            version: Some("v1.0.0".to_string()),
178            tool: Some("opencode".to_string()),
179            flatten: None,
180            install: None,
181        };
182
183        let yaml = serde_yaml::to_string(&spec).unwrap();
184        assert!(yaml.contains("path: agents/helper.md"));
185        assert!(yaml.contains("version: v1.0.0"));
186        assert!(yaml.contains("tool: opencode"));
187
188        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
189        assert_eq!(spec, deserialized);
190        assert_eq!(deserialized.tool, Some("opencode".to_string()));
191    }
192
193    #[test]
194    fn test_dependency_metadata_has_dependencies() {
195        let mut metadata = DependencyMetadata::default();
196        assert!(!metadata.has_dependencies());
197
198        metadata.dependencies = Some(HashMap::new());
199        assert!(!metadata.has_dependencies());
200
201        let mut deps = HashMap::new();
202        deps.insert("agents".to_string(), vec![]);
203        metadata.dependencies = Some(deps);
204        assert!(!metadata.has_dependencies());
205
206        let mut deps = HashMap::new();
207        deps.insert(
208            "agents".to_string(),
209            vec![DependencySpec {
210                path: "test.md".to_string(),
211                name: None,
212                version: None,
213                tool: None,
214                flatten: None,
215                install: None,
216            }],
217        );
218        metadata.dependencies = Some(deps);
219        assert!(metadata.has_dependencies());
220    }
221
222    #[test]
223    fn test_dependency_metadata_merge() {
224        let mut metadata1 = DependencyMetadata::default();
225        let mut deps1 = HashMap::new();
226        deps1.insert(
227            "agents".to_string(),
228            vec![DependencySpec {
229                path: "agent1.md".to_string(),
230                name: None,
231                version: None,
232                tool: None,
233                flatten: None,
234                install: None,
235            }],
236        );
237        metadata1.dependencies = Some(deps1);
238
239        let mut metadata2 = DependencyMetadata::default();
240        let mut deps2 = HashMap::new();
241        deps2.insert(
242            "agents".to_string(),
243            vec![DependencySpec {
244                path: "agent2.md".to_string(),
245                name: None,
246                version: None,
247                tool: None,
248                flatten: None,
249                install: None,
250            }],
251        );
252        deps2.insert(
253            "snippets".to_string(),
254            vec![DependencySpec {
255                path: "snippet1.md".to_string(),
256                name: None,
257                version: Some("v1.0.0".to_string()),
258                tool: None,
259                flatten: None,
260                install: None,
261            }],
262        );
263        metadata2.dependencies = Some(deps2);
264
265        metadata1.merge(metadata2);
266
267        assert_eq!(metadata1.dependency_count(), 3);
268        let deps = metadata1.dependencies.as_ref().unwrap();
269        assert_eq!(deps["agents"].len(), 2);
270        assert_eq!(deps["snippets"].len(), 1);
271    }
272}