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}