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::BTreeMap;
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<BTreeMap<String, Vec<DependencySpec>>>,
121
122    /// AGPM-specific metadata wrapper supporting templating and nested dependencies.
123    ///
124    /// Example with templating flag and nested dependencies:
125    /// ```yaml
126    /// agpm:
127    ///   templating: true
128    ///   dependencies:
129    ///     snippets:
130    ///       - path: snippets/utils.md
131    /// ```
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub agpm: Option<AgpmMetadata>,
134
135    /// Cached merged dependencies for efficient access.
136    /// This field is not serialized and is computed on demand.
137    #[serde(skip)]
138    merged_cache: std::cell::OnceCell<BTreeMap<String, Vec<DependencySpec>>>,
139}
140
141/// AGPM-specific metadata for templating and nested dependency declarations.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct AgpmMetadata {
144    /// Enable templating for this resource (default: false).
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub templating: Option<bool>,
147
148    /// Dependencies nested under `agpm` section (takes precedence over root-level).
149    ///
150    /// Example:
151    /// ```yaml
152    /// agpm:
153    ///   templating: true
154    ///   dependencies:
155    ///     snippets:
156    ///       - path: snippets/utils.md
157    /// ```
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
160}
161
162impl DependencyMetadata {
163    /// Create a new DependencyMetadata with the given dependencies and agpm metadata.
164    pub fn new(
165        dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
166        agpm: Option<AgpmMetadata>,
167    ) -> Self {
168        Self {
169            dependencies,
170            agpm,
171            merged_cache: std::cell::OnceCell::new(),
172        }
173    }
174
175    /// Get merged dependencies from both nested and root-level locations.
176    ///
177    /// Merges `agpm.dependencies` and `dependencies` into a unified view.
178    /// Root-level dependencies are added first, then nested dependencies.
179    /// Duplicates (same path and name) are removed, keeping the first occurrence.
180    pub fn get_dependencies(&self) -> Option<&BTreeMap<String, Vec<DependencySpec>>> {
181        // Check if we have any dependencies at all
182        let has_root_deps = self.dependencies.is_some();
183        let has_nested_deps =
184            self.agpm.as_ref().and_then(|agpm| agpm.dependencies.as_ref()).is_some();
185
186        if !has_root_deps && !has_nested_deps {
187            return None;
188        }
189
190        // Use OnceCell for lazy caching of merged dependencies
191        let merged = self
192            .merged_cache
193            .get_or_init(|| self.compute_merged_dependencies().unwrap_or_default());
194
195        // Return None if the merged result is empty
196        if merged.is_empty() {
197            None
198        } else {
199            Some(merged)
200        }
201    }
202
203    /// Compute merged dependencies from both sources.
204    ///
205    /// This method performs the actual merging of dependencies from both sources.
206    /// Returns None if neither source has dependencies.
207    fn compute_merged_dependencies(&self) -> Option<BTreeMap<String, Vec<DependencySpec>>> {
208        let mut merged: BTreeMap<String, Vec<DependencySpec>> = BTreeMap::new();
209        let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
210
211        // Add root-level dependencies first
212        if let Some(root_deps) = &self.dependencies {
213            for (resource_type, specs) in root_deps {
214                let filtered_specs: Vec<DependencySpec> = specs
215                    .iter()
216                    .filter(|spec| seen_paths.insert(spec.path.clone()))
217                    .cloned()
218                    .collect();
219
220                if !filtered_specs.is_empty() {
221                    merged.insert(resource_type.clone(), filtered_specs);
222                }
223            }
224        }
225
226        // Add nested dependencies second (root takes precedence for duplicates)
227        if let Some(agpm) = &self.agpm {
228            if let Some(nested_deps) = &agpm.dependencies {
229                for (resource_type, specs) in nested_deps {
230                    let existing_specs = merged.entry(resource_type.clone()).or_default();
231                    let filtered_specs: Vec<DependencySpec> = specs
232                        .iter()
233                        .filter(|spec| seen_paths.insert(spec.path.clone()))
234                        .cloned()
235                        .collect();
236
237                    existing_specs.extend(filtered_specs);
238
239                    // Remove empty resource type entries
240                    if existing_specs.is_empty() {
241                        merged.remove(resource_type);
242                    }
243                }
244            }
245        }
246
247        // Return None if no actual dependencies were added
248        if merged.is_empty() {
249            None
250        } else {
251            Some(merged)
252        }
253    }
254
255    /// Check if metadata contains any non-empty dependencies.
256    pub fn has_dependencies(&self) -> bool {
257        self.get_dependencies()
258            .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
259    }
260
261    /// Count total dependencies across all resource types.
262    pub fn dependency_count(&self) -> usize {
263        self.get_dependencies().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
264    }
265
266    /// Merge another metadata into this one.
267    ///
268    /// Used when combining dependencies from multiple sources.
269    pub fn merge(&mut self, other: Self) {
270        // Clear cache since we're modifying the dependencies
271        self.merged_cache = std::cell::OnceCell::new();
272
273        if let Some(other_deps) = other.dependencies {
274            let deps = self.dependencies.get_or_insert_with(BTreeMap::new);
275            for (resource_type, specs) in other_deps {
276                deps.entry(resource_type).or_default().extend(specs);
277            }
278        }
279
280        // Also merge agpm dependencies if present
281        if let Some(other_agpm) = other.agpm {
282            if let Some(other_agpm_deps) = other_agpm.dependencies {
283                let agpm = self.agpm.get_or_insert(AgpmMetadata {
284                    templating: None,
285                    dependencies: None,
286                });
287                let agpm_deps = agpm.dependencies.get_or_insert_with(BTreeMap::new);
288                for (resource_type, specs) in other_agpm_deps {
289                    agpm_deps.entry(resource_type).or_default().extend(specs);
290                }
291            }
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_dependency_spec_serialization() {
302        let spec = DependencySpec {
303            path: "agents/helper.md".to_string(),
304            name: None,
305            version: Some("v1.0.0".to_string()),
306            tool: None,
307            flatten: None,
308            install: None,
309        };
310
311        let yaml = serde_yaml::to_string(&spec).unwrap();
312        assert!(yaml.contains("path: agents/helper.md"));
313        assert!(yaml.contains("version: v1.0.0"));
314
315        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
316        assert_eq!(spec, deserialized);
317    }
318
319    #[test]
320    fn test_dependency_spec_with_tool() {
321        let spec = DependencySpec {
322            path: "agents/helper.md".to_string(),
323            name: None,
324            version: Some("v1.0.0".to_string()),
325            tool: Some("opencode".to_string()),
326            flatten: None,
327            install: None,
328        };
329
330        let yaml = serde_yaml::to_string(&spec).unwrap();
331        assert!(yaml.contains("path: agents/helper.md"));
332        assert!(yaml.contains("version: v1.0.0"));
333        assert!(yaml.contains("tool: opencode"));
334
335        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
336        assert_eq!(spec, deserialized);
337        assert_eq!(deserialized.tool, Some("opencode".to_string()));
338    }
339
340    #[test]
341    fn test_dependency_metadata_has_dependencies() {
342        let metadata = DependencyMetadata::default();
343        assert!(!metadata.has_dependencies());
344
345        let metadata = DependencyMetadata::new(Some(BTreeMap::new()), None);
346        assert!(!metadata.has_dependencies());
347
348        let mut deps = BTreeMap::new();
349        deps.insert("agents".to_string(), vec![]);
350        let metadata = DependencyMetadata::new(Some(deps), None);
351        assert!(!metadata.has_dependencies());
352
353        let mut deps = BTreeMap::new();
354        deps.insert(
355            "agents".to_string(),
356            vec![DependencySpec {
357                path: "test.md".to_string(),
358                name: None,
359                version: None,
360                tool: None,
361                flatten: None,
362                install: None,
363            }],
364        );
365        let metadata = DependencyMetadata::new(Some(deps), None);
366        assert!(metadata.has_dependencies());
367    }
368
369    #[test]
370    fn test_dependency_metadata_merge() {
371        let mut metadata1 = DependencyMetadata::default();
372        let mut deps1 = BTreeMap::new();
373        deps1.insert(
374            "agents".to_string(),
375            vec![DependencySpec {
376                path: "agent1.md".to_string(),
377                name: None,
378                version: None,
379                tool: None,
380                flatten: None,
381                install: None,
382            }],
383        );
384        metadata1.dependencies = Some(deps1);
385
386        let mut metadata2 = DependencyMetadata::default();
387        let mut deps2 = BTreeMap::new();
388        deps2.insert(
389            "agents".to_string(),
390            vec![DependencySpec {
391                path: "agent2.md".to_string(),
392                name: None,
393                version: None,
394                tool: None,
395                flatten: None,
396                install: None,
397            }],
398        );
399        deps2.insert(
400            "snippets".to_string(),
401            vec![DependencySpec {
402                path: "snippet1.md".to_string(),
403                name: None,
404                version: Some("v1.0.0".to_string()),
405                tool: None,
406                flatten: None,
407                install: None,
408            }],
409        );
410        metadata2.dependencies = Some(deps2);
411
412        metadata1.merge(metadata2);
413
414        assert_eq!(metadata1.dependency_count(), 3);
415        let deps = metadata1.get_dependencies().unwrap();
416        assert_eq!(deps["agents"].len(), 2);
417        assert_eq!(deps["snippets"].len(), 1);
418    }
419
420    #[test]
421    fn test_merged_dependencies_root_only() {
422        let mut root_deps = BTreeMap::new();
423        root_deps.insert(
424            "agents".to_string(),
425            vec![DependencySpec {
426                path: "agent1.md".to_string(),
427                name: None,
428                version: Some("v1.0.0".to_string()),
429                tool: None,
430                flatten: None,
431                install: None,
432            }],
433        );
434        let metadata = DependencyMetadata::new(Some(root_deps), None);
435
436        let merged = metadata.get_dependencies().unwrap();
437        assert_eq!(merged.len(), 1);
438        assert_eq!(merged["agents"].len(), 1);
439        assert_eq!(merged["agents"][0].path, "agent1.md");
440        assert_eq!(metadata.dependency_count(), 1);
441        assert!(metadata.has_dependencies());
442    }
443
444    #[test]
445    fn test_merged_dependencies_nested_only() {
446        let mut nested_deps = BTreeMap::new();
447        nested_deps.insert(
448            "snippets".to_string(),
449            vec![DependencySpec {
450                path: "utils.md".to_string(),
451                name: Some("utils".to_string()),
452                version: Some("v2.0.0".to_string()),
453                tool: None,
454                flatten: None,
455                install: None,
456            }],
457        );
458        let agpm = AgpmMetadata {
459            templating: Some(true),
460            dependencies: Some(nested_deps),
461        };
462        let metadata = DependencyMetadata::new(None, Some(agpm));
463
464        let merged = metadata.get_dependencies().unwrap();
465        assert_eq!(merged.len(), 1);
466        assert_eq!(merged["snippets"].len(), 1);
467        assert_eq!(merged["snippets"][0].path, "utils.md");
468        assert_eq!(merged["snippets"][0].name, Some("utils".to_string()));
469        assert_eq!(metadata.dependency_count(), 1);
470        assert!(metadata.has_dependencies());
471    }
472
473    #[test]
474    fn test_merged_dependencies_both_sources() {
475        // Root-level dependencies
476        let mut root_deps = BTreeMap::new();
477        root_deps.insert(
478            "agents".to_string(),
479            vec![
480                DependencySpec {
481                    path: "agent1.md".to_string(),
482                    name: None,
483                    version: Some("v1.0.0".to_string()),
484                    tool: None,
485                    flatten: None,
486                    install: None,
487                },
488                DependencySpec {
489                    path: "shared.md".to_string(),
490                    name: Some("shared_root".to_string()),
491                    version: Some("v1.0.0".to_string()),
492                    tool: None,
493                    flatten: None,
494                    install: None,
495                },
496            ],
497        );
498
499        // Nested dependencies
500        let mut nested_deps = BTreeMap::new();
501        nested_deps.insert(
502            "snippets".to_string(),
503            vec![DependencySpec {
504                path: "utils.md".to_string(),
505                name: None,
506                version: Some("v2.0.0".to_string()),
507                tool: None,
508                flatten: None,
509                install: None,
510            }],
511        );
512        nested_deps.insert(
513            "agents".to_string(),
514            vec![
515                DependencySpec {
516                    path: "agent2.md".to_string(),
517                    name: None,
518                    version: Some("v2.0.0".to_string()),
519                    tool: None,
520                    flatten: None,
521                    install: None,
522                },
523                // Duplicate path with different name - should be filtered out
524                DependencySpec {
525                    path: "shared.md".to_string(),
526                    name: Some("shared_nested".to_string()),
527                    version: Some("v2.0.0".to_string()),
528                    tool: None,
529                    flatten: None,
530                    install: None,
531                },
532            ],
533        );
534        let agpm = AgpmMetadata {
535            templating: Some(true),
536            dependencies: Some(nested_deps),
537        };
538        let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
539
540        let merged = metadata.get_dependencies().unwrap();
541
542        // Should have both resource types
543        assert_eq!(merged.len(), 2);
544
545        // Agents should have 3 total (2 root + 1 nested, duplicate filtered)
546        assert_eq!(merged["agents"].len(), 3);
547        assert_eq!(merged["agents"][0].path, "agent1.md");
548        assert_eq!(merged["agents"][1].path, "shared.md");
549        assert_eq!(merged["agents"][1].name, Some("shared_root".to_string()));
550        assert_eq!(merged["agents"][2].path, "agent2.md");
551
552        // Snippets should have 1 from nested
553        assert_eq!(merged["snippets"].len(), 1);
554        assert_eq!(merged["snippets"][0].path, "utils.md");
555
556        assert_eq!(metadata.dependency_count(), 4);
557        assert!(metadata.has_dependencies());
558    }
559
560    #[test]
561    fn test_merged_dependencies_no_duplicates() {
562        // Root-level dependencies
563        let mut root_deps = BTreeMap::new();
564        root_deps.insert(
565            "agents".to_string(),
566            vec![
567                DependencySpec {
568                    path: "agent.md".to_string(),
569                    name: None,
570                    version: Some("v1.0.0".to_string()),
571                    tool: None,
572                    flatten: None,
573                    install: None,
574                },
575                DependencySpec {
576                    path: "agent.md".to_string(),
577                    name: Some("custom".to_string()),
578                    version: Some("v1.0.0".to_string()),
579                    tool: None,
580                    flatten: None,
581                    install: None,
582                },
583            ],
584        );
585
586        // Nested dependencies with same path
587        let mut nested_deps = BTreeMap::new();
588        nested_deps.insert(
589            "agents".to_string(),
590            vec![DependencySpec {
591                path: "agent.md".to_string(),
592                name: Some("nested".to_string()),
593                version: Some("v2.0.0".to_string()),
594                tool: None,
595                flatten: None,
596                install: None,
597            }],
598        );
599        let agpm = AgpmMetadata {
600            templating: None,
601            dependencies: Some(nested_deps),
602        };
603        let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
604
605        let merged = metadata.get_dependencies().unwrap();
606
607        // Should only have 1 dependency (duplicates filtered)
608        assert_eq!(merged.len(), 1);
609        assert_eq!(merged["agents"].len(), 1);
610        assert_eq!(merged["agents"][0].path, "agent.md");
611        assert_eq!(merged["agents"][0].name, None); // First occurrence kept
612
613        assert_eq!(metadata.dependency_count(), 1);
614    }
615
616    #[test]
617    fn test_merged_dependencies_empty() {
618        let metadata = DependencyMetadata::default();
619
620        assert!(metadata.get_dependencies().is_none());
621        assert_eq!(metadata.dependency_count(), 0);
622        assert!(!metadata.has_dependencies());
623    }
624
625    #[test]
626    fn test_merged_dependencies_empty_maps() {
627        let agpm = AgpmMetadata {
628            templating: None,
629            dependencies: Some(BTreeMap::new()),
630        };
631        let metadata = DependencyMetadata::new(Some(BTreeMap::new()), Some(agpm));
632
633        assert!(metadata.get_dependencies().is_none());
634        assert_eq!(metadata.dependency_count(), 0);
635        assert!(!metadata.has_dependencies());
636    }
637
638    #[test]
639    fn test_merged_dependencies_with_agpm_merge() {
640        let mut metadata1 = DependencyMetadata::default();
641        let mut root_deps = BTreeMap::new();
642        root_deps.insert(
643            "agents".to_string(),
644            vec![DependencySpec {
645                path: "agent1.md".to_string(),
646                name: None,
647                version: None,
648                tool: None,
649                flatten: None,
650                install: None,
651            }],
652        );
653        metadata1.dependencies = Some(root_deps);
654
655        let mut metadata2 = DependencyMetadata::default();
656        let mut nested_deps = BTreeMap::new();
657        nested_deps.insert(
658            "snippets".to_string(),
659            vec![DependencySpec {
660                path: "snippet1.md".to_string(),
661                name: None,
662                version: None,
663                tool: None,
664                flatten: None,
665                install: None,
666            }],
667        );
668        metadata2.agpm = Some(AgpmMetadata {
669            templating: Some(true),
670            dependencies: Some(nested_deps),
671        });
672
673        metadata1.merge(metadata2);
674
675        let merged = metadata1.get_dependencies().unwrap();
676        assert_eq!(merged.len(), 2); // Both resource types present
677        assert_eq!(metadata1.dependency_count(), 2);
678        assert!(metadata1.agpm.is_some());
679        assert!(metadata1.agpm.unwrap().dependencies.is_some());
680    }
681}