agpm_cli/manifest/
resource_dependency.rs

1//! Resource dependency types and implementations.
2//!
3//! This module provides the core dependency specification types used in AGPM manifests:
4//! - `ResourceDependency`: Enum supporting both simple path-only and detailed specifications
5//! - `DetailedDependency`: Full dependency specification with all configuration options
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10use crate::manifest::dependency_spec::DependencySpec;
11
12/// A resource dependency specification supporting multiple formats.
13///
14/// Dependencies can be specified in two main formats to balance simplicity
15/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
16/// deserialize the correct variant based on the TOML structure.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(untagged)]
19pub enum ResourceDependency {
20    /// Simple path-only dependency, typically for local files.
21    ///
22    /// This variant represents the simplest dependency format where only
23    /// a file path is specified. It's primarily used for local dependencies
24    /// that exist in the filesystem relative to the project.
25    ///
26    /// # Format
27    ///
28    /// ```toml
29    /// dependency-name = "path/to/file.md"
30    /// ```
31    ///
32    /// # Examples
33    ///
34    /// ```toml
35    /// [agents]
36    /// # Relative paths from manifest directory
37    /// helper = "../shared/helper.md"
38    /// custom = "./local/custom.md"
39    ///
40    /// # Absolute paths (not recommended)
41    /// system = "/usr/local/share/agent.md"
42    /// ```
43    ///
44    /// # Limitations
45    ///
46    /// - Cannot specify version constraints
47    /// - Cannot reference remote Git sources
48    /// - Must be a valid filesystem path
49    /// - Path must exist at installation time
50    Simple(String),
51
52    /// Detailed dependency specification with full control.
53    ///
54    /// This variant provides complete control over dependency specification,
55    /// supporting both local and remote dependencies with version constraints,
56    /// Git references, and explicit source mapping.
57    ///
58    /// See [`DetailedDependency`] for field-level documentation.
59    ///
60    /// Note: This variant is boxed to reduce the overall size of the enum.
61    Detailed(Box<DetailedDependency>),
62}
63
64/// Detailed dependency specification with full control over source resolution.
65///
66/// This struct provides fine-grained control over dependency specification,
67/// supporting both local filesystem paths and remote Git repository resources
68/// with flexible version constraints and Git reference handling.
69///
70/// # Field Relationships
71///
72/// The fields work together with specific validation rules:
73/// - If `source` is specified: Must have either `version` or `git`
74/// - If `source` is omitted: Dependency is local, `version` and `git` are ignored
75/// - `path` is always required and cannot be empty
76///
77/// # Examples
78///
79/// ## Remote Dependencies
80///
81/// ```toml
82/// [agents]
83/// # Semantic version constraint
84/// stable = { source = "official", path = "agents/stable.md", version = "v1.0.0" }
85///
86/// # Latest version (not recommended for production)
87/// latest = { source = "community", path = "agents/utils.md", version = "latest" }
88///
89/// # Specific Git branch
90/// cutting-edge = { source = "official", path = "agents/new.md", git = "develop" }
91///
92/// # Specific commit SHA (maximum reproducibility)
93/// pinned = { source = "community", path = "agents/tool.md", git = "a1b2c3d4e5f6..." }
94///
95/// # Git tag
96/// release = { source = "official", path = "agents/release.md", git = "v2.0-release" }
97/// ```
98///
99/// ## Local Dependencies
100///
101/// ```toml
102/// [agents]
103/// # Local file (version/git fields ignored if present)
104/// local-helper = { path = "../shared/helper.md" }
105/// custom = { path = "./local/custom.md" }
106/// ```
107///
108/// # Version Resolution Priority
109///
110/// When both `version` and `git` are specified, `git` takes precedence:
111///
112/// ```toml
113/// # This will use the "develop" branch, not "v1.0.0"
114/// conflicted = { source = "repo", path = "file.md", version = "v1.0.0", git = "develop" }
115/// ```
116///
117/// # Path Format
118///
119/// Paths are interpreted differently based on context:
120/// - **Remote dependencies**: Path within the Git repository
121/// - **Local dependencies**: Filesystem path relative to manifest directory
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct DetailedDependency {
124    /// Source repository name referencing the `[sources]` section.
125    ///
126    /// When specified, this dependency will be resolved from a Git repository.
127    /// The name must exactly match a key in the manifest's `[sources]` table.
128    ///
129    /// **Omit this field** to create a local filesystem dependency.
130    ///
131    /// # Examples
132    ///
133    /// ```toml
134    /// # References this source definition:
135    /// [sources]
136    /// official = "https://github.com/org/repo.git"
137    ///
138    /// [agents]
139    /// remote-agent = { source = "official", path = "agents/tool.md", version = "v1.0.0" }
140    /// local-agent = { path = "../local/tool.md" }  # No source = local dependency
141    /// ```
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub source: Option<String>,
144
145    /// Path to the resource file or glob pattern for multiple resources.
146    ///
147    /// For **remote dependencies**: Path within the Git repository\
148    /// For **local dependencies**: Filesystem path relative to manifest directory\
149    /// For **pattern dependencies**: Glob pattern to match multiple resources
150    ///
151    /// This field supports both individual file paths and glob patterns:
152    /// - Individual file: `"agents/helper.md"`
153    /// - Pattern matching: `"agents/*.md"`, `"**/*.md"`, `"agents/[a-z]*.md"`
154    ///
155    /// Pattern dependencies are detected by the presence of glob characters
156    /// (`*`, `?`, `[`) in the path. When a pattern is detected, AGPM will
157    /// expand it to match all resources in the source repository.
158    ///
159    /// # Examples
160    ///
161    /// ```toml
162    /// # Remote: single file in git repo
163    /// remote = { source = "repo", path = "agents/helper.md", version = "v1.0.0" }
164    ///
165    /// # Local: filesystem path
166    /// local = { path = "../shared/helper.md" }
167    ///
168    /// # Pattern: all agents in AI folder
169    /// ai_agents = { source = "repo", path = "agents/ai/*.md", version = "v1.0.0" }
170    ///
171    /// # Pattern: all agents recursively
172    /// all_agents = { source = "repo", path = "agents/**/*.md", version = "v1.0.0" }
173    /// ```
174    pub path: String,
175
176    /// Version constraint for Git tag resolution.
177    ///
178    /// Specifies which version of the resource to use when resolving from
179    /// a Git repository. This field is ignored for local dependencies.
180    ///
181    /// **Note**: If both `version` and `git` are specified, `git` takes precedence.
182    ///
183    /// # Supported Formats
184    ///
185    /// - `"v1.0.0"` - Exact semantic version tag
186    /// - `"1.0.0"` - Exact version (v prefix optional)
187    /// - `"^1.0.0"` - Semantic version constraint (highest compatible 1.x.x)
188    /// - `"latest"` - Git tag or branch named "latest" (not special - just a name)
189    /// - `"main"` - Use main/master branch HEAD
190    ///
191    /// # Examples
192    ///
193    /// ```toml
194    /// [agents]
195    /// stable = { source = "repo", path = "agent.md", version = "v1.0.0" }
196    /// flexible = { source = "repo", path = "agent.md", version = "^1.0.0" }
197    /// latest-tag = { source = "repo", path = "agent.md", version = "latest" }  # If repo has a "latest" tag
198    /// main = { source = "repo", path = "agent.md", version = "main" }
199    /// ```
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub version: Option<String>,
202
203    /// Git branch to track.
204    ///
205    /// Specifies a Git branch to use when resolving the dependency.
206    /// Branch references are mutable and will update to the latest commit on each update.
207    /// This field is ignored for local dependencies.
208    ///
209    /// # Examples
210    ///
211    /// ```toml
212    /// [agents]
213    /// # Track the main branch
214    /// dev = { source = "repo", path = "agent.md", branch = "main" }
215    ///
216    /// # Track a feature branch
217    /// experimental = { source = "repo", path = "agent.md", branch = "feature/new-capability" }
218    /// ```
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub branch: Option<String>,
221
222    /// Git commit hash (revision).
223    ///
224    /// Specifies an exact Git commit to use when resolving the dependency.
225    /// Provides maximum reproducibility as commits are immutable.
226    /// This field is ignored for local dependencies.
227    ///
228    /// # Examples
229    ///
230    /// ```toml
231    /// [agents]
232    /// # Pin to exact commit (full hash)
233    /// pinned = { source = "repo", path = "agent.md", rev = "a1b2c3d4e5f67890abcdef1234567890abcdef12" }
234    ///
235    /// # Pin to exact commit (abbreviated)
236    /// stable = { source = "repo", path = "agent.md", rev = "abc123def" }
237    /// ```
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub rev: Option<String>,
240
241    /// Command to execute for MCP servers.
242    ///
243    /// This field is specific to MCP server dependencies and specifies
244    /// the command that will be executed to run the MCP server.
245    /// Only used for entries in the `[mcp-servers]` section.
246    ///
247    /// # Examples
248    ///
249    /// ```toml
250    /// [mcp-servers]
251    /// github = { source = "repo", path = "mcp/github.toml", version = "v1.0.0", command = "npx" }
252    /// sqlite = { path = "./local/sqlite.toml", command = "uvx" }
253    /// ```
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub command: Option<String>,
256
257    /// Arguments to pass to the MCP server command.
258    ///
259    /// This field is specific to MCP server dependencies and provides
260    /// the arguments that will be passed to the command when starting
261    /// the MCP server. Only used for entries in the `[mcp-servers]` section.
262    ///
263    /// # Examples
264    ///
265    /// ```toml
266    /// [mcp-servers]
267    /// github = {
268    ///     source = "repo",
269    ///     path = "mcp/github.toml",
270    ///     version = "v1.0.0",
271    ///     command = "npx",
272    ///     args = ["-y", "@modelcontextprotocol/server-github"]
273    /// }
274    /// sqlite = {
275    ///     path = "./local/sqlite.toml",
276    ///     command = "uvx",
277    ///     args = ["mcp-server-sqlite", "--db", "./data/local.db"]
278    /// }
279    /// ```
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub args: Option<Vec<String>>,
282    /// Custom target directory for this dependency.
283    ///
284    /// Overrides the default installation directory for this specific dependency.
285    /// The path is relative to the `.claude` directory for consistency and security.
286    /// If not specified, the dependency will be installed to the default location
287    /// based on its resource type.
288    ///
289    /// # Examples
290    ///
291    /// ```toml
292    /// [agents]
293    /// # Install to .claude/custom/tools/ instead of default .claude/agents/
294    /// special-agent = {
295    ///     source = "repo",
296    ///     path = "agent.md",
297    ///     version = "v1.0.0",
298    ///     target = "custom/tools"
299    /// }
300    ///
301    /// # Install to .claude/integrations/ai/
302    /// integration = {
303    ///     source = "repo",
304    ///     path = "integration.md",
305    ///     version = "v2.0.0",
306    ///     target = "integrations/ai"
307    /// }
308    /// ```
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub target: Option<String>,
311
312    /// Custom filename for this dependency.
313    ///
314    /// Overrides the default filename (which is based on the dependency key).
315    /// The filename should include the desired file extension. If not specified,
316    /// the dependency will be installed using the key name with an automatically
317    /// determined extension based on the resource type.
318    ///
319    /// # Examples
320    ///
321    /// ```toml
322    /// [agents]
323    /// # Install as "ai-assistant.md" instead of "my-ai.md"
324    /// my-ai = {
325    ///     source = "repo",
326    ///     path = "agent.md",
327    ///     version = "v1.0.0",
328    ///     filename = "ai-assistant.md"
329    /// }
330    ///
331    /// # Install with a different extension
332    /// doc-agent = {
333    ///     source = "repo",
334    ///     path = "documentation.md",
335    ///     version = "v2.0.0",
336    ///     filename = "docs-helper.txt"
337    /// }
338    ///
339    /// [scripts]
340    /// # Rename a script during installation
341    /// analyzer = {
342    ///     source = "repo",
343    ///     path = "scripts/data-analyzer-v3.py",
344    ///     version = "v1.0.0",
345    ///     filename = "analyze.py"
346    /// }
347    /// ```
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub filename: Option<String>,
350
351    /// Transitive dependencies on other resources.
352    ///
353    /// This field is populated from metadata extracted from the resource file itself
354    /// (YAML frontmatter in .md files or JSON fields in .json files).
355    /// Maps resource type to list of dependency specifications.
356    ///
357    /// Example:
358    /// ```toml
359    /// # This would be extracted from the file's frontmatter/JSON, not specified in agpm.toml
360    /// # { "agents": [{"path": "agents/helper.md", "version": "v1.0.0"}] }
361    /// ```
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
364
365    /// Tool type (claude-code, opencode, agpm, or custom).
366    ///
367    /// Specifies which target AI coding assistant tool this resource is for. This determines
368    /// where the resource is installed and how it's configured.
369    ///
370    /// When `None`, defaults are applied based on resource type:
371    /// - Snippets default to "agpm" (shared infrastructure)
372    /// - All other resources default to "claude-code"
373    ///
374    /// Omitted from TOML serialization when not specified.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub tool: Option<String>,
377
378    /// Control directory structure preservation during installation.
379    ///
380    /// When `true`, only the filename is used for installation (e.g., `nested/dir/file.md` → `file.md`).
381    /// When `false`, the full relative path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`).
382    ///
383    /// Default values by resource type (from tool configuration):
384    /// - `agents`: `true` (flatten by default - no nested directories)
385    /// - `commands`: `true` (flatten by default - no nested directories)
386    /// - All others: `false` (preserve directory structure)
387    ///
388    /// # Examples
389    ///
390    /// ```toml
391    /// [agents]
392    /// # Default behavior (flatten=true) - installs as "helper.md"
393    /// agent1 = { source = "repo", path = "agents/subdir/helper.md", version = "v1.0.0" }
394    ///
395    /// # Preserve structure - installs as "subdir/helper.md"
396    /// agent2 = { source = "repo", path = "agents/subdir/helper.md", version = "v1.0.0", flatten = false }
397    ///
398    /// [snippets]
399    /// # Default behavior (flatten=false) - installs as "utils/helper.md"
400    /// snippet1 = { source = "repo", path = "snippets/utils/helper.md", version = "v1.0.0" }
401    ///
402    /// # Flatten - installs as "helper.md"
403    /// snippet2 = { source = "repo", path = "snippets/utils/helper.md", version = "v1.0.0", flatten = true }
404    /// ```
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub flatten: Option<bool>,
407
408    /// Control whether the dependency should be installed to disk.
409    ///
410    /// When `false`, the dependency is resolved, fetched, and tracked in the lockfile,
411    /// but the file is not written to the project directory. Instead, its content is
412    /// made available in template context via `agpm.deps.<type>.<name>.content`.
413    ///
414    /// This is useful for snippet embedding use cases where you want to include
415    /// content inline rather than as a separate file.
416    ///
417    /// Defaults to `true` (install the file).
418    ///
419    /// # Examples
420    ///
421    /// ```toml
422    /// [snippets]
423    /// # Embed content directly without creating a file
424    /// best_practices = {
425    ///     source = "repo",
426    ///     path = "snippets/rust-best-practices.md",
427    ///     version = "v1.0.0",
428    ///     install = false
429    /// }
430    /// ```
431    ///
432    /// Then use in template:
433    /// ```markdown
434    /// {{ agpm.deps.snippets.best_practices.content }}
435    /// ```
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub install: Option<bool>,
438
439    /// Template variable overrides for this specific resource.
440    ///
441    /// Allows specializing generic resources for different use cases by overriding
442    /// template variables. These variables are merged with (and take precedence over)
443    /// the global `[project]` configuration when rendering this resource and resolving
444    /// its transitive dependencies.
445    ///
446    /// This enables creating multiple variants of the same resource without duplication.
447    /// For example, a single `backend-engineer.md` agent can be specialized for different
448    /// languages by providing different `template_vars` for each variant.
449    ///
450    /// The structure matches the template namespace hierarchy (e.g., `{ "project": { "language": "golang" } }`).
451    ///
452    /// # Examples
453    ///
454    /// ```toml
455    /// [agents]
456    /// # Generic backend engineer agent specialized for different languages
457    /// backend-engineer-golang = {
458    ///     source = "community",
459    ///     path = "agents/backend-engineer.md",
460    ///     version = "v1.0.0",
461    ///     filename = "backend-engineer-golang.md",
462    ///     template_vars = { project = { language = "golang" } }
463    /// }
464    ///
465    /// backend-engineer-python = {
466    ///     source = "community",
467    ///     path = "agents/backend-engineer.md",
468    ///     version = "v1.0.0",
469    ///     filename = "backend-engineer-python.md",
470    ///     template_vars = { project = { language = "python", framework = "fastapi" } }
471    /// }
472    /// ```
473    ///
474    /// The agent at `agents/backend-engineer.md` can use templates like:
475    /// ```markdown
476    /// # Backend Engineer for {{ agpm.project.language }}
477    ///
478    /// ---
479    /// dependencies:
480    ///   snippets:
481    ///     - path: ../best-practices/{{ agpm.project.language }}-best-practices.md
482    /// ---
483    /// ```
484    ///
485    /// Each variant will resolve its transitive dependencies using its specific `template_vars`,
486    /// so the golang variant resolves `golang-best-practices.md` while python resolves
487    /// `python-best-practices.md`.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub template_vars: Option<serde_json::Value>,
490}
491
492impl ResourceDependency {
493    /// Get the source repository name if this is a remote dependency.
494    ///
495    /// Returns the source name for remote dependencies (those that reference
496    /// a Git repository), or `None` for local dependencies (those that reference
497    /// local filesystem paths).
498    ///
499    /// # Examples
500    ///
501    /// ```rust,no_run
502    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
503    ///
504    /// // Local dependency - no source
505    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
506    /// assert!(local.get_source().is_none());
507    ///
508    /// // Remote dependency - has source
509    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
510    ///     source: Some("official".to_string()),
511    ///     path: "agents/tool.md".to_string(),
512    ///     version: Some("v1.0.0".to_string()),
513    ///     branch: None,
514    ///     rev: None,
515    ///     command: None,
516    ///     args: None,
517    ///     target: None,
518    ///     filename: None,
519    ///     dependencies: None,
520    ///     tool: Some("claude-code".to_string()),
521    ///     flatten: None,
522    ///     install: None,
523    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
524    /// }));
525    /// assert_eq!(remote.get_source(), Some("official"));
526    /// assert_eq!(remote.get_source(), Some("official"));
527    /// ```
528    ///
529    /// # Use Cases
530    ///
531    /// This method is commonly used to:
532    /// - Determine if dependency resolution should use Git vs filesystem
533    /// - Validate that referenced sources exist in the manifest
534    /// - Filter dependencies by type (local vs remote)
535    /// - Generate dependency graphs and reports
536    #[must_use]
537    pub fn get_source(&self) -> Option<&str> {
538        match self {
539            Self::Simple(_) => None,
540            Self::Detailed(d) => d.source.as_deref(),
541        }
542    }
543
544    /// Get the custom target directory for this dependency.
545    ///
546    /// Returns the custom target directory if specified, or `None` if the
547    /// dependency should use the default installation location for its resource type.
548    ///
549    /// # Examples
550    ///
551    /// ```rust,no_run
552    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
553    ///
554    /// // Dependency with custom target
555    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
556    ///     source: Some("official".to_string()),
557    ///     path: "agents/tool.md".to_string(),
558    ///     version: Some("v1.0.0".to_string()),
559    ///     target: Some("custom/tools".to_string()),
560    ///     branch: None,
561    ///     rev: None,
562    ///     command: None,
563    ///     args: None,
564    ///     filename: None,
565    ///     dependencies: None,
566    ///     tool: Some("claude-code".to_string()),
567    ///     flatten: None,
568    ///     install: None,
569    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
570    /// }));
571    /// assert_eq!(custom.get_target(), Some("custom/tools"));
572    ///
573    /// // Dependency without custom target
574    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
575    /// assert!(default.get_target().is_none());
576    /// ```
577    #[must_use]
578    pub fn get_target(&self) -> Option<&str> {
579        match self {
580            Self::Simple(_) => None,
581            Self::Detailed(d) => d.target.as_deref(),
582        }
583    }
584
585    /// Get the tool for this dependency.
586    ///
587    /// Returns the tool string if specified, or None if not specified.
588    /// When None is returned, the caller should apply resource-type-specific defaults.
589    ///
590    /// # Returns
591    ///
592    /// - `Some(tool)` if tool is explicitly specified
593    /// - `None` if no tool is configured (use resource-type default)
594    #[must_use]
595    pub fn get_tool(&self) -> Option<&str> {
596        match self {
597            Self::Detailed(d) => d.tool.as_deref(),
598            Self::Simple(_) => None,
599        }
600    }
601
602    /// Set the tool for this dependency.
603    ///
604    /// Only works for `Detailed` dependencies. Does nothing for `Simple` dependencies.
605    pub fn set_tool(&mut self, tool: Option<String>) {
606        if let Self::Detailed(d) = self {
607            d.tool = tool;
608        }
609    }
610
611    /// Get the custom filename for this dependency.
612    ///
613    /// Returns the custom filename if specified, or `None` if the
614    /// dependency should use the default filename based on the dependency key.
615    ///
616    /// # Examples
617    ///
618    /// ```rust,no_run
619    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
620    ///
621    /// // Dependency with custom filename
622    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
623    ///     source: Some("official".to_string()),
624    ///     path: "agents/tool.md".to_string(),
625    ///     version: Some("v1.0.0".to_string()),
626    ///     filename: Some("ai-assistant.md".to_string()),
627    ///     branch: None,
628    ///     rev: None,
629    ///     command: None,
630    ///     args: None,
631    ///     target: None,
632    ///     dependencies: None,
633    ///     tool: Some("claude-code".to_string()),
634    ///     install: None,
635    ///     flatten: None,
636    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
637    /// }));
638    /// assert_eq!(custom.get_filename(), Some("ai-assistant.md"));
639    ///
640    /// // Dependency without custom filename
641    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
642    /// assert!(default.get_filename().is_none());
643    /// ```
644    #[must_use]
645    pub fn get_filename(&self) -> Option<&str> {
646        match self {
647            Self::Simple(_) => None,
648            Self::Detailed(d) => d.filename.as_deref(),
649        }
650    }
651
652    /// Get the flatten flag for this dependency.
653    ///
654    /// Returns the flatten setting if explicitly specified, or `None` if the
655    /// dependency should use the default flatten behavior based on tool configuration.
656    ///
657    /// When `flatten = true`: Only the filename is used (e.g., `nested/dir/file.md` → `file.md`)
658    /// When `flatten = false`: Full path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`)
659    ///
660    /// # Default Behavior (from tool configuration)
661    ///
662    /// - **Agents**: Default to `true` (flatten)
663    /// - **Commands**: Default to `true` (flatten)
664    /// - **All others**: Default to `false` (preserve structure)
665    #[must_use]
666    pub fn get_flatten(&self) -> Option<bool> {
667        match self {
668            Self::Simple(_) => None,
669            Self::Detailed(d) => d.flatten,
670        }
671    }
672
673    /// Get the install flag for this dependency.
674    ///
675    /// Returns the install setting if explicitly specified, or `None` to use the
676    /// default behavior (install = true).
677    ///
678    /// When `install = false`: Dependency is resolved and content made available in
679    /// template context, but file is not written to disk.
680    ///
681    /// When `install = true` (or `None`): Dependency is installed as a file.
682    ///
683    /// # Returns
684    ///
685    /// - `Some(false)` - Do not install the file, only make content available
686    /// - `Some(true)` - Install the file normally
687    /// - `None` - Use default behavior (install = true)
688    #[must_use]
689    pub fn get_install(&self) -> Option<bool> {
690        match self {
691            Self::Simple(_) => None,
692            Self::Detailed(d) => d.install,
693        }
694    }
695
696    /// Get the template variable overrides for this resource.
697    ///
698    /// Returns the resource-specific template variables that override the global
699    /// `[project]` configuration. These variables are used when:
700    /// - Rendering the resource file itself
701    /// - Resolving the resource's transitive dependencies
702    ///
703    /// This allows creating specialized variants of generic resources without duplication.
704    ///
705    /// # Examples
706    ///
707    /// ```rust,no_run
708    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
709    /// use serde_json::json;
710    ///
711    /// // Resource with template variable overrides
712    /// let resource = ResourceDependency::Detailed(Box::new(DetailedDependency {
713    ///     source: Some("community".to_string()),
714    ///     path: "agents/backend-engineer.md".to_string(),
715    ///     version: Some("v1.0.0".to_string()),
716    ///     branch: None,
717    ///     rev: None,
718    ///     command: None,
719    ///     args: None,
720    ///     target: None,
721    ///     filename: Some("backend-engineer-golang.md".to_string()),
722    ///     dependencies: None,
723    ///     tool: Some("claude-code".to_string()),
724    ///     flatten: None,
725    ///     install: None,
726    ///     template_vars: Some(json!({ "project": { "language": "golang" } })),
727    /// }));
728    ///
729    /// assert!(resource.get_template_vars().is_some());
730    /// ```
731    pub fn get_template_vars(&self) -> Option<&serde_json::Value> {
732        match self {
733            Self::Simple(_) => None,
734            Self::Detailed(d) => d.template_vars.as_ref(),
735        }
736    }
737
738    /// Get the path to the resource file.
739    ///
740    /// Returns the path component of the dependency, which is interpreted
741    /// differently based on whether this is a local or remote dependency:
742    ///
743    /// - **Local dependencies**: Filesystem path relative to the manifest directory
744    /// - **Remote dependencies**: Path within the Git repository
745    ///
746    /// # Examples
747    ///
748    /// ```rust,no_run
749    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
750    ///
751    /// // Local dependency - filesystem path
752    /// let local = ResourceDependency::Simple("../shared/helper.md".to_string());
753    /// assert_eq!(local.get_path(), "../shared/helper.md");
754    ///
755    /// // Remote dependency - repository path
756    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
757    ///     source: Some("official".to_string()),
758    ///     path: "agents/code-reviewer.md".to_string(),
759    ///     version: Some("v1.0.0".to_string()),
760    ///     branch: None,
761    ///     rev: None,
762    ///     command: None,
763    ///     args: None,
764    ///     target: None,
765    ///     filename: None,
766    ///     dependencies: None,
767    ///     tool: Some("claude-code".to_string()),
768    ///     flatten: None,
769    ///     install: None,
770    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
771    /// }));
772    /// assert_eq!(remote.get_path(), "agents/code-reviewer.md");
773    /// ```
774    ///
775    /// # Path Resolution
776    ///
777    /// The returned path should be processed appropriately based on the dependency type:
778    /// - Local paths may need resolution against the manifest directory
779    /// - Remote paths are used directly within the cloned repository
780    /// - All paths should use forward slashes (/) for cross-platform compatibility
781    #[must_use]
782    pub fn get_path(&self) -> &str {
783        match self {
784            Self::Simple(path) => path,
785            Self::Detailed(d) => &d.path,
786        }
787    }
788
789    /// Check if this is a pattern-based dependency.
790    ///
791    /// Returns `true` if this dependency uses a glob pattern to match
792    /// multiple resources, `false` if it specifies a single resource path.
793    ///
794    /// Patterns are detected by the presence of glob characters (`*`, `?`, `[`)
795    /// in the path field.
796    #[must_use]
797    pub fn is_pattern(&self) -> bool {
798        let path = self.get_path();
799        path.contains('*') || path.contains('?') || path.contains('[')
800    }
801
802    /// Get the version constraint for dependency resolution.
803    ///
804    /// Returns the version constraint that should be used when resolving this
805    /// dependency from a Git repository. For local dependencies, always returns `None`.
806    ///
807    /// # Priority Rules
808    ///
809    /// If both `version` and `git` fields are present in a detailed dependency,
810    /// the `git` field takes precedence:
811    ///
812    /// ```rust,no_run
813    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
814    ///
815    /// let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
816    ///     source: Some("repo".to_string()),
817    ///     path: "file.md".to_string(),
818    ///     version: Some("v1.0.0".to_string()),  // This is ignored
819    ///     branch: Some("develop".to_string()),   // This takes precedence over version
820    ///     rev: None,
821    ///     command: None,
822    ///     args: None,
823    ///     target: None,
824    ///     filename: None,
825    ///     dependencies: None,
826    ///     tool: Some("claude-code".to_string()),
827    ///     flatten: None,
828    ///     install: None,
829    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
830    /// }));
831    ///
832    /// assert_eq!(dep.get_version(), Some("develop"));
833    /// ```
834    ///
835    /// # Examples
836    ///
837    /// ```rust,no_run
838    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
839    ///
840    /// // Local dependency - no version
841    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
842    /// assert!(local.get_version().is_none());
843    ///
844    /// // Remote dependency with version
845    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
846    ///     source: Some("repo".to_string()),
847    ///     path: "file.md".to_string(),
848    ///     version: Some("v1.0.0".to_string()),
849    ///     branch: None,
850    ///     rev: None,
851    ///     command: None,
852    ///     args: None,
853    ///     target: None,
854    ///     filename: None,
855    ///     dependencies: None,
856    ///     tool: Some("claude-code".to_string()),
857    ///     flatten: None,
858    ///     install: None,
859    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
860    /// }));
861    /// assert_eq!(versioned.get_version(), Some("v1.0.0"));
862    ///
863    /// // Remote dependency with branch reference
864    /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
865    ///     source: Some("repo".to_string()),
866    ///     path: "file.md".to_string(),
867    ///     version: None,
868    ///     branch: Some("main".to_string()),
869    ///     rev: None,
870    ///     command: None,
871    ///     args: None,
872    ///     target: None,
873    ///     filename: None,
874    ///     dependencies: None,
875    ///     tool: Some("claude-code".to_string()),
876    ///     flatten: None,
877    ///     install: None,
878    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
879    /// }));
880    /// assert_eq!(branch_ref.get_version(), Some("main"));
881    /// ```
882    ///
883    /// # Version Formats
884    ///
885    /// Supported version constraint formats include:
886    /// - Semantic versions: `"v1.0.0"`, `"1.2.3"`
887    /// - Semantic version ranges: `"^1.0.0"`, `"~2.1.0"`
888    /// - Branch names: `"main"`, `"develop"`, `"latest"`, `"feature/new"`
889    /// - Git tags: `"release-2023"`, `"stable"`
890    /// - Commit SHAs: `"a1b2c3d4e5f6..."`
891    #[must_use]
892    pub fn get_version(&self) -> Option<&str> {
893        match self {
894            Self::Simple(_) => None,
895            Self::Detailed(d) => {
896                // Precedence: rev > branch > version
897                d.rev.as_deref().or(d.branch.as_deref()).or(d.version.as_deref())
898            }
899        }
900    }
901
902    /// Check if this is a local filesystem dependency.
903    ///
904    /// Returns `true` if this dependency refers to a local file (no Git source),
905    /// or `false` if it's a remote dependency that will be resolved from a
906    /// Git repository.
907    ///
908    /// This is a convenience method equivalent to `self.get_source().is_none()`.
909    ///
910    /// # Examples
911    ///
912    /// ```rust,no_run
913    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
914    ///
915    /// // Local dependency
916    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
917    /// assert!(local.is_local());
918    ///
919    /// // Remote dependency
920    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
921    ///     source: Some("official".to_string()),
922    ///     path: "agents/tool.md".to_string(),
923    ///     version: Some("v1.0.0".to_string()),
924    ///     branch: None,
925    ///     rev: None,
926    ///     command: None,
927    ///     args: None,
928    ///     target: None,
929    ///     filename: None,
930    ///     dependencies: None,
931    ///     tool: Some("claude-code".to_string()),
932    ///     flatten: None,
933    ///     install: None,
934    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
935    /// }));
936    /// assert!(!remote.is_local());
937    ///
938    /// // Local detailed dependency (no source specified)
939    /// let local_detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
940    ///     source: None,
941    ///     path: "../shared/tool.md".to_string(),
942    ///     version: None,
943    ///     branch: None,
944    ///     rev: None,
945    ///     command: None,
946    ///     args: None,
947    ///     target: None,
948    ///     filename: None,
949    ///     dependencies: None,
950    ///     tool: Some("claude-code".to_string()),
951    ///     flatten: None,
952    ///     install: None,
953    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
954    /// }));
955    /// assert!(local_detailed.is_local());
956    /// ```
957    ///
958    /// # Use Cases
959    ///
960    /// This method is useful for:
961    /// - Choosing between filesystem and Git resolution strategies
962    /// - Validation logic (local deps can't have versions)
963    /// - Installation planning (local deps don't need caching)
964    /// - Progress reporting (different steps for local vs remote)
965    #[must_use]
966    pub fn is_local(&self) -> bool {
967        self.get_source().is_none()
968    }
969}