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}