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.resolution_mode() {
894            crate::resolver::types::ResolutionMode::Version => {
895                // Version path: return version constraint
896                self.get_version_constraint()
897            }
898            crate::resolver::types::ResolutionMode::GitRef => {
899                // Git path: return git reference (rev takes precedence)
900                self.get_git_ref()
901            }
902        }
903    }
904
905    /// Check if this is a local filesystem dependency.
906    ///
907    /// Returns `true` if this dependency refers to a local file (no Git source),
908    /// or `false` if it's a remote dependency that will be resolved from a
909    /// Git repository.
910    ///
911    /// This is a convenience method equivalent to `self.get_source().is_none()`.
912    ///
913    /// # Examples
914    ///
915    /// ```rust,no_run
916    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
917    ///
918    /// // Local dependency
919    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
920    /// assert!(local.is_local());
921    ///
922    /// // Remote dependency
923    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
924    ///     source: Some("official".to_string()),
925    ///     path: "agents/tool.md".to_string(),
926    ///     version: Some("v1.0.0".to_string()),
927    ///     branch: None,
928    ///     rev: None,
929    ///     command: None,
930    ///     args: None,
931    ///     target: None,
932    ///     filename: None,
933    ///     dependencies: None,
934    ///     tool: Some("claude-code".to_string()),
935    ///     flatten: None,
936    ///     install: None,
937    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
938    /// }));
939    /// assert!(!remote.is_local());
940    ///
941    /// // Local detailed dependency (no source specified)
942    /// let local_detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
943    ///     source: None,
944    ///     path: "../shared/tool.md".to_string(),
945    ///     version: None,
946    ///     branch: None,
947    ///     rev: None,
948    ///     command: None,
949    ///     args: None,
950    ///     target: None,
951    ///     filename: None,
952    ///     dependencies: None,
953    ///     tool: Some("claude-code".to_string()),
954    ///     flatten: None,
955    ///     install: None,
956    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
957    /// }));
958    /// assert!(local_detailed.is_local());
959    /// ```
960    ///
961    /// # Use Cases
962    ///
963    /// This method is useful for:
964    /// - Choosing between filesystem and Git resolution strategies
965    /// - Validation logic (local deps can't have versions)
966    /// - Installation planning (local deps don't need caching)
967    /// - Progress reporting (different steps for local vs remote)
968    #[must_use]
969    pub fn is_local(&self) -> bool {
970        self.get_source().is_none()
971    }
972
973    /// Get the resolution mode for this dependency.
974    ///
975    /// Returns whether this dependency should be resolved using version constraints
976    /// (semantic versioning with tags) or direct git references (branch/rev).
977    ///
978    /// # Returns
979    ///
980    /// - `ResolutionMode::Version` if this dependency uses `version` field or has no git reference
981    /// - `ResolutionMode::GitRef` if this dependency uses `branch` or `rev` fields
982    ///
983    /// # Examples
984    ///
985    /// ```rust,no_run
986    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
987    /// use agpm_cli::resolver::types::ResolutionMode;
988    ///
989    /// // Version path dependency
990    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
991    ///     source: Some("official".to_string()),
992    ///     path: "agents/tool.md".to_string(),
993    ///     version: Some("^1.0.0".to_string()),
994    ///     branch: None,
995    ///     rev: None,
996    ///     command: None,
997    ///     args: None,
998    ///     target: None,
999    ///     filename: None,
1000    ///     dependencies: None,
1001    ///     tool: None,
1002    ///     flatten: None,
1003    ///     install: None,
1004    ///     template_vars: None,
1005    /// }));
1006    /// assert_eq!(versioned.resolution_mode(), ResolutionMode::Version);
1007    ///
1008    /// // Git path dependency
1009    /// let git_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
1010    ///     source: Some("official".to_string()),
1011    ///     path: "agents/tool.md".to_string(),
1012    ///     version: None,
1013    ///     branch: Some("main".to_string()),
1014    ///     rev: None,
1015    ///     command: None,
1016    ///     args: None,
1017    ///     target: None,
1018    ///     filename: None,
1019    ///     dependencies: None,
1020    ///     tool: None,
1021    ///     flatten: None,
1022    ///     install: None,
1023    ///     template_vars: None,
1024    /// }));
1025    /// assert_eq!(git_ref.resolution_mode(), ResolutionMode::GitRef);
1026    /// ```
1027    #[must_use]
1028    pub fn resolution_mode(&self) -> crate::resolver::types::ResolutionMode {
1029        crate::resolver::types::ResolutionMode::from_dependency(self)
1030    }
1031
1032    /// Get version constraint (Version path only).
1033    ///
1034    /// Returns the version constraint only for Version path dependencies.
1035    /// For Git path dependencies, returns None.
1036    ///
1037    /// # Returns
1038    ///
1039    /// - `Some(version)` if this is a Version path dependency with a version constraint
1040    /// - `None` for Git path dependencies or dependencies without version
1041    ///
1042    /// # Examples
1043    ///
1044    /// ```rust,no_run
1045    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
1046    ///
1047    /// // Version constraint
1048    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
1049    ///     source: Some("official".to_string()),
1050    ///     path: "agents/tool.md".to_string(),
1051    ///     version: Some("^1.0.0".to_string()),
1052    ///     branch: None,
1053    ///     rev: None,
1054    ///     command: None,
1055    ///     args: None,
1056    ///     target: None,
1057    ///     filename: None,
1058    ///     dependencies: None,
1059    ///     tool: None,
1060    ///     flatten: None,
1061    ///     install: None,
1062    ///     template_vars: None,
1063    /// }));
1064    /// assert_eq!(versioned.get_version_constraint(), Some("^1.0.0"));
1065    ///
1066    /// // Git reference - no version constraint
1067    /// let git_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
1068    ///     source: Some("official".to_string()),
1069    ///     path: "agents/tool.md".to_string(),
1070    ///     version: None,
1071    ///     branch: Some("main".to_string()),
1072    ///     rev: None,
1073    ///     command: None,
1074    ///     args: None,
1075    ///     target: None,
1076    ///     filename: None,
1077    ///     dependencies: None,
1078    ///     tool: None,
1079    ///     flatten: None,
1080    ///     install: None,
1081    ///     template_vars: None,
1082    /// }));
1083    /// assert_eq!(git_ref.get_version_constraint(), None);
1084    /// ```
1085    #[must_use]
1086    pub fn get_version_constraint(&self) -> Option<&str> {
1087        match (self, self.resolution_mode()) {
1088            (Self::Detailed(d), crate::resolver::types::ResolutionMode::Version) => {
1089                d.version.as_deref()
1090            }
1091            _ => None,
1092        }
1093    }
1094
1095    /// Get git reference (Git path only).
1096    ///
1097    /// Returns the git reference (branch or rev) only for Git path dependencies.
1098    /// For Version path dependencies, returns None.
1099    ///
1100    /// Rev takes precedence over branch if both are specified.
1101    ///
1102    /// # Returns
1103    ///
1104    /// - `Some(git_ref)` if this is a Git path dependency with branch or rev
1105    /// - `None` for Version path dependencies
1106    ///
1107    /// # Examples
1108    ///
1109    /// ```rust,no_run
1110    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
1111    ///
1112    /// // Branch reference
1113    /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
1114    ///     source: Some("official".to_string()),
1115    ///     path: "agents/tool.md".to_string(),
1116    ///     version: None,
1117    ///     branch: Some("main".to_string()),
1118    ///     rev: None,
1119    ///     command: None,
1120    ///     args: None,
1121    ///     target: None,
1122    ///     filename: None,
1123    ///     dependencies: None,
1124    ///     tool: None,
1125    ///     flatten: None,
1126    ///     install: None,
1127    ///     template_vars: None,
1128    /// }));
1129    /// assert_eq!(branch_ref.get_git_ref(), Some("main"));
1130    ///
1131    /// // Version constraint - no git reference
1132    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
1133    ///     source: Some("official".to_string()),
1134    ///     path: "agents/tool.md".to_string(),
1135    ///     version: Some("^1.0.0".to_string()),
1136    ///     branch: None,
1137    ///     rev: None,
1138    ///     command: None,
1139    ///     args: None,
1140    ///     target: None,
1141    ///     filename: None,
1142    ///     dependencies: None,
1143    ///     tool: None,
1144    ///     flatten: None,
1145    ///     install: None,
1146    ///     template_vars: None,
1147    /// }));
1148    /// assert_eq!(versioned.get_git_ref(), None);
1149    /// ```
1150    #[must_use]
1151    pub fn get_git_ref(&self) -> Option<&str> {
1152        match self {
1153            Self::Detailed(d) => {
1154                // Precedence: rev > branch
1155                d.rev.as_deref().or(d.branch.as_deref())
1156            }
1157            _ => None,
1158        }
1159    }
1160
1161    /// Check if this dependency is mutable (can change without manifest changes).
1162    ///
1163    /// A dependency is considered mutable if:
1164    /// - It's a local dependency (no source, just a filesystem path)
1165    /// - It uses a branch reference (branches can be updated)
1166    /// - It uses a version that looks like a branch name (not semver)
1167    ///
1168    /// A dependency is considered immutable if:
1169    /// - It uses a `rev` field (explicitly pinned to a SHA)
1170    /// - It uses a semver version string (resolved to a specific tag)
1171    ///
1172    /// Immutable dependencies are safe for fast-path optimization because their
1173    /// content is locked by SHA commit hash after initial resolution.
1174    ///
1175    /// # Returns
1176    ///
1177    /// - `true` if the dependency can change without manifest changes
1178    /// - `false` if the dependency is locked to a specific commit
1179    ///
1180    /// # Examples
1181    ///
1182    /// ```rust,no_run
1183    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
1184    ///
1185    /// // Local dependency - always mutable
1186    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
1187    /// assert!(local.is_mutable());
1188    ///
1189    /// // Branch reference - mutable
1190    /// let branch = ResourceDependency::Detailed(Box::new(DetailedDependency {
1191    ///     source: Some("repo".to_string()),
1192    ///     path: "file.md".to_string(),
1193    ///     version: None,
1194    ///     branch: Some("main".to_string()),
1195    ///     rev: None,
1196    ///     command: None,
1197    ///     args: None,
1198    ///     target: None,
1199    ///     filename: None,
1200    ///     dependencies: None,
1201    ///     tool: None,
1202    ///     flatten: None,
1203    ///     install: None,
1204    ///     template_vars: None,
1205    /// }));
1206    /// assert!(branch.is_mutable());
1207    ///
1208    /// // Semver version - immutable (tags are stable)
1209    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
1210    ///     source: Some("repo".to_string()),
1211    ///     path: "file.md".to_string(),
1212    ///     version: Some("^1.0.0".to_string()),
1213    ///     branch: None,
1214    ///     rev: None,
1215    ///     command: None,
1216    ///     args: None,
1217    ///     target: None,
1218    ///     filename: None,
1219    ///     dependencies: None,
1220    ///     tool: None,
1221    ///     flatten: None,
1222    ///     install: None,
1223    ///     template_vars: None,
1224    /// }));
1225    /// assert!(!versioned.is_mutable());
1226    /// ```
1227    #[must_use]
1228    pub fn is_mutable(&self) -> bool {
1229        // Local dependencies are always mutable (filesystem can change)
1230        if self.is_local() {
1231            return true;
1232        }
1233
1234        // Branch references are mutable (branches can be updated)
1235        match self {
1236            Self::Detailed(d) => {
1237                // If branch is explicitly set, it's mutable
1238                if d.branch.is_some() {
1239                    return true;
1240                }
1241
1242                // If rev (SHA) is explicitly set, it's immutable
1243                if d.rev.is_some() {
1244                    return false;
1245                }
1246
1247                // Check the version field for branch-like patterns
1248                if let Some(version) = &d.version {
1249                    return Self::is_branch_like_version(version);
1250                }
1251
1252                // No version, branch, or rev means it's undefined (treat as mutable to be safe)
1253                true
1254            }
1255            // Simple string is always local, already handled above
1256            Self::Simple(_) => true,
1257        }
1258    }
1259
1260    /// Check if a version string looks like a branch name rather than semver.
1261    ///
1262    /// Semver-like versions (immutable):
1263    /// - `v1.0.0`, `1.0.0`, `^1.0.0`, `~1.0.0`, `>=1.0.0`
1264    /// - Prefixed versions like `prefix-v1.0.0`, `prefix-^v1.0.0`
1265    /// - Full 40-character SHA hashes
1266    ///
1267    /// Branch-like versions (mutable):
1268    /// - `main`, `master`, `develop`
1269    /// - Any string without digits or semver operators
1270    ///
1271    /// Note: This is `pub(crate)` rather than private to enable direct testing
1272    /// in `resource_dependency_tests.rs`. It's only used internally by `is_mutable()`.
1273    #[cfg_attr(test, allow(dead_code))]
1274    pub(crate) fn is_branch_like_version(version: &str) -> bool {
1275        let version = version.trim();
1276
1277        // Empty is undefined, treat as mutable
1278        if version.is_empty() {
1279            return true;
1280        }
1281
1282        // Full SHA (40 hex chars) is immutable - it points to a specific commit
1283        if version.len() == 40 && version.chars().all(|c| c.is_ascii_hexdigit()) {
1284            return false;
1285        }
1286
1287        // Check for semver operators at start or after a prefix
1288        // Patterns: ^, ~, >=, <=, >, <, = or version starting with digit/v
1289        let semver_start = |s: &str| {
1290            s.starts_with('^')
1291                || s.starts_with('~')
1292                || s.starts_with('>')
1293                || s.starts_with('<')
1294                || s.starts_with('=')
1295                || s.starts_with('v')
1296                || s.starts_with('V')
1297                || s.chars().next().is_some_and(|c| c.is_ascii_digit())
1298        };
1299
1300        // Handle prefixed versions like "claude-code-agent-v1.0.0" or "prefix-^v1.0.0"
1301        // Find the last hyphen and check if what follows looks like semver
1302        if let Some(last_hyphen_pos) = version.rfind('-') {
1303            let after_hyphen = &version[last_hyphen_pos + 1..];
1304            if semver_start(after_hyphen) {
1305                return false; // It's a prefixed semver, not mutable
1306            }
1307        }
1308
1309        // If the whole version looks like semver, it's not mutable
1310        if semver_start(version) {
1311            return false;
1312        }
1313
1314        // Everything else (main, develop, feature/xyz) is branch-like and mutable
1315        true
1316    }
1317}