agpm_cli/manifest/
mod.rs

1//! Manifest file parsing and validation for AGPM projects.
2//!
3//! This module handles the `agpm.toml` manifest file that defines project
4//! dependencies and configuration. The manifest uses TOML format and follows
5//! a structure similar to Cargo.toml, providing a lockfile-based dependency
6//! management system for Claude Code resources.
7//!
8//! # Overview
9//!
10//! The manifest system enables:
11//! - Declarative dependency management through `agpm.toml`
12//! - Reproducible installations via lockfile generation
13//! - Support for multiple Git-based source repositories
14//! - Local and remote dependency resolution
15//! - Version constraint specification and validation
16//! - Transitive dependency resolution from resource metadata
17//! - Cross-platform path handling and installation
18//! - MCP (Model Context Protocol) server configuration management
19//! - Atomic file operations for reliability
20//!
21//! # Complete TOML Format Specification
22//!
23//! ## Basic Structure
24//!
25//! A `agpm.toml` manifest file consists of four main sections:
26//!
27//! ```toml
28//! # Named source repositories (optional)
29//! [sources]
30//! # Git repository URLs mapped to convenient names
31//! official = "https://github.com/example-org/agpm-official.git"
32//! community = "https://github.com/community/agpm-resources.git"
33//! private = "git@github.com:company/private-resources.git"
34//!
35//! # Installation target directories (optional)
36//! [target]
37//! # Where agents should be installed (default: ".claude/agents")
38//! agents = ".claude/agents"
39//! # Where snippets should be installed (default: ".claude/agpm/snippets")
40//! snippets = ".claude/agpm/snippets"
41//! # Where commands should be installed (default: ".claude/commands")
42//! commands = ".claude/commands"
43//!
44//! # Agent dependencies (optional)
45//! [agents]
46//! # Various dependency specification formats
47//! simple-agent = "../local/agents/helper.md"                    # Local path
48//! remote-agent = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
49//! latest-agent = { source = "community", path = "agents/utils.md", version = "latest" }
50//! branch-agent = { source = "private", path = "agents/internal.md", git = "develop" }
51//! commit-agent = { source = "official", path = "agents/stable.md", git = "abc123..." }
52//! # Custom target installation directory (relative to .claude)
53//! custom-agent = { source = "official", path = "agents/special.md", version = "v1.0.0", target = "integrations/ai" }
54//!
55//! # Snippet dependencies (optional)
56//! [snippets]
57//! # Same formats as agents
58//! local-snippet = "../shared/snippets/common.md"
59//! remote-snippet = { source = "community", path = "snippets/utils.md", version = "v2.1.0" }
60//! # Custom target for special snippets
61//! integration-snippet = { source = "community", path = "snippets/api.md", version = "v1.0.0", target = "tools/snippets" }
62//!
63//! # Command dependencies (optional)
64//! [commands]
65//! # Same formats as agents and snippets
66//! local-command = "../shared/commands/helper.md"
67//! remote-command = { source = "community", path = "commands/build.md", version = "v1.0.0" }
68//! ```
69//!
70//! ## Sources Section
71//!
72//! The `[sources]` section maps convenient names to Git repository URLs:
73//!
74//! ```toml
75//! [sources]
76//! # HTTPS URLs (recommended for public repositories)
77//! official = "https://github.com/owner/agpm-resources.git"
78//! community = "https://gitlab.com/group/agpm-community.git"
79//!
80//! # SSH URLs (for private repositories with key authentication)
81//! private = "git@github.com:company/private-resources.git"
82//! internal = "git@gitlab.company.com:team/internal-resources.git"
83//!
84//! # Local Git repository URLs
85//! local-repo = "file:///absolute/path/to/local/repo"
86//!
87//! # Environment variable expansion (useful for CI/CD)
88//! dynamic = "https://github.com/${GITHUB_ORG}/resources.git"
89//! home-repo = "file://${HOME}/git/resources"
90//! ```
91//!
92//! ## Target Section
93//!
94//! The `[target]` section configures where resources are installed:
95//!
96//! ```toml
97//! [target]
98//! # Default values shown - these can be customized
99//! agents = ".claude/agents"      # Where agent .md files are copied
100//! snippets = ".claude/agpm/snippets"  # Where snippet .md files are copied
101//! commands = ".claude/commands"  # Where command .md files are copied
102//!
103//! # Alternative configurations
104//! agents = "resources/agents"
105//! snippets = "resources/snippets"
106//! commands = "resources/commands"
107//!
108//! # Absolute paths are supported
109//! agents = "/opt/claude/agents"
110//! snippets = "/opt/claude/snippets"
111//! commands = "/opt/claude/commands"
112//! ```
113//!
114//! ## Dependency Sections
115//!
116//! Both `[agents]` and `[snippets]` sections support multiple dependency formats:
117//!
118//! ### 1. Local Path Dependencies
119//!
120//! For resources in your local filesystem:
121//!
122//! ```toml
123//! [agents]
124//! # Relative paths from manifest directory
125//! local-helper = "../shared/agents/helper.md"
126//! nearby-agent = "./local-agents/custom.md"
127//!
128//! # Absolute paths (not recommended for portability)
129//! system-agent = "/usr/local/share/claude/agents/system.md"
130//! ```
131//!
132//! Local dependencies:
133//! - Do not support version constraints
134//! - Are copied directly from the filesystem
135//! - Are not cached or managed through Git
136//! - Must exist at install time
137//!
138//! ### 2. Remote Source Dependencies
139//!
140//! For resources from Git repositories:
141//!
142//! ```toml
143//! [agents]
144//! # Basic remote dependency with semantic version
145//! code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
146//!
147//! # Using latest version (not recommended for production)
148//! utils = { source = "community", path = "agents/utils.md", version = "latest" }
149//!
150//! # Specific Git branch
151//! bleeding-edge = { source = "official", path = "agents/experimental.md", git = "develop" }
152//!
153//! # Specific Git commit (maximum reproducibility)
154//! stable = { source = "official", path = "agents/stable.md", git = "a1b2c3d4e5f6..." }
155//!
156//! # Git tag (alternative to version field)
157//! tagged = { source = "community", path = "agents/tagged.md", git = "release-2.0" }
158//! ```
159//!
160//! ### 3. Custom Target Installation
161//!
162//! Dependencies can specify a custom installation directory using the `target` field:
163//!
164//! ```toml
165//! [agents]
166//! # Install to .claude/integrations/ai/ instead of .claude/agents/
167//! integration-agent = {
168//!     source = "official",
169//!     path = "agents/integration.md",
170//!     version = "v1.0.0",
171//!     target = "integrations/ai"
172//! }
173//!
174//! # Organize tools in a custom structure
175//! debug-tool = {
176//!     source = "community",
177//!     path = "agents/debugger.md",
178//!     version = "v2.0.0",
179//!     target = "development/tools"
180//! }
181//!
182//! [snippets]
183//! # Custom location for API snippets
184//! api-helper = {
185//!     source = "community",
186//!     path = "snippets/api.md",
187//!     version = "v1.0.0",
188//!     target = "api/snippets"
189//! }
190//! ```
191//!
192//! Custom targets:
193//! - Are always relative to the `.claude` directory
194//! - Leading `.claude/` or `/` are automatically stripped
195//! - Directories are created if they don't exist
196//! - Help organize resources in complex projects
197//!
198//! ### 4. Custom Filenames
199//!
200//! Dependencies can specify a custom filename using the `filename` field:
201//!
202//! ```toml
203//! [agents]
204//! # Install as "ai-assistant.md" instead of "my-ai.md"
205//! my-ai = {
206//!     source = "official",
207//!     path = "agents/complex-long-name-v2.md",
208//!     version = "v1.0.0",
209//!     filename = "ai-assistant.md"
210//! }
211//!
212//! # Change both filename and extension
213//! doc-helper = {
214//!     source = "community",
215//!     path = "agents/documentation.md",
216//!     version = "v2.0.0",
217//!     filename = "docs.txt"
218//! }
219//!
220//! # Combine custom target and filename
221//! special-tool = {
222//!     source = "official",
223//!     path = "agents/debug-analyzer-enhanced.md",
224//!     version = "v1.0.0",
225//!     target = "tools/debugging",
226//!     filename = "analyzer.markdown"
227//! }
228//!
229//! [scripts]
230//! # Rename script during installation
231//! data-processor = {
232//!     source = "community",
233//!     path = "scripts/data-processor-v3.py",
234//!     version = "v1.0.0",
235//!     filename = "process.py"
236//! }
237//! ```
238//!
239//! Custom filenames:
240//! - Include the full filename with extension
241//! - Override the default name (based on dependency key)
242//! - Work with any resource type
243//! - Can be combined with custom targets
244//!
245//! ## Version Constraint Syntax
246//!
247//! AGPM supports flexible version constraints:
248//!
249//! - `"v1.0.0"` - Exact semantic version
250//! - `"1.0.0"` - Exact version (v prefix optional)
251//! - `"latest"` - Always use the latest available version
252//! - `"main"` - Use the main/master branch HEAD
253//! - `"develop"` - Use a specific branch
254//! - `"a1b2c3d4..."` - Use a specific commit SHA
255//! - `"release-1.0"` - Use a specific Git tag
256//!
257//! ## Complete Examples
258//!
259//! ### Minimal Manifest
260//!
261//! ```toml
262//! [agents]
263//! helper = "../agents/helper.md"
264//! ```
265//!
266//! ### Production Manifest
267//!
268//! ```toml
269//! [sources]
270//! official = "https://github.com/claude-org/official-resources.git"
271//! community = "https://github.com/claude-community/resources.git"
272//! company = "git@github.com:mycompany/claude-resources.git"
273//!
274//! [target]
275//! agents = "resources/agents"
276//! snippets = "resources/snippets"
277//!
278//! [agents]
279//! # Production agents with pinned versions
280//! code-reviewer = { source = "official", path = "agents/code-reviewer.md", version = "v2.1.0" }
281//! documentation = { source = "community", path = "agents/doc-writer.md", version = "v1.5.2" }
282//! internal-helper = { source = "company", path = "agents/helper.md", version = "v1.0.0" }
283//!
284//! # Local customizations
285//! custom-agent = "./local/agents/custom.md"
286//!
287//! [snippets]
288//! # Utility snippets
289//! common-patterns = { source = "community", path = "snippets/patterns.md", version = "v1.2.0" }
290//! company-templates = { source = "company", path = "snippets/templates.md", version = "latest" }
291//! ```
292//!
293//! ## Security Considerations
294//!
295//! **CRITICAL**: Never include authentication credentials in `agpm.toml`:
296//!
297//! ```toml
298//! # ❌ NEVER DO THIS - credentials will be committed to git
299//! [sources]
300//! private = "https://token:ghp_xxxx@github.com/company/repo.git"
301//!
302//! # ✅ Instead, use global configuration in ~/.agpm/config.toml
303//! # Or use SSH keys with git@ URLs
304//! [sources]
305//! private = "git@github.com:company/repo.git"
306//! ```
307//!
308//! Authentication should be configured globally in `~/.agpm/config.toml` or
309//! through SSH keys for `git@` URLs. See [`crate::config`] for details.
310//!
311//! ## Relationship to Lockfile
312//!
313//! The manifest works together with the lockfile (`agpm.lock`):
314//!
315//! - **Manifest (`agpm.toml`)**: Declares dependencies and constraints
316//! - **Lockfile (`agpm.lock`)**: Records exact resolved versions and checksums
317//!
318//! When you run `agpm install`:
319//! 1. Reads dependencies from `agpm.toml`
320//! 2. Resolves versions within constraints  
321//! 3. Generates/updates `agpm.lock` with exact commits
322//! 4. Installs resources to target directories
323//!
324//! See [`crate::lockfile`] for lockfile format details.
325//!
326//! ## Cross-Platform Compatibility
327//!
328//! AGPM handles platform differences automatically:
329//! - Path separators (/ vs \\) are normalized
330//! - Home directory expansion (~) is supported
331//! - Environment variable expansion is available
332//! - Git commands work on Windows, macOS, and Linux
333//! - Long path support on Windows (>260 characters)
334//! - Unicode filenames and paths are fully supported
335//!
336//! ## Best Practices
337//!
338//! 1. **Use semantic versions**: Prefer `v1.0.0` over `latest`
339//! 2. **Pin production dependencies**: Use exact versions in production
340//! 3. **Organize sources logically**: Group by organization or purpose
341//! 4. **Document dependencies**: Add comments explaining why each is needed
342//! 5. **Keep manifests simple**: Avoid overly complex dependency trees
343//! 6. **Use SSH for private repos**: More secure than HTTPS tokens
344//! 7. **Test across platforms**: Verify paths work on all target systems
345//! 8. **Version control manifests**: Always commit `agpm.toml` to git
346//! 9. **Validate regularly**: Run `agpm validate` before commits
347//! 10. **Use lockfiles**: Commit `agpm.lock` for reproducible builds
348//!
349//! ## Transitive Dependencies
350//!
351//! Resources can declare their own dependencies within their files using structured
352//! metadata. This enables automatic dependency resolution without manual manifest updates.
353//!
354//! ### Supported Formats
355//!
356//! #### Markdown Files (YAML Frontmatter)
357//!
358//! ```markdown
359//! ---
360//! dependencies:
361//!   agents:
362//!     - path: agents/helper.md
363//!       version: v1.0.0
364//!     - path: agents/reviewer.md
365//!   snippets:
366//!     - path: snippets/utils.md
367//! ---
368//!
369//! # My Command Documentation
370//! ...
371//! ```
372//!
373//! #### JSON Files (Top-Level Field)
374//!
375//! ```json
376//! {
377//!   "events": ["UserPromptSubmit"],
378//!   "type": "command",
379//!   "command": ".claude/agpm/scripts/test.js",
380//!   "dependencies": {
381//!     "scripts": [
382//!       { "path": "scripts/test-runner.sh", "version": "v1.0.0" },
383//!       { "path": "scripts/validator.py" }
384//!     ],
385//!     "agents": [
386//!       { "path": "agents/code-analyzer.md", "version": "~1.2.0" }
387//!     ]
388//!   }
389//! }
390//! ```
391//!
392//! ### Key Features
393//!
394//! - **Automatic Discovery**: Dependencies extracted during resolution
395//! - **Version Inheritance**: If no version specified, parent's version is used
396//! - **Same-Source Model**: Transitive deps inherit parent's source repository
397//! - **Cycle Detection**: Circular dependency loops are detected and prevented
398//! - **Topological Ordering**: Dependencies installed in correct order
399//! - **Optional Resolution**: Can be disabled with `--no-transitive` flag
400//!
401//! ### Data Structures
402//!
403//! Transitive dependencies are represented by:
404//! - [`DependencySpec`]: Individual dependency specification (path + optional version)
405//! - [`DependencyMetadata`]: Collection of dependencies by resource type
406//! - [`DetailedDependency::dependencies`]: Field storing extracted transitive deps
407//!
408//! ### Processing Flow
409//!
410//! 1. Manifest dependencies are resolved first
411//! 2. Resource files are checked for metadata (YAML frontmatter or JSON fields)
412//! 3. Discovered dependencies are added to dependency graph
413//! 4. Graph is validated for cycles
414//! 5. Dependencies are resolved in topological order
415//! 6. All resources (direct + transitive) are installed
416//!
417//! See [`dependency_spec`] module for detailed specification formats.
418//!
419//! ## Error Handling
420//!
421//! The manifest module provides comprehensive error handling with:
422//! - **Context-rich errors**: Detailed messages with actionable suggestions
423//! - **Validation errors**: Clear explanations of manifest problems
424//! - **I/O errors**: Helpful context for file system issues
425//! - **TOML parsing errors**: Specific syntax error locations
426//! - **Security validation**: Detection of potential security issues
427//!
428//! All errors implement [`std::error::Error`] and provide both user-friendly
429//! messages and programmatic access to error details.
430//!
431//! ## Performance Characteristics
432//!
433//! - **Parsing**: O(n) where n is the manifest file size
434//! - **Validation**: O(d) where d is the number of dependencies
435//! - **Serialization**: O(n) where n is the total data size
436//! - **Memory usage**: Proportional to manifest complexity
437//! - **Thread safety**: All operations are thread-safe
438//!
439//! ## Integration with Other Modules
440//!
441//! The manifest module works closely with other AGPM modules:
442//!
443//! ### With [`crate::resolver`]
444//!
445//! ```rust,ignore
446//! use agpm_cli::manifest::Manifest;
447//! use agpm_cli::resolver::DependencyResolver;
448//!
449//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
450//! let resolver = DependencyResolver::new(&manifest);
451//! let resolved = resolver.resolve_all().await?;
452//! ```
453//!
454//! ### With [`crate::lockfile`]
455//!
456//! ```rust,ignore  
457//! use agpm_cli::manifest::Manifest;
458//! use agpm_cli::lockfile::LockFile;
459//!
460//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
461//! let lockfile = LockFile::generate_from_manifest(&manifest).await?;
462//! lockfile.save(&project_path.join("agpm.lock"))?;
463//! ```
464//!
465//! ### With [`crate::git`] for Source Management
466//!
467//! ```rust,ignore
468//! use agpm_cli::manifest::Manifest;
469//! use agpm_cli::git::GitManager;
470//!
471//! let manifest = Manifest::load(&project_path.join("agpm.toml"))?;
472//! let git = GitManager::new(&cache_dir);
473//!
474//! for (name, url) in &manifest.sources {
475//!     git.clone_or_update(name, url).await?;
476//! }
477//! ```
478
479pub mod dependency_spec;
480
481use anyhow::{Context, Result};
482use serde::{Deserialize, Serialize};
483use std::collections::HashMap;
484use std::path::{Path, PathBuf};
485
486pub use dependency_spec::{DependencyMetadata, DependencySpec};
487
488/// The main manifest file structure representing a complete `agpm.toml` file.
489///
490/// This struct encapsulates all configuration for a AGPM project, including
491/// source repositories, installation targets, and resource dependencies.
492/// It provides the foundation for declarative dependency management similar
493/// to Cargo's `Cargo.toml`.
494///
495/// # Structure
496///
497/// - **Sources**: Named Git repositories that can be referenced by dependencies
498/// - **Target**: Installation directories for different resource types
499/// - **Agents**: AI agent dependencies (`.md` files with agent definitions)
500/// - **Snippets**: Code snippet dependencies (`.md` files with reusable code)
501/// - **Commands**: Claude Code command dependencies (`.md` files with slash commands)
502///
503/// # Serialization
504///
505/// The struct uses Serde for TOML serialization/deserialization with these behaviors:
506/// - Empty collections are omitted from serialized output for cleaner files
507/// - Default values are automatically applied for missing fields
508/// - Field names match TOML section names exactly
509///
510/// # Thread Safety
511///
512/// This struct is thread-safe and can be shared across async tasks safely.
513///
514/// # Examples
515///
516/// ```rust,no_run
517/// use agpm_cli::manifest::{Manifest, ResourceDependency};
518///
519/// // Create a new empty manifest
520/// let mut manifest = Manifest::new();
521///
522/// // Add a source repository
523/// manifest.add_source(
524///     "community".to_string(),
525///     "https://github.com/claude-community/resources.git".to_string()
526/// );
527///
528/// // Add a dependency
529/// manifest.add_dependency(
530///     "helper".to_string(),
531///     ResourceDependency::Simple("../local/helper.md".to_string()),
532///     true  // is_agent = true
533/// );
534/// ```
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct Manifest {
537    /// Named source repositories mapped to their Git URLs.
538    ///
539    /// Keys are short, convenient names used in dependency specifications.
540    /// Values are Git repository URLs (HTTPS, SSH, or local file:// URLs).
541    ///
542    /// **Security Note**: Never include authentication tokens in these URLs.
543    /// Use SSH keys or configure authentication in the global config file.
544    ///
545    /// # Examples
546    ///
547    /// ```toml
548    /// [sources]
549    /// official = "https://github.com/claude-org/official.git"
550    /// private = "git@github.com:company/private.git"
551    /// local = "file:///home/user/local-repo"
552    /// ```
553    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
554    pub sources: HashMap<String, String>,
555
556    /// Installation target directory configuration.
557    ///
558    /// Specifies where different resource types should be installed relative
559    /// to the project root. Uses sensible defaults if not specified.
560    ///
561    /// See [`TargetConfig`] for details on default values and customization.
562    #[deprecated(since = "0.4.0", note = "Use tools configuration instead")]
563    #[serde(default)]
564    pub target: TargetConfig,
565
566    /// Tool type configurations for multi-tool support.
567    ///
568    /// Maps tool type names (claude-code, opencode, agpm, custom) to their
569    /// installation configurations. This replaces the old `target` field and
570    /// enables support for multiple tools and custom tool types.
571    ///
572    /// See [`ToolsConfig`] for details on configuration format.
573    #[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
574    pub tools: Option<ToolsConfig>,
575
576    /// Agent dependencies mapping names to their specifications.
577    ///
578    /// Agents are typically AI model definitions, prompts, or behavioral
579    /// specifications stored as Markdown files. Each dependency can be
580    /// either local (filesystem path) or remote (from a Git source).
581    ///
582    /// See [`ResourceDependency`] for specification format details.
583    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
584    pub agents: HashMap<String, ResourceDependency>,
585
586    /// Snippet dependencies mapping names to their specifications.
587    ///
588    /// Snippets are typically reusable code templates, examples, or
589    /// documentation stored as Markdown files. They follow the same
590    /// dependency format as agents.
591    ///
592    /// See [`ResourceDependency`] for specification format details.
593    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
594    pub snippets: HashMap<String, ResourceDependency>,
595
596    /// Command dependencies mapping names to their specifications.
597    ///
598    /// Commands are Claude Code slash commands that provide custom functionality
599    /// and automation within the Claude Code interface. They follow the same
600    /// dependency format as agents and snippets.
601    ///
602    /// See [`ResourceDependency`] for specification format details.
603    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
604    pub commands: HashMap<String, ResourceDependency>,
605
606    /// MCP server configurations mapping names to their specifications.
607    ///
608    /// MCP servers provide integrations with external systems and services,
609    /// allowing Claude Code to connect to databases, APIs, and other tools.
610    /// MCP servers are JSON configuration files that get installed to
611    /// `.claude/agpm/mcp-servers/` and configured in `.mcp.json`.
612    ///
613    /// See [`ResourceDependency`] for specification format details.
614    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
615    pub mcp_servers: HashMap<String, ResourceDependency>,
616
617    /// Script dependencies mapping names to their specifications.
618    ///
619    /// Scripts are executable files (.sh, .js, .py, etc.) that can be run by hooks
620    /// or independently. They are installed to `.claude/agpm/scripts/` and can be
621    /// referenced by hook configurations.
622    ///
623    /// See [`ResourceDependency`] for specification format details.
624    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
625    pub scripts: HashMap<String, ResourceDependency>,
626
627    /// Hook dependencies mapping names to their specifications.
628    ///
629    /// Hooks are JSON configuration files that define event-based automation
630    /// in Claude Code. They specify when to run scripts based on tool usage,
631    /// prompts, and other events. Hook configurations are merged into
632    /// `settings.local.json`.
633    ///
634    /// See [`ResourceDependency`] for specification format details.
635    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
636    pub hooks: HashMap<String, ResourceDependency>,
637}
638
639/// Resource configuration within a tool.
640///
641/// Defines the installation path for a specific resource type within a tool.
642/// The path can be omitted for resources that have special handling (e.g., MCP servers
643/// that merge into configuration files instead of being installed as separate files).
644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
645pub struct ResourceConfig {
646    /// Subdirectory path for this resource type relative to the tool's base directory.
647    ///
648    /// None means special handling (e.g., MCP servers that merge into config files)
649    #[serde(skip_serializing_if = "Option::is_none")]
650    pub path: Option<String>,
651}
652
653/// Tool configuration.
654///
655/// Defines how a specific tool (e.g., claude-code, opencode, agpm)
656/// organizes its resources. Each tool has a base directory and
657/// a map of resource types to their subdirectory configurations.
658#[derive(Debug, Clone, Serialize, Deserialize)]
659pub struct ArtifactTypeConfig {
660    /// Base directory for this tool (e.g., ".claude", ".opencode", ".agpm")
661    pub path: PathBuf,
662
663    /// Map of resource type -> configuration
664    pub resources: HashMap<String, ResourceConfig>,
665}
666
667/// Top-level tools configuration.
668///
669/// Maps tool type names to their configurations. This replaces the old
670/// `[target]` section and enables multi-tool support.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct ToolsConfig {
673    /// Map of tool type name -> configuration
674    #[serde(flatten)]
675    pub types: HashMap<String, ArtifactTypeConfig>,
676}
677
678impl Default for ToolsConfig {
679    fn default() -> Self {
680        use crate::core::ResourceType;
681        let mut types = HashMap::new();
682
683        // Claude Code configuration
684        let mut claude_resources = HashMap::new();
685        claude_resources.insert(
686            ResourceType::Agent.to_plural().to_string(),
687            ResourceConfig {
688                path: Some("agents".to_string()),
689            },
690        );
691        claude_resources.insert(
692            ResourceType::Snippet.to_plural().to_string(),
693            ResourceConfig {
694                path: Some("agpm/snippets".to_string()),
695            },
696        );
697        claude_resources.insert(
698            ResourceType::Command.to_plural().to_string(),
699            ResourceConfig {
700                path: Some("commands".to_string()),
701            },
702        );
703        claude_resources.insert(
704            ResourceType::Script.to_plural().to_string(),
705            ResourceConfig {
706                path: Some("agpm/scripts".to_string()),
707            },
708        );
709        claude_resources.insert(
710            ResourceType::Hook.to_plural().to_string(),
711            ResourceConfig {
712                path: Some("agpm/hooks".to_string()),
713            },
714        );
715        claude_resources.insert(
716            ResourceType::McpServer.to_plural().to_string(),
717            ResourceConfig {
718                path: Some("agpm/mcp-servers".to_string()),
719            },
720        );
721
722        types.insert(
723            "claude-code".to_string(),
724            ArtifactTypeConfig {
725                path: PathBuf::from(".claude"),
726                resources: claude_resources,
727            },
728        );
729
730        // OpenCode configuration
731        let mut opencode_resources = HashMap::new();
732        opencode_resources.insert(
733            ResourceType::Agent.to_plural().to_string(),
734            ResourceConfig {
735                path: Some("agent".to_string()), // Singular
736            },
737        );
738        opencode_resources.insert(
739            ResourceType::Command.to_plural().to_string(),
740            ResourceConfig {
741                path: Some("command".to_string()), // Singular
742            },
743        );
744        opencode_resources.insert(
745            ResourceType::McpServer.to_plural().to_string(),
746            ResourceConfig {
747                path: Some("agpm/mcp-servers".to_string()), // Temporary staging area for merge
748            },
749        );
750
751        types.insert(
752            "opencode".to_string(),
753            ArtifactTypeConfig {
754                path: PathBuf::from(".opencode"),
755                resources: opencode_resources,
756            },
757        );
758
759        // AGPM configuration (snippets only)
760        let mut agpm_resources = HashMap::new();
761        agpm_resources.insert(
762            ResourceType::Snippet.to_plural().to_string(),
763            ResourceConfig {
764                path: Some("snippets".to_string()),
765            },
766        );
767
768        types.insert(
769            "agpm".to_string(),
770            ArtifactTypeConfig {
771                path: PathBuf::from(".agpm"),
772                resources: agpm_resources,
773            },
774        );
775
776        Self {
777            types,
778        }
779    }
780}
781
782/// Target directories configuration specifying where resources are installed.
783///
784/// This struct defines the installation destinations for different resource types
785/// within a AGPM project. All paths are relative to the project root (where
786/// `agpm.toml` is located) unless they are absolute paths.
787///
788/// # Default Values
789///
790/// - **Agents**: `.claude/agents` - Following Claude Code conventions
791/// - **Snippets**: `.claude/agpm/snippets` - Following Claude Code conventions
792/// - **Commands**: `.claude/commands` - Following Claude Code conventions
793///
794/// # Path Resolution
795///
796/// - Relative paths are resolved from the manifest directory
797/// - Absolute paths are used as-is (not recommended for portability)
798/// - Path separators are automatically normalized for the target platform
799/// - Directories are created automatically during installation if they don't exist
800///
801/// # Examples
802///
803/// ```toml
804/// # Default configuration (can be omitted)
805/// [target]
806/// agents = ".claude/agents"
807/// snippets = ".claude/agpm/snippets"
808/// commands = ".claude/commands"
809///
810/// # Custom configuration
811/// [target]
812/// agents = "resources/ai-agents"
813/// snippets = "templates/code-snippets"
814/// commands = "resources/commands"
815///
816/// # Absolute paths (use with caution)
817/// [target]
818/// agents = "/opt/claude/agents"
819/// snippets = "/opt/claude/snippets"
820/// commands = "/opt/claude/commands"
821/// ```
822///
823/// # Cross-Platform Considerations
824///
825/// AGPM automatically handles platform differences:
826/// - Forward slashes work on all platforms (Windows, macOS, Linux)
827/// - Path separators are normalized during installation
828/// - Long path support on Windows is handled automatically
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct TargetConfig {
831    /// Directory where agent `.md` files should be installed.
832    ///
833    /// Agents are AI model definitions, prompts, or behavioral specifications.
834    /// This directory will contain copies of agent files from dependencies.
835    ///
836    /// **Default**: `.claude/agents` (following Claude Code conventions)
837    #[serde(default = "default_agents_dir")]
838    pub agents: String,
839
840    /// Directory where snippet `.md` files should be installed.
841    ///
842    /// Snippets are reusable code templates, examples, or documentation.
843    /// This directory will contain copies of snippet files from dependencies.
844    ///
845    /// **Default**: `.claude/agpm/snippets` (following Claude Code conventions)
846    #[serde(default = "default_snippets_dir")]
847    pub snippets: String,
848
849    /// Directory where command `.md` files should be installed.
850    ///
851    /// Commands are Claude Code slash commands that provide custom functionality.
852    /// This directory will contain copies of command files from dependencies.
853    ///
854    /// **Default**: `.claude/commands` (following Claude Code conventions)
855    #[serde(default = "default_commands_dir")]
856    pub commands: String,
857
858    /// Directory where MCP server configurations should be tracked.
859    ///
860    /// Note: MCP servers are configured in `.mcp.json` at the project root,
861    /// not installed to this directory. This directory is used for tracking
862    /// metadata about installed servers.
863    ///
864    /// **Default**: `.claude/agpm/mcp-servers` (following Claude Code conventions)
865    #[serde(default = "default_mcp_servers_dir", rename = "mcp-servers")]
866    pub mcp_servers: String,
867
868    /// Directory where script files should be installed.
869    ///
870    /// Scripts are executable files (.sh, .js, .py, etc.) that can be referenced
871    /// by hooks or run independently.
872    ///
873    /// **Default**: `.claude/agpm/scripts` (following Claude Code conventions)
874    #[serde(default = "default_scripts_dir")]
875    pub scripts: String,
876
877    /// Directory where hook configuration files should be installed.
878    ///
879    /// Hooks are JSON configuration files that define event-based automation
880    /// in Claude Code.
881    ///
882    /// **Default**: `.claude/agpm/hooks` (following Claude Code conventions)
883    #[serde(default = "default_hooks_dir")]
884    pub hooks: String,
885
886    /// Whether to automatically add installed files to `.gitignore`.
887    ///
888    /// When enabled (default), AGPM will create or update `.gitignore`
889    /// to exclude all installed files from version control. This prevents
890    /// installed dependencies from being committed to your repository.
891    ///
892    /// Set to `false` if you want to commit installed resources to version control.
893    ///
894    /// **Default**: `true`
895    #[serde(default = "default_gitignore")]
896    pub gitignore: bool,
897}
898
899impl Default for TargetConfig {
900    fn default() -> Self {
901        Self {
902            agents: default_agents_dir(),
903            snippets: default_snippets_dir(),
904            commands: default_commands_dir(),
905            mcp_servers: default_mcp_servers_dir(),
906            scripts: default_scripts_dir(),
907            hooks: default_hooks_dir(),
908            gitignore: default_gitignore(),
909        }
910    }
911}
912
913fn default_agents_dir() -> String {
914    ".claude/agents".to_string()
915}
916
917fn default_snippets_dir() -> String {
918    ".claude/agpm/snippets".to_string()
919}
920
921fn default_commands_dir() -> String {
922    ".claude/commands".to_string()
923}
924
925fn default_mcp_servers_dir() -> String {
926    ".claude/agpm/mcp-servers".to_string()
927}
928
929fn default_scripts_dir() -> String {
930    ".claude/agpm/scripts".to_string()
931}
932
933fn default_hooks_dir() -> String {
934    ".claude/agpm/hooks".to_string()
935}
936
937const fn default_gitignore() -> bool {
938    true
939}
940
941/// A resource dependency specification supporting multiple formats.
942///
943/// Dependencies can be specified in two main formats to balance simplicity
944/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
945/// deserialize the correct variant based on the TOML structure.
946///
947/// # Variants
948///
949/// ## Simple Dependencies
950///
951/// For local file dependencies, just specify the path directly:
952///
953/// ```toml
954/// [agents]
955/// local-helper = "../shared/agents/helper.md"
956/// nearby-agent = "./local/custom-agent.md"
957/// ```
958///
959/// ## Detailed Dependencies
960///
961/// For remote dependencies or when you need more control:
962///
963/// ```toml
964/// [agents]
965/// # Remote dependency with version
966/// code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
967///
968/// # Remote dependency with git reference
969/// experimental = { source = "community", path = "agents/new.md", git = "develop" }
970///
971/// # Local dependency with explicit path (equivalent to simple form)
972/// local-tool = { path = "../tools/agent.md" }
973/// ```
974///
975/// # Validation Rules
976///
977/// - **Local dependencies** (no source): Cannot have version constraints
978/// - **Remote dependencies** (with source): Must have either `version` or `git` field
979/// - **Path field**: Required and cannot be empty
980/// - **Source field**: Must reference an existing source in the `[sources]` section
981///
982/// # Type Safety
983///
984/// The enum ensures type safety at compile time while providing runtime
985/// validation through the [`Manifest::validate`] method.
986///
987/// # Serialization Behavior
988///
989/// - Simple paths serialize directly as strings
990/// - Detailed specs serialize as TOML inline tables
991/// - Empty optional fields are omitted for cleaner output
992/// - Deserialization is automatic based on TOML structure
993///
994/// # Memory Layout
995///
996/// This enum uses `#[serde(untagged)]` for automatic variant detection,
997/// which means deserialization tries the `Detailed` variant first, then
998/// falls back to `Simple`. This is efficient for the expected usage patterns
999/// where detailed dependencies are more common in larger projects.
1000///
1001/// # Memory Layout
1002///
1003/// The `Detailed` variant is boxed to reduce the size of the enum from 264 bytes
1004/// to 24 bytes, improving memory efficiency when many dependencies are stored.
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1006#[serde(untagged)]
1007pub enum ResourceDependency {
1008    /// Simple path-only dependency, typically for local files.
1009    ///
1010    /// This variant represents the simplest dependency format where only
1011    /// a file path is specified. It's primarily used for local dependencies
1012    /// that exist in the filesystem relative to the project.
1013    ///
1014    /// # Format
1015    ///
1016    /// ```toml
1017    /// dependency-name = "path/to/file.md"
1018    /// ```
1019    ///
1020    /// # Examples
1021    ///
1022    /// ```toml
1023    /// [agents]
1024    /// # Relative paths from manifest directory
1025    /// helper = "../shared/helper.md"
1026    /// custom = "./local/custom.md"
1027    ///
1028    /// # Absolute paths (not recommended)
1029    /// system = "/usr/local/share/agent.md"
1030    /// ```
1031    ///
1032    /// # Limitations
1033    ///
1034    /// - Cannot specify version constraints
1035    /// - Cannot reference remote Git sources
1036    /// - Must be a valid filesystem path
1037    /// - Path must exist at installation time
1038    Simple(String),
1039
1040    /// Detailed dependency specification with full control.
1041    ///
1042    /// This variant provides complete control over dependency specification,
1043    /// supporting both local and remote dependencies with version constraints,
1044    /// Git references, and explicit source mapping.
1045    ///
1046    /// See [`DetailedDependency`] for field-level documentation.
1047    ///
1048    /// Note: This variant is boxed to reduce the overall size of the enum.
1049    Detailed(Box<DetailedDependency>),
1050}
1051
1052/// Detailed dependency specification with full control over source resolution.
1053///
1054/// This struct provides fine-grained control over dependency specification,
1055/// supporting both local filesystem paths and remote Git repository resources
1056/// with flexible version constraints and Git reference handling.
1057///
1058/// # Field Relationships
1059///
1060/// The fields work together with specific validation rules:
1061/// - If `source` is specified: Must have either `version` or `git`
1062/// - If `source` is omitted: Dependency is local, `version` and `git` are ignored
1063/// - `path` is always required and cannot be empty
1064///
1065/// # Examples
1066///
1067/// ## Remote Dependencies
1068///
1069/// ```toml
1070/// [agents]
1071/// # Semantic version constraint
1072/// stable = { source = "official", path = "agents/stable.md", version = "v1.0.0" }
1073///
1074/// # Latest version (not recommended for production)
1075/// latest = { source = "community", path = "agents/utils.md", version = "latest" }
1076///
1077/// # Specific Git branch
1078/// cutting-edge = { source = "official", path = "agents/new.md", git = "develop" }
1079///
1080/// # Specific commit SHA (maximum reproducibility)
1081/// pinned = { source = "community", path = "agents/tool.md", git = "a1b2c3d4e5f6..." }
1082///
1083/// # Git tag
1084/// release = { source = "official", path = "agents/release.md", git = "v2.0-release" }
1085/// ```
1086///
1087/// ## Local Dependencies
1088///
1089/// ```toml
1090/// [agents]
1091/// # Local file (version/git fields ignored if present)
1092/// local-helper = { path = "../shared/helper.md" }
1093/// custom = { path = "./local/custom.md" }
1094/// ```
1095///
1096/// # Version Resolution Priority
1097///
1098/// When both `version` and `git` are specified, `git` takes precedence:
1099///
1100/// ```toml
1101/// # This will use the "develop" branch, not "v1.0.0"
1102/// conflicted = { source = "repo", path = "file.md", version = "v1.0.0", git = "develop" }
1103/// ```
1104///
1105/// # Path Format
1106///
1107/// Paths are interpreted differently based on context:
1108/// - **Remote dependencies**: Path within the Git repository
1109/// - **Local dependencies**: Filesystem path relative to manifest directory
1110#[derive(Debug, Clone, Serialize, Deserialize)]
1111pub struct DetailedDependency {
1112    /// Source repository name referencing the `[sources]` section.
1113    ///
1114    /// When specified, this dependency will be resolved from a Git repository.
1115    /// The name must exactly match a key in the manifest's `[sources]` table.
1116    ///
1117    /// **Omit this field** to create a local filesystem dependency.
1118    ///
1119    /// # Examples
1120    ///
1121    /// ```toml
1122    /// # References this source definition:
1123    /// [sources]
1124    /// official = "https://github.com/org/repo.git"
1125    ///
1126    /// [agents]
1127    /// remote-agent = { source = "official", path = "agents/tool.md", version = "v1.0.0" }
1128    /// local-agent = { path = "../local/tool.md" }  # No source = local dependency
1129    /// ```
1130    #[serde(skip_serializing_if = "Option::is_none")]
1131    pub source: Option<String>,
1132
1133    /// Path to the resource file or glob pattern for multiple resources.
1134    ///
1135    /// For **remote dependencies**: Path within the Git repository\
1136    /// For **local dependencies**: Filesystem path relative to manifest directory\
1137    /// For **pattern dependencies**: Glob pattern to match multiple resources
1138    ///
1139    /// This field supports both individual file paths and glob patterns:
1140    /// - Individual file: `"agents/helper.md"`
1141    /// - Pattern matching: `"agents/*.md"`, `"**/*.md"`, `"agents/[a-z]*.md"`
1142    ///
1143    /// Pattern dependencies are detected by the presence of glob characters
1144    /// (`*`, `?`, `[`) in the path. When a pattern is detected, AGPM will
1145    /// expand it to match all resources in the source repository.
1146    ///
1147    /// # Examples
1148    ///
1149    /// ```toml
1150    /// # Remote: single file in git repo
1151    /// remote = { source = "repo", path = "agents/helper.md", version = "v1.0.0" }
1152    ///
1153    /// # Local: filesystem path
1154    /// local = { path = "../shared/helper.md" }
1155    ///
1156    /// # Pattern: all agents in AI folder
1157    /// ai_agents = { source = "repo", path = "agents/ai/*.md", version = "v1.0.0" }
1158    ///
1159    /// # Pattern: all agents recursively
1160    /// all_agents = { source = "repo", path = "agents/**/*.md", version = "v1.0.0" }
1161    /// ```
1162    pub path: String,
1163
1164    /// Version constraint for Git tag resolution.
1165    ///
1166    /// Specifies which version of the resource to use when resolving from
1167    /// a Git repository. This field is ignored for local dependencies.
1168    ///
1169    /// **Note**: If both `version` and `git` are specified, `git` takes precedence.
1170    ///
1171    /// # Supported Formats
1172    ///
1173    /// - `"v1.0.0"` - Exact semantic version tag
1174    /// - `"1.0.0"` - Exact version (v prefix optional)
1175    /// - `"^1.0.0"` - Semantic version constraint (highest compatible 1.x.x)
1176    /// - `"latest"` - Git tag or branch named "latest" (not special - just a name)
1177    /// - `"main"` - Use main/master branch HEAD
1178    ///
1179    /// # Examples
1180    ///
1181    /// ```toml
1182    /// [agents]
1183    /// stable = { source = "repo", path = "agent.md", version = "v1.0.0" }
1184    /// flexible = { source = "repo", path = "agent.md", version = "^1.0.0" }
1185    /// latest-tag = { source = "repo", path = "agent.md", version = "latest" }  # If repo has a "latest" tag
1186    /// main = { source = "repo", path = "agent.md", version = "main" }
1187    /// ```
1188    #[serde(skip_serializing_if = "Option::is_none")]
1189    pub version: Option<String>,
1190
1191    /// Git branch to track.
1192    ///
1193    /// Specifies a Git branch to use when resolving the dependency.
1194    /// Branch references are mutable and will update to the latest commit on each update.
1195    /// This field is ignored for local dependencies.
1196    ///
1197    /// # Examples
1198    ///
1199    /// ```toml
1200    /// [agents]
1201    /// # Track the main branch
1202    /// dev = { source = "repo", path = "agent.md", branch = "main" }
1203    ///
1204    /// # Track a feature branch
1205    /// experimental = { source = "repo", path = "agent.md", branch = "feature/new-capability" }
1206    /// ```
1207    #[serde(skip_serializing_if = "Option::is_none")]
1208    pub branch: Option<String>,
1209
1210    /// Git commit hash (revision).
1211    ///
1212    /// Specifies an exact Git commit to use when resolving the dependency.
1213    /// Provides maximum reproducibility as commits are immutable.
1214    /// This field is ignored for local dependencies.
1215    ///
1216    /// # Examples
1217    ///
1218    /// ```toml
1219    /// [agents]
1220    /// # Pin to exact commit (full hash)
1221    /// pinned = { source = "repo", path = "agent.md", rev = "a1b2c3d4e5f67890abcdef1234567890abcdef12" }
1222    ///
1223    /// # Pin to exact commit (abbreviated)
1224    /// stable = { source = "repo", path = "agent.md", rev = "abc123def" }
1225    /// ```
1226    #[serde(skip_serializing_if = "Option::is_none")]
1227    pub rev: Option<String>,
1228
1229    /// Command to execute for MCP servers.
1230    ///
1231    /// This field is specific to MCP server dependencies and specifies
1232    /// the command that will be executed to run the MCP server.
1233    /// Only used for entries in the `[mcp-servers]` section.
1234    ///
1235    /// # Examples
1236    ///
1237    /// ```toml
1238    /// [mcp-servers]
1239    /// github = { source = "repo", path = "mcp/github.toml", version = "v1.0.0", command = "npx" }
1240    /// sqlite = { path = "./local/sqlite.toml", command = "uvx" }
1241    /// ```
1242    #[serde(skip_serializing_if = "Option::is_none")]
1243    pub command: Option<String>,
1244
1245    /// Arguments to pass to the MCP server command.
1246    ///
1247    /// This field is specific to MCP server dependencies and provides
1248    /// the arguments that will be passed to the command when starting
1249    /// the MCP server. Only used for entries in the `[mcp-servers]` section.
1250    ///
1251    /// # Examples
1252    ///
1253    /// ```toml
1254    /// [mcp-servers]
1255    /// github = {
1256    ///     source = "repo",
1257    ///     path = "mcp/github.toml",
1258    ///     version = "v1.0.0",
1259    ///     command = "npx",
1260    ///     args = ["-y", "@modelcontextprotocol/server-github"]
1261    /// }
1262    /// sqlite = {
1263    ///     path = "./local/sqlite.toml",
1264    ///     command = "uvx",
1265    ///     args = ["mcp-server-sqlite", "--db", "./data/local.db"]
1266    /// }
1267    /// ```
1268    #[serde(skip_serializing_if = "Option::is_none")]
1269    pub args: Option<Vec<String>>,
1270    /// Custom target directory for this dependency.
1271    ///
1272    /// Overrides the default installation directory for this specific dependency.
1273    /// The path is relative to the `.claude` directory for consistency and security.
1274    /// If not specified, the dependency will be installed to the default location
1275    /// based on its resource type.
1276    ///
1277    /// # Examples
1278    ///
1279    /// ```toml
1280    /// [agents]
1281    /// # Install to .claude/custom/tools/ instead of default .claude/agents/
1282    /// special-agent = {
1283    ///     source = "repo",
1284    ///     path = "agent.md",
1285    ///     version = "v1.0.0",
1286    ///     target = "custom/tools"
1287    /// }
1288    ///
1289    /// # Install to .claude/integrations/ai/
1290    /// integration = {
1291    ///     source = "repo",
1292    ///     path = "integration.md",
1293    ///     version = "v2.0.0",
1294    ///     target = "integrations/ai"
1295    /// }
1296    /// ```
1297    #[serde(skip_serializing_if = "Option::is_none")]
1298    pub target: Option<String>,
1299
1300    /// Custom filename for this dependency.
1301    ///
1302    /// Overrides the default filename (which is based on the dependency key).
1303    /// The filename should include the desired file extension. If not specified,
1304    /// the dependency will be installed using the key name with an automatically
1305    /// determined extension based on the resource type.
1306    ///
1307    /// # Examples
1308    ///
1309    /// ```toml
1310    /// [agents]
1311    /// # Install as "ai-assistant.md" instead of "my-ai.md"
1312    /// my-ai = {
1313    ///     source = "repo",
1314    ///     path = "agent.md",
1315    ///     version = "v1.0.0",
1316    ///     filename = "ai-assistant.md"
1317    /// }
1318    ///
1319    /// # Install with a different extension
1320    /// doc-agent = {
1321    ///     source = "repo",
1322    ///     path = "documentation.md",
1323    ///     version = "v2.0.0",
1324    ///     filename = "docs-helper.txt"
1325    /// }
1326    ///
1327    /// [scripts]
1328    /// # Rename a script during installation
1329    /// analyzer = {
1330    ///     source = "repo",
1331    ///     path = "scripts/data-analyzer-v3.py",
1332    ///     version = "v1.0.0",
1333    ///     filename = "analyze.py"
1334    /// }
1335    /// ```
1336    #[serde(skip_serializing_if = "Option::is_none")]
1337    pub filename: Option<String>,
1338
1339    /// Transitive dependencies on other resources.
1340    ///
1341    /// This field is populated from metadata extracted from the resource file itself
1342    /// (YAML frontmatter in .md files or JSON fields in .json files).
1343    /// Maps resource type to list of dependency specifications.
1344    ///
1345    /// Example:
1346    /// ```toml
1347    /// # This would be extracted from the file's frontmatter/JSON, not specified in agpm.toml
1348    /// # { "agents": [{"path": "agents/helper.md", "version": "v1.0.0"}] }
1349    /// ```
1350    #[serde(skip_serializing_if = "Option::is_none")]
1351    pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,
1352
1353    /// Tool type (claude-code, opencode, agpm, or custom).
1354    ///
1355    /// Specifies which target AI coding assistant tool this resource is for. This determines
1356    /// where the resource is installed and how it's configured.
1357    ///
1358    /// **Defaults to "claude-code"** for backward compatibility with existing manifests.
1359    ///
1360    /// Omitted from TOML serialization when the value is "claude-code" (default).
1361    #[serde(default = "default_tool", skip_serializing_if = "is_default_tool")]
1362    pub tool: String,
1363}
1364
1365fn default_tool() -> String {
1366    "claude-code".to_string()
1367}
1368
1369fn is_default_tool(tool: &str) -> bool {
1370    tool == "claude-code"
1371}
1372
1373impl Manifest {
1374    /// Create a new empty manifest with default configuration.
1375    ///
1376    /// The new manifest will have:
1377    /// - No sources defined
1378    /// - Default target directories (`.claude/agents` and `.claude/agpm/snippets`)
1379    /// - No dependencies
1380    ///
1381    /// This is typically used when programmatically building a manifest or
1382    /// as a starting point for adding dependencies.
1383    ///
1384    /// # Examples
1385    ///
1386    /// ```rust,no_run
1387    /// use agpm_cli::manifest::Manifest;
1388    ///
1389    /// let manifest = Manifest::new();
1390    /// assert!(manifest.sources.is_empty());
1391    /// assert!(manifest.agents.is_empty());
1392    /// assert!(manifest.snippets.is_empty());
1393    /// assert!(manifest.commands.is_empty());
1394    /// assert!(manifest.mcp_servers.is_empty());
1395    /// assert_eq!(manifest.target.agents, ".claude/agents");
1396    /// ```
1397    #[must_use]
1398    #[allow(deprecated)]
1399    pub fn new() -> Self {
1400        Self {
1401            sources: HashMap::new(),
1402            target: TargetConfig::default(),
1403            tools: None,
1404            agents: HashMap::new(),
1405            snippets: HashMap::new(),
1406            commands: HashMap::new(),
1407            mcp_servers: HashMap::new(),
1408            scripts: HashMap::new(),
1409            hooks: HashMap::new(),
1410        }
1411    }
1412
1413    /// Load and parse a manifest from a TOML file.
1414    ///
1415    /// This method reads the specified file, parses it as TOML, deserializes
1416    /// it into a [`Manifest`] struct, and validates the result. The entire
1417    /// operation is atomic - either the manifest loads successfully or an
1418    /// error is returned.
1419    ///
1420    /// # Validation
1421    ///
1422    /// After parsing, the manifest is automatically validated to ensure:
1423    /// - All dependency sources reference valid entries in the `[sources]` section
1424    /// - Required fields are present and non-empty
1425    /// - Version constraints are properly specified for remote dependencies
1426    /// - Source URLs use supported protocols
1427    /// - No version conflicts exist between dependencies
1428    ///
1429    /// # Error Handling
1430    ///
1431    /// Returns detailed errors for common problems:
1432    /// - **File I/O errors**: File not found, permission denied, etc.
1433    /// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
1434    /// - **Validation errors**: Logical inconsistencies in the manifest
1435    /// - **Security errors**: Unsafe URL patterns or credential leakage
1436    ///
1437    /// All errors include contextual information and actionable suggestions.
1438    ///
1439    /// # Examples
1440    ///
1441    /// ```rust,no_run,ignore
1442    /// use agpm_cli::manifest::Manifest;
1443    /// use std::path::Path;
1444    ///
1445    /// // Load a manifest file
1446    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1447    ///
1448    /// // Access parsed data
1449    /// println!("Found {} sources", manifest.sources.len());
1450    /// println!("Found {} agents", manifest.agents.len());
1451    /// println!("Found {} snippets", manifest.snippets.len());
1452    /// # Ok::<(), anyhow::Error>(())
1453    /// ```
1454    ///
1455    /// # File Format
1456    ///
1457    /// Expects a valid TOML file following the AGPM manifest format.
1458    /// See the module-level documentation for complete format specification.
1459    pub fn load(path: &Path) -> Result<Self> {
1460        let content = std::fs::read_to_string(path).with_context(|| {
1461            format!(
1462                "Cannot read manifest file: {}\n\n\
1463                    Possible causes:\n\
1464                    - File doesn't exist or has been moved\n\
1465                    - Permission denied (check file ownership)\n\
1466                    - File is locked by another process",
1467                path.display()
1468            )
1469        })?;
1470
1471        let mut manifest: Self = toml::from_str(&content)
1472            .map_err(|e| crate::core::AgpmError::ManifestParseError {
1473                file: path.display().to_string(),
1474                reason: e.to_string(),
1475            })
1476            .with_context(|| {
1477                format!(
1478                    "Invalid TOML syntax in manifest file: {}\n\n\
1479                    Common TOML syntax errors:\n\
1480                    - Missing quotes around strings\n\
1481                    - Unmatched brackets [ ] or braces {{ }}\n\
1482                    - Invalid characters in keys or values\n\
1483                    - Incorrect indentation or structure",
1484                    path.display()
1485                )
1486            })?;
1487
1488        // Apply resource-type-specific defaults for tool
1489        // Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
1490        manifest.apply_tool_defaults();
1491
1492        manifest.validate()?;
1493
1494        Ok(manifest)
1495    }
1496
1497    /// Apply resource-type-specific defaults for tool.
1498    ///
1499    /// This method adjusts the tool field based on the resource type to provide
1500    /// more sensible defaults:
1501    /// - **Snippets**: Default to "agpm" (shared infrastructure) instead of "claude-code"
1502    /// - **All other resources**: Keep "claude-code" as the default
1503    ///
1504    /// This is called automatically after deserialization in `load()`.
1505    ///
1506    /// # Rationale
1507    ///
1508    /// Snippets are designed to be shared content across multiple tools (Claude Code,
1509    /// OpenCode, etc.). The `.agpm/snippets/` location provides a shared infrastructure
1510    /// that can be referenced by resources from different tools. Therefore, snippets
1511    /// should default to the "agpm" tool type.
1512    ///
1513    /// Users can still explicitly set `tool = "claude-code"` for a snippet if they want
1514    /// it installed to `.claude/agpm/snippets/` instead.
1515    fn apply_tool_defaults(&mut self) {
1516        // Apply snippet-specific default: "agpm" instead of "claude-code"
1517        for dependency in self.snippets.values_mut() {
1518            if let ResourceDependency::Detailed(details) = dependency {
1519                // Only change if it's still the serde default ("claude-code")
1520                // This means: no explicit type was specified in the manifest
1521                if details.tool == "claude-code" {
1522                    details.tool = "agpm".to_string();
1523                }
1524            }
1525        }
1526    }
1527
1528    /// Save the manifest to a TOML file with pretty formatting.
1529    ///
1530    /// This method serializes the manifest to TOML format and writes it to the
1531    /// specified file path. The output is pretty-printed for human readability
1532    /// and follows TOML best practices.
1533    ///
1534    /// # Formatting
1535    ///
1536    /// The generated TOML file will:
1537    /// - Use consistent indentation and spacing
1538    /// - Omit empty sections for cleaner output
1539    /// - Order sections logically (sources, target, agents, snippets)
1540    /// - Include inline tables for detailed dependencies
1541    ///
1542    /// # Atomic Operation
1543    ///
1544    /// The save operation is atomic - the file is either completely written
1545    /// or left unchanged. This prevents corruption if the operation fails
1546    /// partway through.
1547    ///
1548    /// # Error Handling
1549    ///
1550    /// Returns detailed errors for common problems:
1551    /// - **Permission denied**: Insufficient write permissions
1552    /// - **Directory doesn't exist**: Parent directory missing  
1553    /// - **Disk full**: Insufficient storage space
1554    /// - **File locked**: Another process has the file open
1555    ///
1556    /// # Examples
1557    ///
1558    /// ```rust,no_run
1559    /// use agpm_cli::manifest::Manifest;
1560    /// use std::path::Path;
1561    ///
1562    /// let mut manifest = Manifest::new();
1563    /// manifest.add_source(
1564    ///     "official".to_string(),
1565    ///     "https://github.com/claude-org/resources.git".to_string()
1566    /// );
1567    ///
1568    /// // Save to file
1569    /// # use tempfile::tempdir;
1570    /// # let temp_dir = tempdir()?;
1571    /// # let manifest_path = temp_dir.path().join("agpm.toml");
1572    /// manifest.save(&manifest_path)?;
1573    /// # Ok::<(), anyhow::Error>(())
1574    /// ```
1575    ///
1576    /// # Output Format
1577    ///
1578    /// The generated file will follow this structure:
1579    ///
1580    /// ```toml
1581    /// [sources]
1582    /// official = "https://github.com/claude-org/resources.git"
1583    ///
1584    /// [target]
1585    /// agents = ".claude/agents"
1586    /// snippets = ".claude/agpm/snippets"
1587    ///
1588    /// [agents]
1589    /// helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
1590    ///
1591    /// [snippets]
1592    /// utils = { source = "official", path = "snippets/utils.md", version = "v1.0.0" }
1593    /// ```
1594    pub fn save(&self, path: &Path) -> Result<()> {
1595        let content = toml::to_string_pretty(self)
1596            .with_context(|| "Failed to serialize manifest data to TOML format")?;
1597
1598        std::fs::write(path, content).with_context(|| {
1599            format!(
1600                "Cannot write manifest file: {}\n\n\
1601                    Possible causes:\n\
1602                    - Permission denied (try running with elevated permissions)\n\
1603                    - Directory doesn't exist\n\
1604                    - Disk is full or read-only\n\
1605                    - File is locked by another process",
1606                path.display()
1607            )
1608        })?;
1609
1610        Ok(())
1611    }
1612
1613    /// Validate the manifest structure and enforce business rules.
1614    ///
1615    /// This method performs comprehensive validation of the manifest to ensure
1616    /// logical consistency, security best practices, and correct dependency
1617    /// relationships. It's automatically called during [`Self::load`] but can
1618    /// also be used independently to validate programmatically constructed manifests.
1619    ///
1620    /// # Validation Rules
1621    ///
1622    /// ## Source Validation
1623    /// - All source URLs must use supported protocols (HTTPS, SSH, git://, file://)
1624    /// - No plain directory paths allowed as sources (must use file:// URLs)
1625    /// - No authentication tokens embedded in URLs (security check)
1626    /// - Environment variable expansion is validated for syntax
1627    ///
1628    /// ## Dependency Validation  
1629    /// - All dependency paths must be non-empty
1630    /// - Remote dependencies must reference existing sources
1631    /// - Remote dependencies must specify version constraints
1632    /// - Local dependencies cannot have version constraints
1633    /// - No version conflicts between dependencies with the same name
1634    ///
1635    /// ## Path Validation
1636    /// - Local dependency paths are checked for proper format
1637    /// - Remote dependency paths are validated as repository-relative
1638    /// - Path traversal attempts are detected and rejected
1639    ///
1640    /// # Error Types
1641    ///
1642    /// Returns specific error types for different validation failures:
1643    /// - [`crate::core::AgpmError::SourceNotFound`]: Referenced source doesn't exist
1644    /// - [`crate::core::AgpmError::ManifestValidationError`]: General validation failures
1645    /// - Context errors for specific issues with actionable suggestions
1646    ///
1647    /// # Examples
1648    ///
1649    /// ```rust,no_run
1650    /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
1651    ///
1652    /// let mut manifest = Manifest::new();
1653    ///
1654    /// // This will pass validation (local dependency)
1655    /// manifest.add_dependency(
1656    ///     "local".to_string(),
1657    ///     ResourceDependency::Simple("../local/helper.md".to_string()),
1658    ///     true
1659    /// );
1660    /// assert!(manifest.validate().is_ok());
1661    ///
1662    /// // This will fail validation (missing source)
1663    /// manifest.add_dependency(
1664    ///     "remote".to_string(),
1665    ///     ResourceDependency::Detailed(Box::new(DetailedDependency {
1666    ///         source: Some("missing".to_string()),
1667    ///         path: "agent.md".to_string(),
1668    ///         version: Some("v1.0.0".to_string()),
1669    ///         branch: None,
1670    ///         rev: None,
1671    ///         command: None,
1672    ///         args: None,
1673    ///         target: None,
1674    ///         filename: None,
1675    ///         dependencies: None,
1676    ///         tool: "claude-code".to_string(),
1677    ///     })),
1678    ///     true
1679    /// );
1680    /// assert!(manifest.validate().is_err());
1681    /// ```
1682    ///
1683    /// # Security Considerations
1684    ///
1685    /// This method enforces critical security rules:
1686    /// - Prevents credential leakage in version-controlled files
1687    /// - Blocks path traversal attacks in local dependencies
1688    /// - Validates URL schemes to prevent protocol confusion
1689    /// - Checks for malicious patterns in dependency specifications
1690    ///
1691    /// # Performance
1692    ///
1693    /// Validation is designed to be fast and is safe to call frequently.
1694    /// Complex validations (like network connectivity) are not performed
1695    /// here - those are handled during dependency resolution.
1696    pub fn validate(&self) -> Result<()> {
1697        // Validate artifact type names
1698        for artifact_type in self.get_tools_config().types.keys() {
1699            if artifact_type.contains('/') || artifact_type.contains('\\') {
1700                return Err(crate::core::AgpmError::ManifestValidationError {
1701                    reason: format!(
1702                        "Artifact type name '{artifact_type}' cannot contain path separators ('/' or '\\\\'). \n\
1703                        Artifact type names must be simple identifiers without special characters."
1704                    ),
1705                }
1706                .into());
1707            }
1708
1709            // Also check for other potentially problematic characters
1710            if artifact_type.contains("..") {
1711                return Err(crate::core::AgpmError::ManifestValidationError {
1712                    reason: format!(
1713                        "Artifact type name '{artifact_type}' cannot contain '..' (path traversal). \n\
1714                        Artifact type names must be simple identifiers."
1715                    ),
1716                }
1717                .into());
1718            }
1719        }
1720
1721        // Check that all referenced sources exist and dependencies have required fields
1722        for (name, dep) in self.all_dependencies() {
1723            // Check for empty path
1724            if dep.get_path().is_empty() {
1725                return Err(crate::core::AgpmError::ManifestValidationError {
1726                    reason: format!("Missing required field 'path' for dependency '{name}'"),
1727                }
1728                .into());
1729            }
1730
1731            // Validate pattern safety if it's a pattern dependency
1732            if dep.is_pattern() {
1733                crate::pattern::validate_pattern_safety(dep.get_path()).map_err(|e| {
1734                    crate::core::AgpmError::ManifestValidationError {
1735                        reason: format!("Invalid pattern in dependency '{name}': {e}"),
1736                    }
1737                })?;
1738            }
1739
1740            // Check for version when source is specified (non-local dependencies)
1741            if let Some(source) = dep.get_source() {
1742                if !self.sources.contains_key(source) {
1743                    return Err(crate::core::AgpmError::SourceNotFound {
1744                        name: source.to_string(),
1745                    }
1746                    .into());
1747                }
1748
1749                // Check if the source URL is a local path
1750                let source_url = self.sources.get(source).unwrap();
1751                let _is_local_source = source_url.starts_with('/')
1752                    || source_url.starts_with("./")
1753                    || source_url.starts_with("../");
1754
1755                // Git dependencies can optionally have a version (defaults to 'main' if not specified)
1756                // Local path sources don't need versions
1757                // We no longer require versions for Git dependencies - they'll default to 'main'
1758            } else {
1759                // For local path dependencies (no source), version is not allowed
1760                // Skip directory check for pattern dependencies
1761                if !dep.is_pattern() {
1762                    let path = dep.get_path();
1763                    let is_plain_dir =
1764                        path.starts_with('/') || path.starts_with("./") || path.starts_with("../");
1765
1766                    if is_plain_dir && dep.get_version().is_some() {
1767                        return Err(crate::core::AgpmError::ManifestValidationError {
1768                            reason: format!(
1769                                "Version specified for plain directory dependency '{name}' with path '{path}'. \n\
1770                                Plain directory dependencies do not support versions. \n\
1771                            Remove the 'version' field or use a git source instead."
1772                            ),
1773                        }
1774                        .into());
1775                    }
1776                }
1777            }
1778        }
1779
1780        // Check for version conflicts (same dependency name with different versions)
1781        let mut seen_deps: std::collections::HashMap<String, String> =
1782            std::collections::HashMap::new();
1783        for (name, dep) in self.all_dependencies() {
1784            if let Some(version) = dep.get_version() {
1785                if let Some(existing_version) = seen_deps.get(name) {
1786                    if existing_version != version {
1787                        return Err(crate::core::AgpmError::ManifestValidationError {
1788                            reason: format!(
1789                                "Version conflict for dependency '{name}': found versions '{existing_version}' and '{version}'"
1790                            ),
1791                        }
1792                        .into());
1793                    }
1794                } else {
1795                    seen_deps.insert(name.to_string(), version.to_string());
1796                }
1797            }
1798        }
1799
1800        // Validate URLs in sources
1801        for (name, url) in &self.sources {
1802            // Expand environment variables and home directory in URL
1803            let expanded_url = expand_url(url)?;
1804
1805            if !expanded_url.starts_with("http://")
1806                && !expanded_url.starts_with("https://")
1807                && !expanded_url.starts_with("git@")
1808                && !expanded_url.starts_with("file://")
1809            // Plain directory paths not allowed as sources
1810            && !expanded_url.starts_with('/')
1811            && !expanded_url.starts_with("./")
1812            && !expanded_url.starts_with("../")
1813            {
1814                return Err(crate::core::AgpmError::ManifestValidationError {
1815                    reason: format!("Source '{name}' has invalid URL: '{url}'. Must be HTTP(S), SSH (git@...), or file:// URL"),
1816                }
1817                .into());
1818            }
1819
1820            // Check if plain directory path is used as a source
1821            if expanded_url.starts_with('/')
1822                || expanded_url.starts_with("./")
1823                || expanded_url.starts_with("../")
1824            {
1825                return Err(crate::core::AgpmError::ManifestValidationError {
1826                    reason: format!(
1827                        "Plain directory path '{url}' cannot be used as source '{name}'. \n\
1828                        Sources must be git repositories. Use one of:\n\
1829                        - Remote URL: https://github.com/owner/repo.git\n\
1830                        - Local git repo: file:///absolute/path/to/repo\n\
1831                        - Or use direct path dependencies without a source"
1832                    ),
1833                }
1834                .into());
1835            }
1836        }
1837
1838        // Check for case-insensitive conflicts on all platforms
1839        // This ensures manifests are portable across different filesystems
1840        // Even though Linux supports case-sensitive files, we reject conflicts
1841        // to ensure the manifest works on Windows and macOS too
1842        let mut normalized_names: std::collections::HashSet<String> =
1843            std::collections::HashSet::new();
1844
1845        for (name, _) in self.all_dependencies() {
1846            let normalized = name.to_lowercase();
1847            if !normalized_names.insert(normalized.clone()) {
1848                // Find the original conflicting name
1849                for (other_name, _) in self.all_dependencies() {
1850                    if other_name != name && other_name.to_lowercase() == normalized {
1851                        return Err(crate::core::AgpmError::ManifestValidationError {
1852                            reason: format!(
1853                                "Case conflict: '{name}' and '{other_name}' would map to the same file on case-insensitive filesystems. To ensure portability across platforms, resource names must be case-insensitively unique."
1854                            ),
1855                        }
1856                        .into());
1857                    }
1858                }
1859            }
1860        }
1861
1862        // Validate artifact types and resource type support
1863        for resource_type in crate::core::ResourceType::all() {
1864            if let Some(deps) = self.get_dependencies(*resource_type) {
1865                for (name, dep) in deps {
1866                    // Get tool from dependency (defaults to "claude-code")
1867                    let tool = match dep {
1868                        ResourceDependency::Detailed(d) => &d.tool,
1869                        ResourceDependency::Simple(_) => "claude-code", // Default for simple deps
1870                    };
1871
1872                    // Check if tool is configured
1873                    if self.get_tool_config(tool).is_none() {
1874                        return Err(crate::core::AgpmError::ManifestValidationError {
1875                            reason: format!(
1876                                "Unknown tool '{tool}' for dependency '{name}'.\n\
1877                                Available types: {}\n\
1878                                Configure custom types in [tools] section or use a standard type.",
1879                                self.get_tools_config()
1880                                    .types
1881                                    .keys()
1882                                    .map(|s| format!("'{s}'"))
1883                                    .collect::<Vec<_>>()
1884                                    .join(", ")
1885                            ),
1886                        }
1887                        .into());
1888                    }
1889
1890                    // Check if resource type is supported by this tool
1891                    if !self.is_resource_supported(tool, *resource_type) {
1892                        let artifact_config = self.get_tool_config(tool).unwrap();
1893                        let supported_types: Vec<String> =
1894                            artifact_config.resources.keys().map(|s| s.to_string()).collect();
1895
1896                        // Build resource-type-specific suggestions
1897                        let mut suggestions = Vec::new();
1898
1899                        match resource_type {
1900                            crate::core::ResourceType::Snippet => {
1901                                suggestions.push("Snippets work best with the 'agpm' tool (shared infrastructure)".to_string());
1902                                suggestions.push(
1903                                    "Add tool='agpm' to this dependency to use shared snippets"
1904                                        .to_string(),
1905                                );
1906                            }
1907                            _ => {
1908                                // Find which tool types DO support this resource type
1909                                let default_config = ToolsConfig::default();
1910                                let tools_config = self.tools.as_ref().unwrap_or(&default_config);
1911                                let resource_plural = resource_type.to_plural();
1912                                let supporting_types: Vec<String> = tools_config
1913                                    .types
1914                                    .iter()
1915                                    .filter(|(_, config)| {
1916                                        config.resources.contains_key(resource_plural)
1917                                    })
1918                                    .map(|(type_name, _)| format!("'{}'", type_name))
1919                                    .collect();
1920
1921                                if !supporting_types.is_empty() {
1922                                    suggestions.push(format!(
1923                                        "This resource type is supported by tools: {}",
1924                                        supporting_types.join(", ")
1925                                    ));
1926                                }
1927                            }
1928                        }
1929
1930                        let mut reason = format!(
1931                            "Resource type '{}' is not supported by tool '{}' for dependency '{}'.\n\n",
1932                            resource_type.to_plural(),
1933                            tool,
1934                            name
1935                        );
1936
1937                        reason.push_str(&format!(
1938                            "Tool '{}' supports: {}\n\n",
1939                            tool,
1940                            supported_types.join(", ")
1941                        ));
1942
1943                        if !suggestions.is_empty() {
1944                            reason.push_str("💡 Suggestions:\n");
1945                            for suggestion in &suggestions {
1946                                reason.push_str(&format!("  • {}\n", suggestion));
1947                            }
1948                            reason.push('\n');
1949                        }
1950
1951                        reason.push_str(
1952                            "You can fix this by:\n\
1953                            1. Changing the 'tool' field to a supported tool\n\
1954                            2. Using a different resource type\n\
1955                            3. Removing this dependency from your manifest",
1956                        );
1957
1958                        return Err(crate::core::AgpmError::ManifestValidationError {
1959                            reason,
1960                        }
1961                        .into());
1962                    }
1963                }
1964            }
1965        }
1966
1967        Ok(())
1968    }
1969
1970    /// Get all dependencies from both agents and snippets sections.
1971    ///
1972    /// Returns a vector of tuples containing dependency names and their
1973    /// specifications. This is useful for iteration over all dependencies
1974    /// without needing to handle agents and snippets separately.
1975    ///
1976    /// # Return Value
1977    ///
1978    /// Each tuple contains:
1979    /// - `&str`: The dependency name (key from TOML)
1980    /// - `&ResourceDependency`: The dependency specification
1981    ///
1982    /// # Examples
1983    ///
1984    /// ```rust,no_run
1985    /// use agpm_cli::manifest::Manifest;
1986    ///
1987    /// let manifest = Manifest::new();
1988    /// // ... add some dependencies
1989    ///
1990    /// for (name, dep) in manifest.all_dependencies() {
1991    ///     println!("Dependency: {} -> {}", name, dep.get_path());
1992    ///     if let Some(source) = dep.get_source() {
1993    ///         println!("  Source: {}", source);
1994    ///     }
1995    /// }
1996    /// ```
1997    ///
1998    /// # Order
1999    ///
2000    /// Dependencies are returned in the order they appear in the underlying
2001    /// `HashMaps` (agents first, then snippets, then commands), which means the order is not
2002    /// guaranteed to be stable across runs.
2003    /// Get dependencies for a specific resource type
2004    ///
2005    /// Returns the `HashMap` of dependencies for the specified resource type.
2006    /// Note: MCP servers return None as they use a different dependency type.
2007    pub const fn get_dependencies(
2008        &self,
2009        resource_type: crate::core::ResourceType,
2010    ) -> Option<&HashMap<String, ResourceDependency>> {
2011        use crate::core::ResourceType;
2012        match resource_type {
2013            ResourceType::Agent => Some(&self.agents),
2014            ResourceType::Snippet => Some(&self.snippets),
2015            ResourceType::Command => Some(&self.commands),
2016            ResourceType::Script => Some(&self.scripts),
2017            ResourceType::Hook => Some(&self.hooks),
2018            ResourceType::McpServer => Some(&self.mcp_servers),
2019        }
2020    }
2021
2022    /// Get the target directory for a specific resource type
2023    #[allow(deprecated)]
2024    pub fn get_target_dir(&self, resource_type: crate::core::ResourceType) -> &str {
2025        use crate::core::ResourceType;
2026        match resource_type {
2027            ResourceType::Agent => &self.target.agents,
2028            ResourceType::Snippet => &self.target.snippets,
2029            ResourceType::Command => &self.target.commands,
2030            ResourceType::Script => &self.target.scripts,
2031            ResourceType::Hook => &self.target.hooks,
2032            ResourceType::McpServer => &self.target.mcp_servers,
2033        }
2034    }
2035
2036    /// Get the tools configuration, returning default if not specified.
2037    ///
2038    /// This method provides access to the tool configurations which define
2039    /// where resources are installed for different tools (claude-code, opencode, agpm).
2040    ///
2041    /// Returns the configured tools or the default configuration if not specified.
2042    pub fn get_tools_config(&self) -> &ToolsConfig {
2043        self.tools.as_ref().unwrap_or_else(|| {
2044            // Return a static default - this is safe because ToolsConfig::default() is deterministic
2045            static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
2046            DEFAULT.get_or_init(ToolsConfig::default)
2047        })
2048    }
2049
2050    /// Get configuration for a specific tool type.
2051    ///
2052    /// Returns None if the tool is not configured.
2053    pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
2054        self.get_tools_config().types.get(tool)
2055    }
2056
2057    /// Get the installation path for a resource within a tool.
2058    ///
2059    /// Returns the full installation directory path by combining:
2060    /// - Tool's base directory (e.g., ".claude", ".opencode")
2061    /// - Resource type's subdirectory (e.g., "agents", "command")
2062    ///
2063    /// Returns None if:
2064    /// - The tool is not configured
2065    /// - The resource type is not supported by this tool
2066    /// - The resource has no configured path (special handling like MCP merge)
2067    pub fn get_artifact_resource_path(
2068        &self,
2069        tool: &str,
2070        resource_type: crate::core::ResourceType,
2071    ) -> Option<std::path::PathBuf> {
2072        let artifact_config = self.get_tool_config(tool)?;
2073        let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
2074
2075        resource_config.path.as_ref().map(|subdir| artifact_config.path.join(subdir))
2076    }
2077
2078    /// Check if a resource type is supported by a tool.
2079    ///
2080    /// Returns true if the tool has configuration for the given resource type.
2081    pub fn is_resource_supported(
2082        &self,
2083        tool: &str,
2084        resource_type: crate::core::ResourceType,
2085    ) -> bool {
2086        self.get_tool_config(tool)
2087            .and_then(|config| config.resources.get(resource_type.to_plural()))
2088            .is_some()
2089    }
2090
2091    /// Returns all dependencies from all resource types.
2092    ///
2093    /// This method collects dependencies from agents, snippets, commands,
2094    /// scripts, hooks, and MCP servers into a single vector. It's commonly used for:
2095    /// - Manifest validation across all dependency types
2096    /// - Dependency resolution operations
2097    /// - Generating reports of all configured dependencies
2098    /// - Bulk operations on all dependencies
2099    ///
2100    /// # Returns
2101    ///
2102    /// A vector of tuples containing the dependency name and its configuration.
2103    /// Each tuple is `(name, dependency)` where:
2104    /// - `name`: The dependency name as specified in the manifest
2105    /// - `dependency`: Reference to the [`ResourceDependency`] configuration
2106    ///
2107    /// The order follows the resource type order defined in [`crate::core::ResourceType::all()`].
2108    ///
2109    /// # Examples
2110    ///
2111    /// ```rust,no_run
2112    /// # use agpm_cli::manifest::Manifest;
2113    /// # let manifest = Manifest::new();
2114    /// for (name, dep) in manifest.all_dependencies() {
2115    ///     println!("Dependency: {} -> {}", name, dep.get_path());
2116    ///     if let Some(source) = dep.get_source() {
2117    ///         println!("  Source: {}", source);
2118    ///     }
2119    /// }
2120    /// ```
2121    #[must_use]
2122    pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
2123        let mut deps = Vec::new();
2124
2125        // Use ResourceType::all() to iterate through all resource types
2126        for resource_type in crate::core::ResourceType::all() {
2127            if let Some(type_deps) = self.get_dependencies(*resource_type) {
2128                for (name, dep) in type_deps {
2129                    deps.push((name.as_str(), dep));
2130                }
2131            }
2132        }
2133
2134        deps
2135    }
2136
2137    /// Get all dependencies including MCP servers.
2138    ///
2139    /// All resource types now use standard `ResourceDependency`, so no conversion needed.
2140    #[must_use]
2141    pub fn all_dependencies_with_mcp(
2142        &self,
2143    ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
2144        let mut deps = Vec::new();
2145
2146        // Use ResourceType::all() to iterate through all resource types
2147        for resource_type in crate::core::ResourceType::all() {
2148            if let Some(type_deps) = self.get_dependencies(*resource_type) {
2149                for (name, dep) in type_deps {
2150                    deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
2151                }
2152            }
2153        }
2154
2155        deps
2156    }
2157
2158    /// Check if a dependency with the given name exists in any section.
2159    ///
2160    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2161    /// with the specified name. This is useful for avoiding duplicate names
2162    /// across different resource types.
2163    ///
2164    /// # Examples
2165    ///
2166    /// ```rust,no_run
2167    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2168    ///
2169    /// let mut manifest = Manifest::new();
2170    /// manifest.add_dependency(
2171    ///     "helper".to_string(),
2172    ///     ResourceDependency::Simple("../helper.md".to_string()),
2173    ///     true  // is_agent
2174    /// );
2175    ///
2176    /// assert!(manifest.has_dependency("helper"));
2177    /// assert!(!manifest.has_dependency("nonexistent"));
2178    /// ```
2179    ///
2180    /// # Performance
2181    ///
2182    /// This method performs two `HashMap` lookups, so it's O(1) on average.
2183    #[must_use]
2184    pub fn has_dependency(&self, name: &str) -> bool {
2185        self.agents.contains_key(name)
2186            || self.snippets.contains_key(name)
2187            || self.commands.contains_key(name)
2188    }
2189
2190    /// Get a dependency by name from any section.
2191    ///
2192    /// Searches both the `[agents]` and `[snippets]` sections for a dependency
2193    /// with the specified name, returning the first match found. Agents are
2194    /// searched before snippets.
2195    ///
2196    /// # Examples
2197    ///
2198    /// ```rust,no_run
2199    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2200    ///
2201    /// let mut manifest = Manifest::new();
2202    /// manifest.add_dependency(
2203    ///     "helper".to_string(),
2204    ///     ResourceDependency::Simple("../helper.md".to_string()),
2205    ///     true  // is_agent
2206    /// );
2207    ///
2208    /// if let Some(dep) = manifest.get_dependency("helper") {
2209    ///     println!("Found dependency: {}", dep.get_path());
2210    /// }
2211    /// ```
2212    ///
2213    /// # Search Order
2214    ///
2215    /// Dependencies are searched in this order:
2216    /// 1. `[agents]` section
2217    /// 2. `[snippets]` section
2218    /// 3. `[commands]` section
2219    ///
2220    /// If the same name exists in multiple sections, the first match is returned.
2221    #[must_use]
2222    pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2223        self.agents
2224            .get(name)
2225            .or_else(|| self.snippets.get(name))
2226            .or_else(|| self.commands.get(name))
2227    }
2228
2229    /// Find a dependency by name from any section (alias for `get_dependency`).
2230    ///
2231    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
2232    /// with the specified name, returning the first match found.
2233    ///
2234    /// # Examples
2235    ///
2236    /// ```rust,no_run
2237    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2238    ///
2239    /// let mut manifest = Manifest::new();
2240    /// manifest.add_dependency(
2241    ///     "helper".to_string(),
2242    ///     ResourceDependency::Simple("../helper.md".to_string()),
2243    ///     true  // is_agent
2244    /// );
2245    ///
2246    /// if let Some(dep) = manifest.find_dependency("helper") {
2247    ///     println!("Found dependency: {}", dep.get_path());
2248    /// }
2249    /// ```
2250    pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
2251        self.get_dependency(name)
2252    }
2253
2254    /// Add or update a source repository in the `[sources]` section.
2255    ///
2256    /// Sources map convenient names to Git repository URLs. These names can
2257    /// then be referenced in dependency specifications to avoid repeating
2258    /// long URLs throughout the manifest.
2259    ///
2260    /// # Parameters
2261    ///
2262    /// - `name`: Short, convenient name for the source (e.g., "official", "community")
2263    /// - `url`: Git repository URL (HTTPS, SSH, or file:// protocol)
2264    ///
2265    /// # URL Validation
2266    ///
2267    /// The URL is not validated when added - validation occurs during
2268    /// [`Self::validate`]. Supported URL formats:
2269    /// - `https://github.com/owner/repo.git`
2270    /// - `git@github.com:owner/repo.git`
2271    /// - `file:///absolute/path/to/repo`
2272    /// - `file:///path/to/local/repo`
2273    ///
2274    /// # Examples
2275    ///
2276    /// ```rust,no_run
2277    /// use agpm_cli::manifest::Manifest;
2278    ///
2279    /// let mut manifest = Manifest::new();
2280    ///
2281    /// // Add public repository
2282    /// manifest.add_source(
2283    ///     "community".to_string(),
2284    ///     "https://github.com/claude-community/resources.git".to_string()
2285    /// );
2286    ///
2287    /// // Add private repository (SSH)
2288    /// manifest.add_source(
2289    ///     "private".to_string(),
2290    ///     "git@github.com:company/private-resources.git".to_string()
2291    /// );
2292    ///
2293    /// // Add local repository
2294    /// manifest.add_source(
2295    ///     "local".to_string(),
2296    ///     "file:///home/user/my-resources".to_string()
2297    /// );
2298    /// ```
2299    ///
2300    /// # Security Note
2301    ///
2302    /// Never include authentication tokens in the URL. Use SSH keys or
2303    /// configure authentication globally in `~/.agpm/config.toml`.
2304    pub fn add_source(&mut self, name: String, url: String) {
2305        self.sources.insert(name, url);
2306    }
2307
2308    /// Add or update a dependency in the appropriate section.
2309    ///
2310    /// Adds the dependency to either the `[agents]`, `[snippets]`, or `[commands]` section
2311    /// based on the `is_agent` parameter. If a dependency with the same name
2312    /// already exists in the target section, it will be replaced.
2313    ///
2314    /// **Note**: This method is deprecated in favor of [`Self::add_typed_dependency`]
2315    /// which provides explicit control over resource types.
2316    ///
2317    /// # Parameters
2318    ///
2319    /// - `name`: Unique name for the dependency within its section
2320    /// - `dep`: The dependency specification (Simple or Detailed)
2321    /// - `is_agent`: If true, adds to `[agents]`; if false, adds to `[snippets]`
2322    ///   (Note: Use [`Self::add_typed_dependency`] for commands and other resource types)
2323    ///
2324    /// # Validation
2325    ///
2326    /// The dependency is not validated when added - validation occurs during
2327    /// [`Self::validate`]. This allows for building manifests incrementally
2328    /// before all sources are defined.
2329    ///
2330    /// # Examples
2331    ///
2332    /// ```rust,no_run
2333    /// use agpm_cli::manifest::{Manifest, ResourceDependency, DetailedDependency};
2334    ///
2335    /// let mut manifest = Manifest::new();
2336    ///
2337    /// // Add local agent dependency
2338    /// manifest.add_dependency(
2339    ///     "helper".to_string(),
2340    ///     ResourceDependency::Simple("../local/helper.md".to_string()),
2341    ///     true  // is_agent = true
2342    /// );
2343    ///
2344    /// // Add remote snippet dependency
2345    /// manifest.add_dependency(
2346    ///     "utils".to_string(),
2347    ///     ResourceDependency::Detailed(Box::new(DetailedDependency {
2348    ///         source: Some("community".to_string()),
2349    ///         path: "snippets/utils.md".to_string(),
2350    ///         version: Some("v1.0.0".to_string()),
2351    ///         branch: None,
2352    ///         rev: None,
2353    ///         command: None,
2354    ///         args: None,
2355    ///         target: None,
2356    ///         filename: None,
2357    ///         dependencies: None,
2358    ///         tool: "claude-code".to_string(),
2359    ///     })),
2360    ///     false  // is_agent = false (snippet)
2361    /// );
2362    /// ```
2363    ///
2364    /// # Name Conflicts
2365    ///
2366    /// This method allows the same dependency name to exist in both the
2367    /// `[agents]` and `[snippets]` sections. However, some operations like
2368    /// [`Self::get_dependency`] will prefer agents over snippets when
2369    /// searching by name.
2370    pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
2371        if is_agent {
2372            self.agents.insert(name, dep);
2373        } else {
2374            self.snippets.insert(name, dep);
2375        }
2376    }
2377
2378    /// Add or update a dependency with specific resource type.
2379    ///
2380    /// This is the preferred method for adding dependencies as it explicitly
2381    /// specifies the resource type using the `ResourceType` enum.
2382    ///
2383    /// # Examples
2384    ///
2385    /// ```rust,no_run
2386    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2387    /// use agpm_cli::core::ResourceType;
2388    ///
2389    /// let mut manifest = Manifest::new();
2390    ///
2391    /// // Add command dependency
2392    /// manifest.add_typed_dependency(
2393    ///     "build".to_string(),
2394    ///     ResourceDependency::Simple("../commands/build.md".to_string()),
2395    ///     ResourceType::Command
2396    /// );
2397    /// ```
2398    pub fn add_typed_dependency(
2399        &mut self,
2400        name: String,
2401        dep: ResourceDependency,
2402        resource_type: crate::core::ResourceType,
2403    ) {
2404        match resource_type {
2405            crate::core::ResourceType::Agent => {
2406                self.agents.insert(name, dep);
2407            }
2408            crate::core::ResourceType::Snippet => {
2409                self.snippets.insert(name, dep);
2410            }
2411            crate::core::ResourceType::Command => {
2412                self.commands.insert(name, dep);
2413            }
2414            crate::core::ResourceType::McpServer => {
2415                // MCP servers don't use ResourceDependency, they have their own type
2416                // This method shouldn't be called for MCP servers
2417                panic!("Use add_mcp_server() for MCP server dependencies");
2418            }
2419            crate::core::ResourceType::Script => {
2420                self.scripts.insert(name, dep);
2421            }
2422            crate::core::ResourceType::Hook => {
2423                self.hooks.insert(name, dep);
2424            }
2425        }
2426    }
2427
2428    /// Add or update an MCP server configuration.
2429    ///
2430    /// MCP servers now use standard `ResourceDependency` format,
2431    /// pointing to JSON configuration files in source repositories.
2432    ///
2433    /// # Examples
2434    ///
2435    /// ```rust,no_run,ignore
2436    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
2437    ///
2438    /// let mut manifest = Manifest::new();
2439    ///
2440    /// // Add MCP server from source repository
2441    /// manifest.add_mcp_server(
2442    ///     "filesystem".to_string(),
2443    ///     ResourceDependency::Simple("../local/mcp-servers/filesystem.json".to_string())
2444    /// );
2445    /// ```
2446    pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
2447        self.mcp_servers.insert(name, dependency);
2448    }
2449}
2450
2451impl ResourceDependency {
2452    /// Get the source repository name if this is a remote dependency.
2453    ///
2454    /// Returns the source name for remote dependencies (those that reference
2455    /// a Git repository), or `None` for local dependencies (those that reference
2456    /// local filesystem paths).
2457    ///
2458    /// # Examples
2459    ///
2460    /// ```rust,no_run
2461    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2462    ///
2463    /// // Local dependency - no source
2464    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2465    /// assert!(local.get_source().is_none());
2466    ///
2467    /// // Remote dependency - has source
2468    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2469    ///     source: Some("official".to_string()),
2470    ///     path: "agents/tool.md".to_string(),
2471    ///     version: Some("v1.0.0".to_string()),
2472    ///     branch: None,
2473    ///     rev: None,
2474    ///     command: None,
2475    ///     args: None,
2476    ///     target: None,
2477    ///     filename: None,
2478    ///     dependencies: None,
2479    ///     tool: "claude-code".to_string(),
2480    /// }));
2481    /// assert_eq!(remote.get_source(), Some("official"));
2482    /// ```
2483    ///
2484    /// # Use Cases
2485    ///
2486    /// This method is commonly used to:
2487    /// - Determine if dependency resolution should use Git vs filesystem
2488    /// - Validate that referenced sources exist in the manifest
2489    /// - Filter dependencies by type (local vs remote)
2490    /// - Generate dependency graphs and reports
2491    #[must_use]
2492    pub fn get_source(&self) -> Option<&str> {
2493        match self {
2494            Self::Simple(_) => None,
2495            Self::Detailed(d) => d.source.as_deref(),
2496        }
2497    }
2498
2499    /// Get the custom target directory for this dependency.
2500    ///
2501    /// Returns the custom target directory if specified, or `None` if the
2502    /// dependency should use the default installation location for its resource type.
2503    ///
2504    /// # Examples
2505    ///
2506    /// ```rust,no_run
2507    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2508    ///
2509    /// // Dependency with custom target
2510    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
2511    ///     source: Some("official".to_string()),
2512    ///     path: "agents/tool.md".to_string(),
2513    ///     version: Some("v1.0.0".to_string()),
2514    ///     target: Some("custom/tools".to_string()),
2515    ///     branch: None,
2516    ///     rev: None,
2517    ///     command: None,
2518    ///     args: None,
2519    ///     filename: None,
2520    ///     dependencies: None,
2521    ///     tool: "claude-code".to_string(),
2522    /// }));
2523    /// assert_eq!(custom.get_target(), Some("custom/tools"));
2524    ///
2525    /// // Dependency without custom target
2526    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
2527    /// assert!(default.get_target().is_none());
2528    /// ```
2529    #[must_use]
2530    pub fn get_target(&self) -> Option<&str> {
2531        match self {
2532            Self::Simple(_) => None,
2533            Self::Detailed(d) => d.target.as_deref(),
2534        }
2535    }
2536
2537    /// Get the custom filename for this dependency.
2538    ///
2539    /// Returns the custom filename if specified, or `None` if the
2540    /// dependency should use the default filename based on the dependency key.
2541    ///
2542    /// # Examples
2543    ///
2544    /// ```rust,no_run
2545    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2546    ///
2547    /// // Dependency with custom filename
2548    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
2549    ///     source: Some("official".to_string()),
2550    ///     path: "agents/tool.md".to_string(),
2551    ///     version: Some("v1.0.0".to_string()),
2552    ///     filename: Some("ai-assistant.md".to_string()),
2553    ///     branch: None,
2554    ///     rev: None,
2555    ///     command: None,
2556    ///     args: None,
2557    ///     target: None,
2558    ///     dependencies: None,
2559    ///     tool: "claude-code".to_string(),
2560    /// }));
2561    /// assert_eq!(custom.get_filename(), Some("ai-assistant.md"));
2562    ///
2563    /// // Dependency without custom filename
2564    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
2565    /// assert!(default.get_filename().is_none());
2566    /// ```
2567    #[must_use]
2568    pub fn get_filename(&self) -> Option<&str> {
2569        match self {
2570            Self::Simple(_) => None,
2571            Self::Detailed(d) => d.filename.as_deref(),
2572        }
2573    }
2574
2575    /// Get the path to the resource file.
2576    ///
2577    /// Returns the path component of the dependency, which is interpreted
2578    /// differently based on whether this is a local or remote dependency:
2579    ///
2580    /// - **Local dependencies**: Filesystem path relative to the manifest directory
2581    /// - **Remote dependencies**: Path within the Git repository
2582    ///
2583    /// # Examples
2584    ///
2585    /// ```rust,no_run
2586    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2587    ///
2588    /// // Local dependency - filesystem path
2589    /// let local = ResourceDependency::Simple("../shared/helper.md".to_string());
2590    /// assert_eq!(local.get_path(), "../shared/helper.md");
2591    ///
2592    /// // Remote dependency - repository path
2593    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2594    ///     source: Some("official".to_string()),
2595    ///     path: "agents/code-reviewer.md".to_string(),
2596    ///     version: Some("v1.0.0".to_string()),
2597    ///     branch: None,
2598    ///     rev: None,
2599    ///     command: None,
2600    ///     args: None,
2601    ///     target: None,
2602    ///     filename: None,
2603    ///     dependencies: None,
2604    ///     tool: "claude-code".to_string(),
2605    /// }));
2606    /// assert_eq!(remote.get_path(), "agents/code-reviewer.md");
2607    /// ```
2608    ///
2609    /// # Path Resolution
2610    ///
2611    /// The returned path should be processed appropriately based on the dependency type:
2612    /// - Local paths may need resolution against the manifest directory
2613    /// - Remote paths are used directly within the cloned repository
2614    /// - All paths should use forward slashes (/) for cross-platform compatibility
2615    #[must_use]
2616    pub fn get_path(&self) -> &str {
2617        match self {
2618            Self::Simple(path) => path,
2619            Self::Detailed(d) => &d.path,
2620        }
2621    }
2622
2623    /// Check if this is a pattern-based dependency.
2624    ///
2625    /// Returns `true` if this dependency uses a glob pattern to match
2626    /// multiple resources, `false` if it specifies a single resource path.
2627    ///
2628    /// Patterns are detected by the presence of glob characters (`*`, `?`, `[`)
2629    /// in the path field.
2630    #[must_use]
2631    pub fn is_pattern(&self) -> bool {
2632        let path = self.get_path();
2633        path.contains('*') || path.contains('?') || path.contains('[')
2634    }
2635
2636    /// Get the version constraint for dependency resolution.
2637    ///
2638    /// Returns the version constraint that should be used when resolving this
2639    /// dependency from a Git repository. For local dependencies, always returns `None`.
2640    ///
2641    /// # Priority Rules
2642    ///
2643    /// If both `version` and `git` fields are present in a detailed dependency,
2644    /// the `git` field takes precedence:
2645    ///
2646    /// ```rust,no_run
2647    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2648    ///
2649    /// let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
2650    ///     source: Some("repo".to_string()),
2651    ///     path: "file.md".to_string(),
2652    ///     version: Some("v1.0.0".to_string()),  // This is ignored
2653    ///     branch: Some("develop".to_string()),   // This takes precedence over version
2654    ///     rev: None,
2655    ///     command: None,
2656    ///     args: None,
2657    ///     target: None,
2658    ///     filename: None,
2659    ///     dependencies: None,
2660    ///     tool: "claude-code".to_string(),
2661    /// }));
2662    ///
2663    /// assert_eq!(dep.get_version(), Some("develop"));
2664    /// ```
2665    ///
2666    /// # Examples
2667    ///
2668    /// ```rust,no_run
2669    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2670    ///
2671    /// // Local dependency - no version
2672    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2673    /// assert!(local.get_version().is_none());
2674    ///
2675    /// // Remote dependency with version
2676    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
2677    ///     source: Some("repo".to_string()),
2678    ///     path: "file.md".to_string(),
2679    ///     version: Some("v1.0.0".to_string()),
2680    ///     branch: None,
2681    ///     rev: None,
2682    ///     command: None,
2683    ///     args: None,
2684    ///     target: None,
2685    ///     filename: None,
2686    ///     dependencies: None,
2687    ///     tool: "claude-code".to_string(),
2688    /// }));
2689    /// assert_eq!(versioned.get_version(), Some("v1.0.0"));
2690    ///
2691    /// // Remote dependency with branch reference
2692    /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
2693    ///     source: Some("repo".to_string()),
2694    ///     path: "file.md".to_string(),
2695    ///     version: None,
2696    ///     branch: Some("main".to_string()),
2697    ///     rev: None,
2698    ///     command: None,
2699    ///     args: None,
2700    ///     target: None,
2701    ///     filename: None,
2702    ///     dependencies: None,
2703    ///     tool: "claude-code".to_string(),
2704    /// }));
2705    /// assert_eq!(branch_ref.get_version(), Some("main"));
2706    /// ```
2707    ///
2708    /// # Version Formats
2709    ///
2710    /// Supported version constraint formats include:
2711    /// - Semantic versions: `"v1.0.0"`, `"1.2.3"`
2712    /// - Semantic version ranges: `"^1.0.0"`, `"~2.1.0"`
2713    /// - Branch names: `"main"`, `"develop"`, `"latest"`, `"feature/new"`
2714    /// - Git tags: `"release-2023"`, `"stable"`
2715    /// - Commit SHAs: `"a1b2c3d4e5f6..."`
2716    #[must_use]
2717    pub fn get_version(&self) -> Option<&str> {
2718        match self {
2719            Self::Simple(_) => None,
2720            Self::Detailed(d) => {
2721                // Precedence: rev > branch > version
2722                d.rev.as_deref().or(d.branch.as_deref()).or(d.version.as_deref())
2723            }
2724        }
2725    }
2726
2727    /// Check if this is a local filesystem dependency.
2728    ///
2729    /// Returns `true` if this dependency refers to a local file (no Git source),
2730    /// or `false` if it's a remote dependency that will be resolved from a
2731    /// Git repository.
2732    ///
2733    /// This is a convenience method equivalent to `self.get_source().is_none()`.
2734    ///
2735    /// # Examples
2736    ///
2737    /// ```rust,no_run
2738    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2739    ///
2740    /// // Local dependency
2741    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
2742    /// assert!(local.is_local());
2743    ///
2744    /// // Remote dependency
2745    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
2746    ///     source: Some("official".to_string()),
2747    ///     path: "agents/tool.md".to_string(),
2748    ///     version: Some("v1.0.0".to_string()),
2749    ///     branch: None,
2750    ///     rev: None,
2751    ///     command: None,
2752    ///     args: None,
2753    ///     target: None,
2754    ///     filename: None,
2755    ///     dependencies: None,
2756    ///     tool: "claude-code".to_string(),
2757    /// }));
2758    /// assert!(!remote.is_local());
2759    ///
2760    /// // Local detailed dependency (no source specified)
2761    /// let local_detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
2762    ///     source: None,
2763    ///     path: "../shared/tool.md".to_string(),
2764    ///     version: None,
2765    ///     branch: None,
2766    ///     rev: None,
2767    ///     command: None,
2768    ///     args: None,
2769    ///     target: None,
2770    ///     filename: None,
2771    ///     dependencies: None,
2772    ///     tool: "claude-code".to_string(),
2773    /// }));
2774    /// assert!(local_detailed.is_local());
2775    /// ```
2776    ///
2777    /// # Use Cases
2778    ///
2779    /// This method is useful for:
2780    /// - Choosing between filesystem and Git resolution strategies
2781    /// - Validation logic (local deps can't have versions)
2782    /// - Installation planning (local deps don't need caching)
2783    /// - Progress reporting (different steps for local vs remote)
2784    #[must_use]
2785    pub fn is_local(&self) -> bool {
2786        self.get_source().is_none()
2787    }
2788
2789    /// Get the tool type for this dependency.
2790    ///
2791    /// Returns the target AI coding assistant tool for this resource. This determines where
2792    /// the resource will be installed (e.g., `.claude`, `.opencode`, `.agpm`).
2793    ///
2794    /// For simple dependencies, defaults to "claude-code".
2795    /// For detailed dependencies, returns the configured tool type.
2796    ///
2797    /// # Examples
2798    ///
2799    /// ```rust,no_run
2800    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
2801    ///
2802    /// // Simple dependency - defaults to "claude-code"
2803    /// let simple = ResourceDependency::Simple("../local/file.md".to_string());
2804    /// assert_eq!(simple.get_tool(), "claude-code");
2805    ///
2806    /// // Detailed dependency with explicit tool
2807    /// let detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
2808    ///     source: Some("official".to_string()),
2809    ///     path: "agents/tool.md".to_string(),
2810    ///     version: Some("v1.0.0".to_string()),
2811    ///     branch: None,
2812    ///     rev: None,
2813    ///     command: None,
2814    ///     args: None,
2815    ///     target: None,
2816    ///     filename: None,
2817    ///     dependencies: None,
2818    ///     tool: "opencode".to_string(),
2819    /// }));
2820    /// assert_eq!(detailed.get_tool(), "opencode");
2821    /// ```
2822    #[must_use]
2823    pub fn get_tool(&self) -> &str {
2824        match self {
2825            Self::Simple(_) => "claude-code", // Default for simple deps
2826            Self::Detailed(d) => &d.tool,
2827        }
2828    }
2829}
2830
2831impl Default for Manifest {
2832    fn default() -> Self {
2833        Self::new()
2834    }
2835}
2836
2837/// Expand environment variables and home directory in URLs.
2838///
2839/// This function handles URL expansion for source repository specifications,
2840/// supporting environment variable substitution and home directory expansion
2841/// while preserving standard Git URL formats.
2842///
2843/// # Processing Rules
2844///
2845/// 1. **Standard Git URLs** are returned unchanged:
2846///    - `http://` and `https://` URLs
2847///    - SSH URLs starting with `git@`
2848///    - File URLs starting with `file://`
2849///
2850/// 2. **Local paths** with expansion markers are processed:
2851///    - Environment variables: `${VAR_NAME}` or `$VAR_NAME`
2852///    - Home directory: `~` at the start of the path
2853///    - Relative paths: `./` or `../`
2854///    - Absolute paths: starting with `/`
2855///
2856/// 3. **Converted to file:// URLs**: Local paths are converted to file:// URLs
2857///    for consistent handling throughout the system.
2858///
2859/// # Examples
2860///
2861/// ```rust,no_run,ignore
2862/// # use agpm_cli::manifest::expand_url;
2863/// # fn example() -> anyhow::Result<()> {
2864/// // Standard URLs remain unchanged
2865/// assert_eq!(expand_url("https://github.com/user/repo.git")?,
2866///            "https://github.com/user/repo.git");
2867/// assert_eq!(expand_url("git@github.com:user/repo.git")?,
2868///            "git@github.com:user/repo.git");
2869///
2870/// // Environment variable expansion (if HOME=/home/user)
2871/// std::env::set_var("REPOS_DIR", "/home/user/repositories");
2872/// assert_eq!(expand_url("${REPOS_DIR}/my-repo")?,
2873///            "file:///home/user/repositories/my-repo");
2874///
2875/// // Home directory expansion  
2876/// assert_eq!(expand_url("~/git/my-repo")?,
2877///            "file:///home/user/git/my-repo");
2878/// # Ok(())
2879/// # }
2880/// ```
2881///
2882/// # Error Handling
2883///
2884/// - Returns the original URL if expansion fails
2885/// - Never panics, even with malformed input
2886/// - Allows validation to catch invalid URLs with proper error messages
2887///
2888/// # Security Considerations
2889///
2890/// - Environment variable expansion is limited to safe patterns
2891/// - Path traversal attempts in expanded paths are detected later in validation
2892/// - No execution of shell commands or arbitrary code
2893///
2894/// # Use Cases
2895///
2896/// This function enables flexible source specifications in manifests:
2897/// - CI/CD systems can inject repository URLs via environment variables
2898/// - Users can reference repositories relative to their home directory  
2899/// - Docker containers can use mounted paths with consistent URLs
2900/// - Development teams can share manifests without hardcoded paths
2901/// - Multi-platform projects can use consistent path references
2902///
2903/// # Thread Safety
2904///
2905/// This function is thread-safe and does not modify global state.
2906/// Environment variable access is read-only and atomic.
2907fn expand_url(url: &str) -> Result<String> {
2908    // If it looks like a standard protocol URL (http, https, git@, file://), don't expand
2909    if url.starts_with("http://")
2910        || url.starts_with("https://")
2911        || url.starts_with("git@")
2912        || url.starts_with("file://")
2913    {
2914        return Ok(url.to_string());
2915    }
2916
2917    // Only try to expand if it looks like a local path (contains path separators, starts with ~, or contains env vars)
2918    if url.contains('/') || url.contains('\\') || url.starts_with('~') || url.contains('$') {
2919        // For cases that look like local paths, try to expand as a local path and convert to file:// URL
2920        match crate::utils::platform::resolve_path(url) {
2921            Ok(expanded_path) => {
2922                // Convert to file:// URL
2923                let path_str = expanded_path.to_string_lossy();
2924                if expanded_path.is_absolute() {
2925                    Ok(format!("file://{path_str}"))
2926                } else {
2927                    Ok(format!(
2928                        "file://{}",
2929                        std::env::current_dir()?.join(expanded_path).to_string_lossy()
2930                    ))
2931                }
2932            }
2933            Err(_) => {
2934                // If path expansion fails, return the original URL
2935                // This allows the validation to catch the error with a proper message
2936                Ok(url.to_string())
2937            }
2938        }
2939    } else {
2940        // For strings that don't look like paths, return as-is to let validation catch the error
2941        Ok(url.to_string())
2942    }
2943}
2944
2945/// Find the manifest file by searching up the directory tree from the current directory.
2946///
2947/// This function implements the standard AGPM behavior of searching for a `agpm.toml`
2948/// file starting from the current working directory and walking up the directory
2949/// tree until one is found or the filesystem root is reached.
2950///
2951/// This behavior mirrors tools like Cargo, Git, and NPM that search for project
2952/// configuration files in parent directories.
2953///
2954/// # Search Algorithm
2955///
2956/// 1. Start from the current working directory
2957/// 2. Look for `agpm.toml` in the current directory
2958/// 3. If not found, move to the parent directory
2959/// 4. Repeat until found or reach the filesystem root
2960/// 5. Return error if no manifest is found
2961///
2962/// # Examples
2963///
2964/// ```rust,no_run
2965/// use agpm_cli::manifest::find_manifest;
2966///
2967/// // Find manifest from current directory
2968/// match find_manifest() {
2969///     Ok(path) => println!("Found manifest at: {}", path.display()),
2970///     Err(e) => println!("No manifest found: {}", e),
2971/// }
2972/// ```
2973///
2974/// # Directory Structure Example
2975///
2976/// ```text
2977/// /home/user/project/
2978/// ├── agpm.toml          ← Found here
2979/// └── subdir/
2980///     └── deep/
2981///         └── nested/     ← Search started here, walks up
2982/// ```
2983///
2984/// If called from `/home/user/project/subdir/deep/nested/`, this function
2985/// will find and return `/home/user/project/agpm.toml`.
2986///
2987/// # Error Conditions
2988///
2989/// - **No manifest found**: Searched to filesystem root without finding `agpm.toml`
2990/// - **Permission denied**: Cannot read current directory or traverse up
2991/// - **Filesystem corruption**: Cannot determine current working directory
2992///
2993/// # Use Cases
2994///
2995/// This function is typically called by CLI commands that need to locate the
2996/// project configuration, allowing users to run AGPM commands from any
2997/// subdirectory within their project.
2998pub fn find_manifest() -> Result<PathBuf> {
2999    let current = std::env::current_dir()
3000        .context("Cannot determine current working directory. This may indicate a permission issue or corrupted filesystem")?;
3001    find_manifest_from(current)
3002}
3003
3004/// Find the manifest file, using an explicit path if provided.
3005///
3006/// This function provides a consistent way to locate the manifest file,
3007/// either using an explicitly provided path or by searching from the
3008/// current directory.
3009///
3010/// # Arguments
3011///
3012/// * `explicit_path` - Optional path to a manifest file. If provided and the file exists,
3013///   this path is returned. If provided but the file doesn't exist, an error is returned.
3014///
3015/// # Returns
3016///
3017/// The path to the manifest file.
3018///
3019/// # Errors
3020///
3021/// Returns an error if:
3022/// - An explicit path is provided but the file doesn't exist
3023/// - No explicit path is provided and no manifest is found via search
3024///
3025/// # Examples
3026///
3027/// ```rust,no_run
3028/// use agpm_cli::manifest::find_manifest_with_optional;
3029/// use std::path::PathBuf;
3030///
3031/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3032/// // Use explicit path
3033/// let explicit = Some(PathBuf::from("/path/to/agpm.toml"));
3034/// let manifest = find_manifest_with_optional(explicit)?;
3035///
3036/// // Search from current directory
3037/// let manifest = find_manifest_with_optional(None)?;
3038/// # Ok(())
3039/// # }
3040/// ```
3041pub fn find_manifest_with_optional(explicit_path: Option<PathBuf>) -> Result<PathBuf> {
3042    match explicit_path {
3043        Some(path) => {
3044            if path.exists() {
3045                Ok(path)
3046            } else {
3047                Err(crate::core::AgpmError::ManifestNotFound.into())
3048            }
3049        }
3050        None => find_manifest(),
3051    }
3052}
3053
3054/// Find the manifest file by searching up from a specific starting directory.
3055///
3056/// This is the core manifest discovery function that implements the directory
3057/// traversal logic. It's used internally by [`find_manifest`] and can also
3058/// be used when you need to search from a specific directory rather than
3059/// the current working directory.
3060///
3061/// # Algorithm
3062///
3063/// 1. Check if `agpm.toml` exists in the starting directory
3064/// 2. If found, return the full path to the manifest
3065/// 3. If not found, move to the parent directory
3066/// 4. Repeat until manifest is found or filesystem root is reached
3067/// 5. Return [`crate::core::AgpmError::ManifestNotFound`] if no manifest is found
3068///
3069/// # Parameters
3070///
3071/// - `current`: The starting directory for the search (consumed by the function)
3072///
3073/// # Examples
3074///
3075/// ```rust,no_run
3076/// use agpm_cli::manifest::find_manifest_from;
3077/// use std::path::PathBuf;
3078///
3079/// // Search from a specific directory
3080/// let start_dir = PathBuf::from("/home/user/project/subdir");
3081/// match find_manifest_from(start_dir) {
3082///     Ok(manifest_path) => {
3083///         println!("Found manifest: {}", manifest_path.display());
3084///     }
3085///     Err(e) => {
3086///         println!("No manifest found: {}", e);
3087///     }
3088/// }
3089/// ```
3090///
3091/// # Performance Considerations
3092///
3093/// - Each directory check involves a filesystem stat operation
3094/// - Search depth is limited by filesystem hierarchy (typically < 20 levels)
3095/// - Function returns immediately upon finding the first manifest
3096/// - No filesystem locks are held during the search
3097///
3098/// # Cross-Platform Behavior
3099///
3100/// - Works correctly on Windows, macOS, and Linux
3101/// - Handles filesystem roots appropriately (`/` on Unix, `C:\` on Windows)
3102/// - Respects platform-specific path separators and conventions
3103/// - Works with network filesystems and mounted volumes
3104///
3105/// # Error Handling
3106///
3107/// Returns [`crate::core::AgpmError::ManifestNotFound`] wrapped in an [`anyhow::Error`]
3108/// if no manifest file is found after searching to the filesystem root.
3109pub fn find_manifest_from(mut current: PathBuf) -> Result<PathBuf> {
3110    loop {
3111        let manifest_path = current.join("agpm.toml");
3112        if manifest_path.exists() {
3113            return Ok(manifest_path);
3114        }
3115
3116        if !current.pop() {
3117            return Err(crate::core::AgpmError::ManifestNotFound.into());
3118        }
3119    }
3120}
3121
3122#[cfg(test)]
3123mod tests {
3124    use super::*;
3125    use tempfile::tempdir;
3126
3127    #[test]
3128    fn test_manifest_new() {
3129        let manifest = Manifest::new();
3130        assert!(manifest.sources.is_empty());
3131        assert!(manifest.agents.is_empty());
3132        assert!(manifest.snippets.is_empty());
3133        assert!(manifest.commands.is_empty());
3134        assert!(manifest.mcp_servers.is_empty());
3135    }
3136
3137    #[test]
3138    fn test_manifest_load_save() {
3139        let temp = tempdir().unwrap();
3140        let manifest_path = temp.path().join("agpm.toml");
3141
3142        let mut manifest = Manifest::new();
3143        manifest.add_source(
3144            "official".to_string(),
3145            "https://github.com/example-org/agpm-official.git".to_string(),
3146        );
3147        manifest.add_dependency(
3148            "test-agent".to_string(),
3149            ResourceDependency::Detailed(Box::new(DetailedDependency {
3150                source: Some("official".to_string()),
3151                path: "agents/test.md".to_string(),
3152                version: Some("v1.0.0".to_string()),
3153                branch: None,
3154                rev: None,
3155                command: None,
3156                args: None,
3157                target: None,
3158                filename: None,
3159                dependencies: None,
3160                tool: "claude-code".to_string(),
3161            })),
3162            true,
3163        );
3164
3165        manifest.save(&manifest_path).unwrap();
3166
3167        let loaded = Manifest::load(&manifest_path).unwrap();
3168        assert_eq!(loaded.sources.len(), 1);
3169        assert_eq!(loaded.agents.len(), 1);
3170        assert!(loaded.has_dependency("test-agent"));
3171    }
3172
3173    #[test]
3174    fn test_manifest_validation() {
3175        let mut manifest = Manifest::new();
3176
3177        // Add dependency without source - should be valid (local dependency)
3178        manifest.add_dependency(
3179            "local-agent".to_string(),
3180            ResourceDependency::Simple("../local/agent.md".to_string()),
3181            true,
3182        );
3183        assert!(manifest.validate().is_ok());
3184
3185        // Add dependency with undefined source - should fail validation
3186        manifest.add_dependency(
3187            "remote-agent".to_string(),
3188            ResourceDependency::Detailed(Box::new(DetailedDependency {
3189                source: Some("undefined".to_string()),
3190                path: "agent.md".to_string(),
3191                version: Some("v1.0.0".to_string()),
3192                branch: None,
3193                rev: None,
3194                command: None,
3195                args: None,
3196                target: None,
3197                filename: None,
3198                dependencies: None,
3199                tool: "claude-code".to_string(),
3200            })),
3201            true,
3202        );
3203        assert!(manifest.validate().is_err());
3204
3205        // Add the source - should now be valid
3206        manifest
3207            .add_source("undefined".to_string(), "https://github.com/test/repo.git".to_string());
3208        assert!(manifest.validate().is_ok());
3209    }
3210
3211    #[test]
3212    fn test_dependency_helpers() {
3213        let simple_dep = ResourceDependency::Simple("path/to/file.md".to_string());
3214        assert_eq!(simple_dep.get_path(), "path/to/file.md");
3215        assert!(simple_dep.get_source().is_none());
3216        assert!(simple_dep.get_version().is_none());
3217        assert!(simple_dep.is_local());
3218
3219        let detailed_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3220            source: Some("official".to_string()),
3221            path: "agents/test.md".to_string(),
3222            version: Some("v1.0.0".to_string()),
3223            branch: None,
3224            rev: None,
3225            command: None,
3226            args: None,
3227            target: None,
3228            filename: None,
3229            dependencies: None,
3230            tool: "claude-code".to_string(),
3231        }));
3232        assert_eq!(detailed_dep.get_path(), "agents/test.md");
3233        assert_eq!(detailed_dep.get_source(), Some("official"));
3234        assert_eq!(detailed_dep.get_version(), Some("v1.0.0"));
3235        assert!(!detailed_dep.is_local());
3236    }
3237
3238    #[test]
3239    fn test_all_dependencies() {
3240        let mut manifest = Manifest::new();
3241
3242        manifest.add_dependency(
3243            "agent1".to_string(),
3244            ResourceDependency::Simple("a1.md".to_string()),
3245            true,
3246        );
3247        manifest.add_dependency(
3248            "snippet1".to_string(),
3249            ResourceDependency::Simple("s1.md".to_string()),
3250            false,
3251        );
3252
3253        let all_deps = manifest.all_dependencies();
3254        assert_eq!(all_deps.len(), 2);
3255    }
3256
3257    #[test]
3258    fn test_source_url_validation() {
3259        let mut manifest = Manifest::new();
3260
3261        // Valid URLs
3262        manifest.add_source("http".to_string(), "http://github.com/test/repo.git".to_string());
3263        manifest.add_source("https".to_string(), "https://github.com/test/repo.git".to_string());
3264        manifest.add_source("ssh".to_string(), "git@github.com:test/repo.git".to_string());
3265        assert!(manifest.validate().is_ok());
3266
3267        // Invalid URL
3268        manifest.add_source("invalid".to_string(), "not-a-url".to_string());
3269        let result = manifest.validate();
3270        assert!(result.is_err());
3271        assert!(result.unwrap_err().to_string().contains("invalid URL"));
3272    }
3273
3274    #[test]
3275    fn test_manifest_commands() {
3276        let mut manifest = Manifest::new();
3277
3278        // Add a command dependency
3279        manifest.add_typed_dependency(
3280            "build-command".to_string(),
3281            ResourceDependency::Simple("commands/build.md".to_string()),
3282            crate::core::ResourceType::Command,
3283        );
3284
3285        assert!(manifest.commands.contains_key("build-command"));
3286        assert_eq!(manifest.commands.len(), 1);
3287        assert!(manifest.has_dependency("build-command"));
3288
3289        // Test get_dependency returns command
3290        let dep = manifest.get_dependency("build-command");
3291        assert!(dep.is_some());
3292        assert_eq!(dep.unwrap().get_path(), "commands/build.md");
3293    }
3294
3295    #[test]
3296    fn test_manifest_all_dependencies_with_commands() {
3297        let mut manifest = Manifest::new();
3298
3299        manifest.add_typed_dependency(
3300            "agent1".to_string(),
3301            ResourceDependency::Simple("a1.md".to_string()),
3302            crate::core::ResourceType::Agent,
3303        );
3304        manifest.add_typed_dependency(
3305            "snippet1".to_string(),
3306            ResourceDependency::Simple("s1.md".to_string()),
3307            crate::core::ResourceType::Snippet,
3308        );
3309        manifest.add_typed_dependency(
3310            "command1".to_string(),
3311            ResourceDependency::Simple("c1.md".to_string()),
3312            crate::core::ResourceType::Command,
3313        );
3314
3315        let all_deps = manifest.all_dependencies();
3316        assert_eq!(all_deps.len(), 3);
3317
3318        // Verify all three types are present
3319        assert!(manifest.agents.contains_key("agent1"));
3320        assert!(manifest.snippets.contains_key("snippet1"));
3321        assert!(manifest.commands.contains_key("command1"));
3322    }
3323
3324    #[test]
3325    fn test_manifest_save_load_commands() {
3326        let temp = tempdir().unwrap();
3327        let manifest_path = temp.path().join("agpm.toml");
3328
3329        let mut manifest = Manifest::new();
3330        manifest.add_source(
3331            "community".to_string(),
3332            "https://github.com/example/community.git".to_string(),
3333        );
3334        manifest.add_typed_dependency(
3335            "deploy".to_string(),
3336            ResourceDependency::Detailed(Box::new(DetailedDependency {
3337                source: Some("community".to_string()),
3338                path: "commands/deploy.md".to_string(),
3339                version: Some("v2.0.0".to_string()),
3340                branch: None,
3341                rev: None,
3342                command: None,
3343                args: None,
3344                target: None,
3345                filename: None,
3346                dependencies: None,
3347                tool: "claude-code".to_string(),
3348            })),
3349            crate::core::ResourceType::Command,
3350        );
3351
3352        // Save and reload
3353        manifest.save(&manifest_path).unwrap();
3354        let loaded = Manifest::load(&manifest_path).unwrap();
3355
3356        assert_eq!(loaded.commands.len(), 1);
3357        assert!(loaded.commands.contains_key("deploy"));
3358        assert!(loaded.has_dependency("deploy"));
3359
3360        let dep = loaded.get_dependency("deploy").unwrap();
3361        assert_eq!(dep.get_path(), "commands/deploy.md");
3362        assert_eq!(dep.get_version(), Some("v2.0.0"));
3363    }
3364
3365    #[test]
3366    #[allow(deprecated)]
3367    fn test_target_config_commands_dir() {
3368        let config = TargetConfig::default();
3369        assert_eq!(config.commands, ".claude/commands");
3370
3371        // Test custom config
3372        let mut manifest = Manifest::new();
3373        manifest.target.commands = "custom/commands".to_string();
3374        assert_eq!(manifest.target.commands, "custom/commands");
3375    }
3376
3377    #[test]
3378    fn test_mcp_servers() {
3379        let mut manifest = Manifest::new();
3380
3381        // Add an MCP server (now using standard ResourceDependency)
3382        manifest.add_mcp_server(
3383            "test-server".to_string(),
3384            ResourceDependency::Detailed(Box::new(DetailedDependency {
3385                source: Some("npm".to_string()),
3386                path: "mcp-servers/test-server.json".to_string(),
3387                version: Some("latest".to_string()),
3388                branch: None,
3389                rev: None,
3390                command: None,
3391                args: None,
3392                target: None,
3393                filename: None,
3394                dependencies: None,
3395                tool: "claude-code".to_string(),
3396            })),
3397        );
3398
3399        assert_eq!(manifest.mcp_servers.len(), 1);
3400        assert!(manifest.mcp_servers.contains_key("test-server"));
3401
3402        let server = &manifest.mcp_servers["test-server"];
3403        assert_eq!(server.get_source(), Some("npm"));
3404        assert_eq!(server.get_path(), "mcp-servers/test-server.json");
3405        assert_eq!(server.get_version(), Some("latest"));
3406    }
3407
3408    #[test]
3409    fn test_manifest_save_load_mcp_servers() {
3410        let temp = tempdir().unwrap();
3411        let manifest_path = temp.path().join("agpm.toml");
3412
3413        let mut manifest = Manifest::new();
3414        manifest.add_source("npm".to_string(), "https://registry.npmjs.org".to_string());
3415        manifest.add_mcp_server(
3416            "postgres".to_string(),
3417            ResourceDependency::Simple("../local/mcp-servers/postgres.json".to_string()),
3418        );
3419
3420        // Save and reload
3421        manifest.save(&manifest_path).unwrap();
3422        let loaded = Manifest::load(&manifest_path).unwrap();
3423
3424        assert_eq!(loaded.mcp_servers.len(), 1);
3425        assert!(loaded.mcp_servers.contains_key("postgres"));
3426
3427        let server = &loaded.mcp_servers["postgres"];
3428        assert_eq!(server.get_path(), "../local/mcp-servers/postgres.json");
3429    }
3430
3431    #[test]
3432    #[allow(deprecated)]
3433    fn test_target_config_mcp_servers_dir() {
3434        let config = TargetConfig::default();
3435        assert_eq!(config.mcp_servers, ".claude/agpm/mcp-servers");
3436
3437        // Test custom config
3438        let mut manifest = Manifest::new();
3439        manifest.target.mcp_servers = "custom/mcp".to_string();
3440        assert_eq!(manifest.target.mcp_servers, "custom/mcp");
3441    }
3442
3443    #[test]
3444    fn test_dependency_with_custom_target() {
3445        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3446            source: Some("official".to_string()),
3447            path: "agents/tool.md".to_string(),
3448            version: Some("v1.0.0".to_string()),
3449            branch: None,
3450            rev: None,
3451            command: None,
3452            args: None,
3453            target: Some("custom/tools".to_string()),
3454            filename: None,
3455            dependencies: None,
3456            tool: "claude-code".to_string(),
3457        }));
3458
3459        assert_eq!(dep.get_target(), Some("custom/tools"));
3460        assert_eq!(dep.get_source(), Some("official"));
3461        assert_eq!(dep.get_path(), "agents/tool.md");
3462    }
3463
3464    #[test]
3465    fn test_dependency_without_custom_target() {
3466        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3467            source: Some("official".to_string()),
3468            path: "agents/tool.md".to_string(),
3469            version: Some("v1.0.0".to_string()),
3470            branch: None,
3471            rev: None,
3472            command: None,
3473            args: None,
3474            target: None,
3475            filename: None,
3476            dependencies: None,
3477            tool: "claude-code".to_string(),
3478        }));
3479
3480        assert!(dep.get_target().is_none());
3481    }
3482
3483    #[test]
3484    fn test_simple_dependency_no_custom_target() {
3485        let dep = ResourceDependency::Simple("../local/file.md".to_string());
3486        assert!(dep.get_target().is_none());
3487    }
3488
3489    #[test]
3490    fn test_save_load_dependency_with_custom_target() {
3491        let temp = tempdir().unwrap();
3492        let manifest_path = temp.path().join("agpm.toml");
3493
3494        let mut manifest = Manifest::new();
3495        manifest.add_source(
3496            "official".to_string(),
3497            "https://github.com/example/official.git".to_string(),
3498        );
3499
3500        // Add dependency with custom target
3501        manifest.add_typed_dependency(
3502            "special-agent".to_string(),
3503            ResourceDependency::Detailed(Box::new(DetailedDependency {
3504                source: Some("official".to_string()),
3505                path: "agents/special.md".to_string(),
3506                version: Some("v1.0.0".to_string()),
3507                target: Some("integrations/ai".to_string()),
3508                branch: None,
3509                rev: None,
3510                command: None,
3511                args: None,
3512                filename: None,
3513                dependencies: None,
3514                tool: "claude-code".to_string(),
3515            })),
3516            crate::core::ResourceType::Agent,
3517        );
3518
3519        // Save and reload
3520        manifest.save(&manifest_path).unwrap();
3521        let loaded = Manifest::load(&manifest_path).unwrap();
3522
3523        assert_eq!(loaded.agents.len(), 1);
3524        assert!(loaded.agents.contains_key("special-agent"));
3525
3526        let dep = loaded.get_dependency("special-agent").unwrap();
3527        assert_eq!(dep.get_target(), Some("integrations/ai"));
3528        assert_eq!(dep.get_path(), "agents/special.md");
3529    }
3530
3531    #[test]
3532    fn test_dependency_with_custom_filename() {
3533        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3534            source: Some("official".to_string()),
3535            path: "agents/tool.md".to_string(),
3536            version: Some("v1.0.0".to_string()),
3537            branch: None,
3538            rev: None,
3539            command: None,
3540            args: None,
3541            target: None,
3542            filename: Some("ai-assistant.md".to_string()),
3543            dependencies: None,
3544            tool: "claude-code".to_string(),
3545        }));
3546
3547        assert_eq!(dep.get_filename(), Some("ai-assistant.md"));
3548        assert_eq!(dep.get_source(), Some("official"));
3549        assert_eq!(dep.get_path(), "agents/tool.md");
3550    }
3551
3552    #[test]
3553    fn test_dependency_without_custom_filename() {
3554        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3555            source: Some("official".to_string()),
3556            path: "agents/tool.md".to_string(),
3557            version: Some("v1.0.0".to_string()),
3558            branch: None,
3559            rev: None,
3560            command: None,
3561            args: None,
3562            target: None,
3563            filename: None,
3564            dependencies: None,
3565            tool: "claude-code".to_string(),
3566        }));
3567
3568        assert!(dep.get_filename().is_none());
3569    }
3570
3571    #[test]
3572    fn test_simple_dependency_no_custom_filename() {
3573        let dep = ResourceDependency::Simple("../local/file.md".to_string());
3574        assert!(dep.get_filename().is_none());
3575    }
3576
3577    #[test]
3578    fn test_save_load_dependency_with_custom_filename() {
3579        let temp = tempdir().unwrap();
3580        let manifest_path = temp.path().join("agpm.toml");
3581
3582        let mut manifest = Manifest::new();
3583        manifest.add_source(
3584            "official".to_string(),
3585            "https://github.com/example/official.git".to_string(),
3586        );
3587
3588        // Add dependency with custom filename
3589        manifest.add_typed_dependency(
3590            "my-agent".to_string(),
3591            ResourceDependency::Detailed(Box::new(DetailedDependency {
3592                source: Some("official".to_string()),
3593                path: "agents/complex-name.md".to_string(),
3594                version: Some("v1.0.0".to_string()),
3595                target: None,
3596                filename: Some("simple-name.txt".to_string()),
3597                branch: None,
3598                rev: None,
3599                command: None,
3600                args: None,
3601                dependencies: None,
3602                tool: "claude-code".to_string(),
3603            })),
3604            crate::core::ResourceType::Agent,
3605        );
3606
3607        // Save and reload
3608        manifest.save(&manifest_path).unwrap();
3609        let loaded = Manifest::load(&manifest_path).unwrap();
3610
3611        assert_eq!(loaded.agents.len(), 1);
3612        assert!(loaded.agents.contains_key("my-agent"));
3613
3614        let dep = loaded.get_dependency("my-agent").unwrap();
3615        assert_eq!(dep.get_filename(), Some("simple-name.txt"));
3616        assert_eq!(dep.get_path(), "agents/complex-name.md");
3617    }
3618
3619    #[test]
3620    fn test_pattern_dependency() {
3621        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3622            source: Some("repo".to_string()),
3623            path: "agents/**/*.md".to_string(),
3624            version: Some("v1.0.0".to_string()),
3625            branch: None,
3626            rev: None,
3627            command: None,
3628            args: None,
3629            target: None,
3630            filename: None,
3631            dependencies: None,
3632            tool: "claude-code".to_string(),
3633        }));
3634
3635        assert!(dep.is_pattern());
3636        assert_eq!(dep.get_path(), "agents/**/*.md");
3637        assert!(!dep.is_local());
3638    }
3639
3640    #[test]
3641    fn test_pattern_dependency_validation() {
3642        let mut manifest = Manifest::new();
3643        manifest
3644            .sources
3645            .insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
3646
3647        // Valid pattern dependency (uses glob characters in path)
3648        manifest.agents.insert(
3649            "ai-agents".to_string(),
3650            ResourceDependency::Detailed(Box::new(DetailedDependency {
3651                source: Some("repo".to_string()),
3652                path: "agents/ai/*.md".to_string(),
3653                version: Some("v1.0.0".to_string()),
3654                branch: None,
3655                rev: None,
3656                command: None,
3657                args: None,
3658                target: None,
3659                filename: None,
3660                dependencies: None,
3661                tool: "claude-code".to_string(),
3662            })),
3663        );
3664
3665        assert!(manifest.validate().is_ok());
3666
3667        // Valid: regular dependency (no glob characters)
3668        manifest.agents.insert(
3669            "regular".to_string(),
3670            ResourceDependency::Detailed(Box::new(DetailedDependency {
3671                source: Some("repo".to_string()),
3672                path: "agents/test.md".to_string(),
3673                version: Some("v1.0.0".to_string()),
3674                branch: None,
3675                rev: None,
3676                command: None,
3677                args: None,
3678                target: None,
3679                filename: None,
3680                dependencies: None,
3681                tool: "claude-code".to_string(),
3682            })),
3683        );
3684
3685        let result = manifest.validate();
3686        assert!(result.is_ok());
3687    }
3688
3689    #[test]
3690    fn test_pattern_dependency_with_path_traversal() {
3691        let mut manifest = Manifest::new();
3692        manifest
3693            .sources
3694            .insert("repo".to_string(), "https://github.com/example/repo.git".to_string());
3695
3696        // Pattern with path traversal (using path field now)
3697        manifest.agents.insert(
3698            "unsafe".to_string(),
3699            ResourceDependency::Detailed(Box::new(DetailedDependency {
3700                source: Some("repo".to_string()),
3701                path: "../../../etc/*.conf".to_string(),
3702                version: Some("v1.0.0".to_string()),
3703                branch: None,
3704                rev: None,
3705                command: None,
3706                args: None,
3707                target: None,
3708                filename: None,
3709                dependencies: None,
3710                tool: "claude-code".to_string(),
3711            })),
3712        );
3713
3714        let result = manifest.validate();
3715        assert!(result.is_err());
3716        assert!(result.unwrap_err().to_string().contains("Invalid pattern"));
3717    }
3718
3719    #[test]
3720    fn test_dependency_with_both_target_and_filename() {
3721        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
3722            source: Some("official".to_string()),
3723            path: "agents/tool.md".to_string(),
3724            version: Some("v1.0.0".to_string()),
3725            branch: None,
3726            rev: None,
3727            command: None,
3728            args: None,
3729            target: Some("tools/ai".to_string()),
3730            filename: Some("assistant.markdown".to_string()),
3731            dependencies: None,
3732            tool: "claude-code".to_string(),
3733        }));
3734
3735        assert_eq!(dep.get_target(), Some("tools/ai"));
3736        assert_eq!(dep.get_filename(), Some("assistant.markdown"));
3737    }
3738}
3739
3740#[cfg(test)]
3741mod tool_tests {
3742    use super::*;
3743
3744    #[test]
3745    fn test_detailed_dependency_tool_parsing() {
3746        let toml_str = r#"
3747[agents]
3748opencode-helper = { source = "test_repo", path = "agents/helper.md", version = "v1.0.0", tool = "opencode" }
3749"#;
3750
3751        let manifest: Manifest = toml::from_str(toml_str).unwrap();
3752
3753        let helper = manifest.agents.get("opencode-helper").unwrap();
3754
3755        match helper {
3756            ResourceDependency::Detailed(d) => {
3757                assert_eq!(d.tool, "opencode", "tool should be 'opencode'");
3758            }
3759            _ => panic!("Expected Detailed dependency"),
3760        }
3761    }
3762
3763    #[test]
3764    fn test_tool_name_validation() {
3765        // Test that artifact type names with path separators are rejected
3766        let toml_with_slash = r#"
3767[sources]
3768test = "https://example.com/repo.git"
3769
3770[tools."bad/name"]
3771path = ".claude"
3772
3773[tools."bad/name".resources.agents]
3774path = "agents"
3775
3776[agents]
3777test = { source = "test", path = "agents/test.md", type = "bad/name" }
3778"#;
3779
3780        let manifest: Result<Manifest, _> = toml::from_str(toml_with_slash);
3781        assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3782        let manifest = manifest.unwrap();
3783        let result = manifest.validate();
3784        assert!(result.is_err(), "Validation should fail for artifact type with forward slash");
3785        let err = result.unwrap_err();
3786        assert!(
3787            err.to_string().contains("cannot contain path separators"),
3788            "Error should mention path separators, got: {}",
3789            err
3790        );
3791
3792        // Test backslash
3793        let toml_with_backslash = r#"
3794[sources]
3795test = "https://example.com/repo.git"
3796
3797[tools."bad\\name"]
3798path = ".claude"
3799
3800[tools."bad\\name".resources.agents]
3801path = "agents"
3802
3803[agents]
3804test = { source = "test", path = "agents/test.md", type = "bad\\name" }
3805"#;
3806
3807        let manifest: Result<Manifest, _> = toml::from_str(toml_with_backslash);
3808        assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3809        let manifest = manifest.unwrap();
3810        let result = manifest.validate();
3811        assert!(result.is_err(), "Validation should fail for artifact type with backslash");
3812
3813        // Test path traversal (..)
3814        let toml_with_dotdot = r#"
3815[sources]
3816test = "https://example.com/repo.git"
3817
3818[tools."bad..name"]
3819path = ".claude"
3820
3821[tools."bad..name".resources.agents]
3822path = "agents"
3823
3824[agents]
3825test = { source = "test", path = "agents/test.md", type = "bad..name" }
3826"#;
3827
3828        let manifest: Result<Manifest, _> = toml::from_str(toml_with_dotdot);
3829        assert!(manifest.is_ok(), "Manifest should parse (validation happens in validate())");
3830        let manifest = manifest.unwrap();
3831        let result = manifest.validate();
3832        assert!(result.is_err(), "Validation should fail for artifact type with ..");
3833        let err = result.unwrap_err();
3834        assert!(
3835            err.to_string().contains("cannot contain '..'"),
3836            "Error should mention path traversal, got: {}",
3837            err
3838        );
3839
3840        // Test valid tool type names work
3841        let toml_valid = r#"
3842[sources]
3843test = "https://example.com/repo.git"
3844
3845[tools."my-custom-type"]
3846path = ".custom"
3847
3848[tools."my-custom-type".resources.agents]
3849path = "agents"
3850
3851[agents]
3852test = { source = "test", path = "agents/test.md", version = "v1.0.0", tool = "my-custom-type" }
3853"#;
3854
3855        let manifest: Result<Manifest, _> = toml::from_str(toml_valid);
3856        assert!(manifest.is_ok(), "Valid manifest should parse");
3857        let manifest = manifest.unwrap();
3858        let result = manifest.validate();
3859        assert!(result.is_ok(), "Valid artifact type name should pass validation");
3860    }
3861}