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 templating module 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    /// Get merged dependencies with ResourceType keys instead of strings.
204    ///
205    /// This is a type-safe version of `get_dependencies()` that parses the
206    /// string keys into ResourceType enums. Invalid resource type strings are logged
207    /// and skipped.
208    ///
209    /// # Returns
210    ///
211    /// HashMap with ResourceType keys, or None if no valid dependencies
212    pub fn get_dependencies_typed(
213        &self,
214    ) -> Option<std::collections::HashMap<crate::core::ResourceType, Vec<DependencySpec>>> {
215        let deps = self.get_dependencies()?;
216        let mut result = std::collections::HashMap::new();
217
218        for (resource_type_str, specs) in deps {
219            // Parse string to ResourceType
220            if let Ok(resource_type) = resource_type_str.parse::<crate::core::ResourceType>() {
221                result.insert(resource_type, specs.clone());
222            } else {
223                tracing::warn!("Unknown resource type in dependencies: {}", resource_type_str);
224            }
225        }
226
227        if result.is_empty() {
228            None
229        } else {
230            Some(result)
231        }
232    }
233
234    /// Compute merged dependencies from both sources.
235    ///
236    /// This method performs the actual merging of dependencies from both sources.
237    /// Returns None if neither source has dependencies.
238    fn compute_merged_dependencies(&self) -> Option<BTreeMap<String, Vec<DependencySpec>>> {
239        let mut merged: BTreeMap<String, Vec<DependencySpec>> = BTreeMap::new();
240        let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
241
242        // Add root-level dependencies first
243        if let Some(root_deps) = &self.dependencies {
244            for (resource_type, specs) in root_deps {
245                let filtered_specs: Vec<DependencySpec> = specs
246                    .iter()
247                    .filter(|spec| seen_paths.insert(spec.path.clone()))
248                    .cloned()
249                    .collect();
250
251                if !filtered_specs.is_empty() {
252                    merged.insert(resource_type.clone(), filtered_specs);
253                }
254            }
255        }
256
257        // Add nested dependencies second (root takes precedence for duplicates)
258        if let Some(agpm) = &self.agpm {
259            if let Some(nested_deps) = &agpm.dependencies {
260                for (resource_type, specs) in nested_deps {
261                    let existing_specs = merged.entry(resource_type.clone()).or_default();
262                    let filtered_specs: Vec<DependencySpec> = specs
263                        .iter()
264                        .filter(|spec| seen_paths.insert(spec.path.clone()))
265                        .cloned()
266                        .collect();
267
268                    existing_specs.extend(filtered_specs);
269
270                    // Remove empty resource type entries
271                    if existing_specs.is_empty() {
272                        merged.remove(resource_type);
273                    }
274                }
275            }
276        }
277
278        // Return None if no actual dependencies were added
279        if merged.is_empty() {
280            None
281        } else {
282            Some(merged)
283        }
284    }
285
286    /// Check if metadata contains any non-empty dependencies.
287    pub fn has_dependencies(&self) -> bool {
288        self.get_dependencies()
289            .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
290    }
291
292    /// Count total dependencies across all resource types.
293    pub fn dependency_count(&self) -> usize {
294        self.get_dependencies().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
295    }
296
297    /// Merge another metadata into this one.
298    ///
299    /// Used when combining dependencies from multiple sources.
300    pub fn merge(&mut self, other: Self) {
301        // Clear cache since we're modifying the dependencies
302        self.merged_cache = std::cell::OnceCell::new();
303
304        if let Some(other_deps) = other.dependencies {
305            let deps = self.dependencies.get_or_insert_with(BTreeMap::new);
306            for (resource_type, specs) in other_deps {
307                deps.entry(resource_type).or_default().extend(specs);
308            }
309        }
310
311        // Also merge agpm dependencies if present
312        if let Some(other_agpm) = other.agpm {
313            if let Some(other_agpm_deps) = other_agpm.dependencies {
314                let agpm = self.agpm.get_or_insert(AgpmMetadata {
315                    templating: None,
316                    dependencies: None,
317                });
318                let agpm_deps = agpm.dependencies.get_or_insert_with(BTreeMap::new);
319                for (resource_type, specs) in other_agpm_deps {
320                    agpm_deps.entry(resource_type).or_default().extend(specs);
321                }
322            }
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_dependency_spec_serialization() {
333        let spec = DependencySpec {
334            path: "agents/helper.md".to_string(),
335            name: None,
336            version: Some("v1.0.0".to_string()),
337            tool: None,
338            flatten: None,
339            install: None,
340        };
341
342        let yaml = serde_yaml::to_string(&spec).unwrap();
343        assert!(yaml.contains("path: agents/helper.md"));
344        assert!(yaml.contains("version: v1.0.0"));
345
346        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
347        assert_eq!(spec, deserialized);
348    }
349
350    #[test]
351    fn test_dependency_spec_with_tool() {
352        let spec = DependencySpec {
353            path: "agents/helper.md".to_string(),
354            name: None,
355            version: Some("v1.0.0".to_string()),
356            tool: Some("opencode".to_string()),
357            flatten: None,
358            install: None,
359        };
360
361        let yaml = serde_yaml::to_string(&spec).unwrap();
362        assert!(yaml.contains("path: agents/helper.md"));
363        assert!(yaml.contains("version: v1.0.0"));
364        assert!(yaml.contains("tool: opencode"));
365
366        let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
367        assert_eq!(spec, deserialized);
368        assert_eq!(deserialized.tool, Some("opencode".to_string()));
369    }
370
371    #[test]
372    fn test_dependency_metadata_has_dependencies() {
373        let metadata = DependencyMetadata::default();
374        assert!(!metadata.has_dependencies());
375
376        let metadata = DependencyMetadata::new(Some(BTreeMap::new()), None);
377        assert!(!metadata.has_dependencies());
378
379        let mut deps = BTreeMap::new();
380        deps.insert("agents".to_string(), vec![]);
381        let metadata = DependencyMetadata::new(Some(deps), None);
382        assert!(!metadata.has_dependencies());
383
384        let mut deps = BTreeMap::new();
385        deps.insert(
386            "agents".to_string(),
387            vec![DependencySpec {
388                path: "test.md".to_string(),
389                name: None,
390                version: None,
391                tool: None,
392                flatten: None,
393                install: None,
394            }],
395        );
396        let metadata = DependencyMetadata::new(Some(deps), None);
397        assert!(metadata.has_dependencies());
398    }
399
400    #[test]
401    fn test_dependency_metadata_merge() {
402        let mut metadata1 = DependencyMetadata::default();
403        let mut deps1 = BTreeMap::new();
404        deps1.insert(
405            "agents".to_string(),
406            vec![DependencySpec {
407                path: "agent1.md".to_string(),
408                name: None,
409                version: None,
410                tool: None,
411                flatten: None,
412                install: None,
413            }],
414        );
415        metadata1.dependencies = Some(deps1);
416
417        let mut metadata2 = DependencyMetadata::default();
418        let mut deps2 = BTreeMap::new();
419        deps2.insert(
420            "agents".to_string(),
421            vec![DependencySpec {
422                path: "agent2.md".to_string(),
423                name: None,
424                version: None,
425                tool: None,
426                flatten: None,
427                install: None,
428            }],
429        );
430        deps2.insert(
431            "snippets".to_string(),
432            vec![DependencySpec {
433                path: "snippet1.md".to_string(),
434                name: None,
435                version: Some("v1.0.0".to_string()),
436                tool: None,
437                flatten: None,
438                install: None,
439            }],
440        );
441        metadata2.dependencies = Some(deps2);
442
443        metadata1.merge(metadata2);
444
445        assert_eq!(metadata1.dependency_count(), 3);
446        let deps = metadata1.get_dependencies().unwrap();
447        assert_eq!(deps["agents"].len(), 2);
448        assert_eq!(deps["snippets"].len(), 1);
449    }
450
451    #[test]
452    fn test_merged_dependencies_root_only() {
453        let mut root_deps = BTreeMap::new();
454        root_deps.insert(
455            "agents".to_string(),
456            vec![DependencySpec {
457                path: "agent1.md".to_string(),
458                name: None,
459                version: Some("v1.0.0".to_string()),
460                tool: None,
461                flatten: None,
462                install: None,
463            }],
464        );
465        let metadata = DependencyMetadata::new(Some(root_deps), None);
466
467        let merged = metadata.get_dependencies().unwrap();
468        assert_eq!(merged.len(), 1);
469        assert_eq!(merged["agents"].len(), 1);
470        assert_eq!(merged["agents"][0].path, "agent1.md");
471        assert_eq!(metadata.dependency_count(), 1);
472        assert!(metadata.has_dependencies());
473    }
474
475    #[test]
476    fn test_merged_dependencies_nested_only() {
477        let mut nested_deps = BTreeMap::new();
478        nested_deps.insert(
479            "snippets".to_string(),
480            vec![DependencySpec {
481                path: "utils.md".to_string(),
482                name: Some("utils".to_string()),
483                version: Some("v2.0.0".to_string()),
484                tool: None,
485                flatten: None,
486                install: None,
487            }],
488        );
489        let agpm = AgpmMetadata {
490            templating: Some(true),
491            dependencies: Some(nested_deps),
492        };
493        let metadata = DependencyMetadata::new(None, Some(agpm));
494
495        let merged = metadata.get_dependencies().unwrap();
496        assert_eq!(merged.len(), 1);
497        assert_eq!(merged["snippets"].len(), 1);
498        assert_eq!(merged["snippets"][0].path, "utils.md");
499        assert_eq!(merged["snippets"][0].name, Some("utils".to_string()));
500        assert_eq!(metadata.dependency_count(), 1);
501        assert!(metadata.has_dependencies());
502    }
503
504    #[test]
505    fn test_merged_dependencies_both_sources() {
506        // Root-level dependencies
507        let mut root_deps = BTreeMap::new();
508        root_deps.insert(
509            "agents".to_string(),
510            vec![
511                DependencySpec {
512                    path: "agent1.md".to_string(),
513                    name: None,
514                    version: Some("v1.0.0".to_string()),
515                    tool: None,
516                    flatten: None,
517                    install: None,
518                },
519                DependencySpec {
520                    path: "shared.md".to_string(),
521                    name: Some("shared_root".to_string()),
522                    version: Some("v1.0.0".to_string()),
523                    tool: None,
524                    flatten: None,
525                    install: None,
526                },
527            ],
528        );
529
530        // Nested dependencies
531        let mut nested_deps = BTreeMap::new();
532        nested_deps.insert(
533            "snippets".to_string(),
534            vec![DependencySpec {
535                path: "utils.md".to_string(),
536                name: None,
537                version: Some("v2.0.0".to_string()),
538                tool: None,
539                flatten: None,
540                install: None,
541            }],
542        );
543        nested_deps.insert(
544            "agents".to_string(),
545            vec![
546                DependencySpec {
547                    path: "agent2.md".to_string(),
548                    name: None,
549                    version: Some("v2.0.0".to_string()),
550                    tool: None,
551                    flatten: None,
552                    install: None,
553                },
554                // Duplicate path with different name - should be filtered out
555                DependencySpec {
556                    path: "shared.md".to_string(),
557                    name: Some("shared_nested".to_string()),
558                    version: Some("v2.0.0".to_string()),
559                    tool: None,
560                    flatten: None,
561                    install: None,
562                },
563            ],
564        );
565        let agpm = AgpmMetadata {
566            templating: Some(true),
567            dependencies: Some(nested_deps),
568        };
569        let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
570
571        let merged = metadata.get_dependencies().unwrap();
572
573        // Should have both resource types
574        assert_eq!(merged.len(), 2);
575
576        // Agents should have 3 total (2 root + 1 nested, duplicate filtered)
577        assert_eq!(merged["agents"].len(), 3);
578        assert_eq!(merged["agents"][0].path, "agent1.md");
579        assert_eq!(merged["agents"][1].path, "shared.md");
580        assert_eq!(merged["agents"][1].name, Some("shared_root".to_string()));
581        assert_eq!(merged["agents"][2].path, "agent2.md");
582
583        // Snippets should have 1 from nested
584        assert_eq!(merged["snippets"].len(), 1);
585        assert_eq!(merged["snippets"][0].path, "utils.md");
586
587        assert_eq!(metadata.dependency_count(), 4);
588        assert!(metadata.has_dependencies());
589    }
590
591    #[test]
592    fn test_merged_dependencies_no_duplicates() {
593        // Root-level dependencies
594        let mut root_deps = BTreeMap::new();
595        root_deps.insert(
596            "agents".to_string(),
597            vec![
598                DependencySpec {
599                    path: "agent.md".to_string(),
600                    name: None,
601                    version: Some("v1.0.0".to_string()),
602                    tool: None,
603                    flatten: None,
604                    install: None,
605                },
606                DependencySpec {
607                    path: "agent.md".to_string(),
608                    name: Some("custom".to_string()),
609                    version: Some("v1.0.0".to_string()),
610                    tool: None,
611                    flatten: None,
612                    install: None,
613                },
614            ],
615        );
616
617        // Nested dependencies with same path
618        let mut nested_deps = BTreeMap::new();
619        nested_deps.insert(
620            "agents".to_string(),
621            vec![DependencySpec {
622                path: "agent.md".to_string(),
623                name: Some("nested".to_string()),
624                version: Some("v2.0.0".to_string()),
625                tool: None,
626                flatten: None,
627                install: None,
628            }],
629        );
630        let agpm = AgpmMetadata {
631            templating: None,
632            dependencies: Some(nested_deps),
633        };
634        let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
635
636        let merged = metadata.get_dependencies().unwrap();
637
638        // Should only have 1 dependency (duplicates filtered)
639        assert_eq!(merged.len(), 1);
640        assert_eq!(merged["agents"].len(), 1);
641        assert_eq!(merged["agents"][0].path, "agent.md");
642        assert_eq!(merged["agents"][0].name, None); // First occurrence kept
643
644        assert_eq!(metadata.dependency_count(), 1);
645    }
646
647    #[test]
648    fn test_merged_dependencies_empty() {
649        let metadata = DependencyMetadata::default();
650
651        assert!(metadata.get_dependencies().is_none());
652        assert_eq!(metadata.dependency_count(), 0);
653        assert!(!metadata.has_dependencies());
654    }
655
656    #[test]
657    fn test_merged_dependencies_empty_maps() {
658        let agpm = AgpmMetadata {
659            templating: None,
660            dependencies: Some(BTreeMap::new()),
661        };
662        let metadata = DependencyMetadata::new(Some(BTreeMap::new()), Some(agpm));
663
664        assert!(metadata.get_dependencies().is_none());
665        assert_eq!(metadata.dependency_count(), 0);
666        assert!(!metadata.has_dependencies());
667    }
668
669    #[test]
670    fn test_merged_dependencies_with_agpm_merge() {
671        let mut metadata1 = DependencyMetadata::default();
672        let mut root_deps = BTreeMap::new();
673        root_deps.insert(
674            "agents".to_string(),
675            vec![DependencySpec {
676                path: "agent1.md".to_string(),
677                name: None,
678                version: None,
679                tool: None,
680                flatten: None,
681                install: None,
682            }],
683        );
684        metadata1.dependencies = Some(root_deps);
685
686        let mut metadata2 = DependencyMetadata::default();
687        let mut nested_deps = BTreeMap::new();
688        nested_deps.insert(
689            "snippets".to_string(),
690            vec![DependencySpec {
691                path: "snippet1.md".to_string(),
692                name: None,
693                version: None,
694                tool: None,
695                flatten: None,
696                install: None,
697            }],
698        );
699        metadata2.agpm = Some(AgpmMetadata {
700            templating: Some(true),
701            dependencies: Some(nested_deps),
702        });
703
704        metadata1.merge(metadata2);
705
706        let merged = metadata1.get_dependencies().unwrap();
707        assert_eq!(merged.len(), 2); // Both resource types present
708        assert_eq!(metadata1.dependency_count(), 2);
709        assert!(metadata1.agpm.is_some());
710        assert!(metadata1.agpm.unwrap().dependencies.is_some());
711    }
712}