agpm_cli/manifest/
mod.rs

1//! Manifest file parsing and validation for AGPM projects.
2//!
3//! This module handles `agpm.toml` manifest files for declarative dependency management
4//! using a lockfile-based system similar to Cargo.
5//!
6//! # Features
7//!
8//! - Git-based source repositories with version constraints
9//! - Local and remote dependency resolution with transitive support
10//! - Multi-tool support (claude-code, opencode, agpm, custom)
11//! - MCP server and hook configuration management
12//! - TOML patches for customization without forking
13//! - Cross-platform path handling
14//!
15//! # Basic Structure
16//!
17//! ```toml
18//! [sources]
19//! official = "https://github.com/owner/agpm-resources.git"
20//!
21//! [agents]
22//! helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
23//!
24//! [snippets]
25//! utils = "../local/snippets/utils.md"
26//! ```
27//!
28//! # Dependency Formats
29//!
30//! - **Simple**: `helper = "../local/helper.md"` (local path only)
31//! - **Detailed**: `{ source = "name", path = "path/to/file.md", version = "v1.0.0" }`
32//! - **Custom target**: Add `target = "custom/dir"` (relative to tool directory)
33//! - **Custom filename**: Add `filename = "custom-name.md"`
34//!
35//! # Version Constraints
36//!
37//! Supports semantic versions (`v1.0.0`), `latest`, branches (`main`), commits, and tags.
38//!
39//! # Transitive Dependencies
40//!
41//! Resources can declare dependencies in YAML frontmatter (Markdown) or JSON fields:
42//!
43//! ```yaml
44//! dependencies:
45//!   agents:
46//!     - path: agents/helper.md
47//!       version: v1.0.0
48//! ```
49//!
50//! # Security
51//!
52//! **Never** include credentials in `agpm.toml`. Use `~/.agpm/config.toml` for authentication
53//! or SSH keys for `git@` URLs.
54//!
55//! # Integration
56//!
57//! Works with [`crate::resolver`] for dependency resolution, [`crate::lockfile`] for
58//! reproducible installations, and [`crate::git`] for source management.
59
60pub mod dependency_spec;
61pub mod helpers;
62pub mod patches;
63pub mod resource_dependency;
64pub mod tool_config;
65
66#[cfg(test)]
67mod manifest_flatten_tests;
68#[cfg(test)]
69mod manifest_hash_tests;
70#[cfg(test)]
71mod manifest_mutable_tests;
72#[cfg(test)]
73mod manifest_template_tests;
74#[cfg(test)]
75mod manifest_tests;
76#[cfg(test)]
77mod manifest_tool_tests;
78mod manifest_validation;
79#[cfg(test)]
80mod manifest_validation_tests;
81#[cfg(test)]
82mod resource_dependency_tests;
83#[cfg(test)]
84mod tool_config_tests;
85
86use crate::core::file_error::{FileOperation, FileResultExt};
87use anyhow::{Context, Result};
88use serde::{Deserialize, Serialize};
89use std::collections::HashMap;
90use std::path::{Path, PathBuf};
91
92pub use dependency_spec::{DependencyMetadata, DependencySpec};
93pub use helpers::{expand_url, find_manifest, find_manifest_from, find_manifest_with_optional};
94pub use patches::{ManifestPatches, PatchConflict, PatchData, PatchOrigin};
95pub use resource_dependency::{DetailedDependency, ResourceDependency};
96pub use tool_config::{ArtifactTypeConfig, ResourceConfig, ToolsConfig, WellKnownTool};
97
98/// The main manifest file structure representing a complete `agpm.toml` file.
99///
100/// This struct encapsulates all configuration for a AGPM project, including
101/// source repositories, installation targets, and resource dependencies.
102/// It provides the foundation for declarative dependency management similar
103/// to Cargo's `Cargo.toml`.
104///
105/// # Structure
106///
107/// - **Sources**: Named Git repositories that can be referenced by dependencies
108/// - **Target**: Installation directories for different resource types
109/// - **Agents**: AI agent dependencies (`.md` files with agent definitions)
110/// - **Snippets**: Code snippet dependencies (`.md` files with reusable code)
111/// - **Commands**: Claude Code command dependencies (`.md` files with slash commands)
112///
113/// # Serialization
114///
115/// The struct uses Serde for TOML serialization/deserialization with these behaviors:
116/// - Empty collections are omitted from serialized output for cleaner files
117/// - Default values are automatically applied for missing fields
118/// - Field names match TOML section names exactly
119///
120/// # Thread Safety
121///
122/// This struct is thread-safe and can be shared across async tasks safely.
123///
124/// # Use Case: AI Agent Context
125///
126/// When AI agents work on your codebase, they need context about:
127/// - Where to find coding standards and style guides
128/// - What conventions to follow (formatting, naming, patterns)
129/// - Where architecture and design docs are located
130/// - Project-specific requirements (testing, security, performance)
131///
132/// # Template Access
133///
134/// All variables are accessible in templates under the `agpm.project` namespace.
135/// The structure is completely user-defined.
136///
137/// **TOML Configuration:**
138/// ```toml
139/// # Top-level variables
140/// style_guide = "docs/STYLE_GUIDE.md"
141/// max_line_length = 100
142/// test_framework = "pytest"
143///
144/// # Nested sections (optional, just for organization)
145/// [project.paths]
146/// architecture = "docs/ARCHITECTURE.md"
147/// conventions = "docs/CONVENTIONS.md"
148///
149/// [project.standards]
150/// indent_style = "spaces"
151/// indent_size = 4
152/// ```
153///
154/// **Template Usage:**
155/// ```text
156/// Follow guidelines at: {{ agpm.project.style_guide }}
157/// Max line length: {{ agpm.project.max_line_length }}
158/// Architecture: {{ agpm.project.paths.architecture }}
159/// ```
160#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct ProjectConfig(toml::map::Map<String, toml::Value>);
162
163impl ProjectConfig {
164    /// Convert this ProjectConfig to a serde_json::Value for template rendering.
165    ///
166    /// This method handles conversion of TOML values to JSON values, which is necessary
167    /// for proper Tera template rendering.
168    ///
169    /// ```rust,no_run
170    /// use agpm_cli::manifest::ProjectConfig;
171    ///
172    /// let mut config_map = toml::map::Map::new();
173    /// config_map.insert("style_guide".to_string(), toml::Value::String("docs/STYLE.md".into()));
174    /// let config = ProjectConfig::from(config_map);
175    /// let json = config.to_json_value();
176    /// ```
177    pub fn to_json_value(&self) -> serde_json::Value {
178        toml_value_to_json(&toml::Value::Table(self.0.clone()))
179    }
180}
181
182impl From<toml::map::Map<String, toml::Value>> for ProjectConfig {
183    fn from(map: toml::map::Map<String, toml::Value>) -> Self {
184        Self(map)
185    }
186}
187
188/// Convert a toml::Value to serde_json::Value.
189pub(crate) fn toml_value_to_json(value: &toml::Value) -> serde_json::Value {
190    match value {
191        toml::Value::String(s) => serde_json::Value::String(s.clone()),
192        toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
193        toml::Value::Float(f) => serde_json::Number::from_f64(*f)
194            .map(serde_json::Value::Number)
195            .unwrap_or(serde_json::Value::Null),
196        toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
197        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
198        toml::Value::Array(arr) => {
199            serde_json::Value::Array(arr.iter().map(toml_value_to_json).collect())
200        }
201        toml::Value::Table(table) => {
202            // Sort keys to ensure deterministic JSON serialization
203            let mut keys: Vec<_> = table.keys().collect();
204            keys.sort();
205            let map: serde_json::Map<String, serde_json::Value> =
206                keys.into_iter().map(|k| (k.clone(), toml_value_to_json(&table[k]))).collect();
207            serde_json::Value::Object(map)
208        }
209    }
210}
211
212/// Convert JSON value to TOML value for template variable merging.
213///
214/// Handles JSON null as empty string since TOML lacks a null type.
215/// Used when merging template_vars (JSON) with project config (TOML).
216#[cfg(test)]
217pub(crate) fn json_value_to_toml(value: &serde_json::Value) -> toml::Value {
218    match value {
219        serde_json::Value::String(s) => toml::Value::String(s.clone()),
220        serde_json::Value::Number(n) => {
221            if let Some(i) = n.as_i64() {
222                toml::Value::Integer(i)
223            } else if let Some(f) = n.as_f64() {
224                toml::Value::Float(f)
225            } else {
226                // Fallback for numbers that don't fit i64 or f64
227                toml::Value::String(n.to_string())
228            }
229        }
230        serde_json::Value::Bool(b) => toml::Value::Boolean(*b),
231        serde_json::Value::Null => {
232            // TOML doesn't have a null type - represent as empty string
233            toml::Value::String(String::new())
234        }
235        serde_json::Value::Array(arr) => {
236            toml::Value::Array(arr.iter().map(json_value_to_toml).collect())
237        }
238        serde_json::Value::Object(obj) => {
239            let table: toml::map::Map<String, toml::Value> =
240                obj.iter().map(|(k, v)| (k.clone(), json_value_to_toml(v))).collect();
241            toml::Value::Table(table)
242        }
243    }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct Manifest {
248    /// Named source repositories mapped to their Git URLs.
249    ///
250    /// Keys are short, convenient names used in dependency specifications.
251    /// Values are Git repository URLs (HTTPS, SSH, or local file:// URLs).
252    ///
253    /// **Security Note**: Never include authentication tokens in these URLs.
254    /// Use SSH keys or configure authentication in the global config file.
255    ///
256    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
257    pub sources: HashMap<String, String>,
258
259    /// Tool type configurations for multi-tool support.
260    ///
261    /// Maps tool type names (claude-code, opencode, agpm, custom) to their
262    /// installation configurations. This replaces the old `target` field and
263    /// enables support for multiple tools and custom tool types.
264    ///
265    /// See [`ToolsConfig`] for details on configuration format.
266    #[serde(rename = "tools", skip_serializing_if = "Option::is_none")]
267    pub tools: Option<ToolsConfig>,
268
269    /// Agent dependencies mapping names to their specifications.
270    ///
271    /// Agents are typically AI model definitions, prompts, or behavioral
272    /// specifications stored as Markdown files. Each dependency can be
273    /// either local (filesystem path) or remote (from a Git source).
274    ///
275    /// See [`ResourceDependency`] for specification format details.
276    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
277    pub agents: HashMap<String, ResourceDependency>,
278
279    /// Snippet dependencies mapping names to their specifications.
280    ///
281    /// Snippets are typically reusable code templates, examples, or
282    /// documentation stored as Markdown files. They follow the same
283    /// dependency format as agents.
284    ///
285    /// See [`ResourceDependency`] for specification format details.
286    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
287    pub snippets: HashMap<String, ResourceDependency>,
288
289    /// Command dependencies mapping names to their specifications.
290    ///
291    /// Commands are Claude Code slash commands that provide custom functionality
292    /// and automation within the Claude Code interface. They follow the same
293    /// dependency format as agents and snippets.
294    ///
295    /// See [`ResourceDependency`] for specification format details.
296    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
297    pub commands: HashMap<String, ResourceDependency>,
298
299    /// MCP server configurations mapping names to their specifications.
300    ///
301    /// MCP servers provide integrations with external systems and services,
302    /// allowing Claude Code to connect to databases, APIs, and other tools.
303    /// MCP servers are JSON configuration files that get installed to
304    /// `.mcp.json` (no separate directory - configurations are merged into the JSON file).
305    ///
306    /// See [`ResourceDependency`] for specification format details.
307    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "mcp-servers")]
308    pub mcp_servers: HashMap<String, ResourceDependency>,
309
310    /// Script dependencies mapping names to their specifications.
311    ///
312    /// Scripts are executable files (.sh, .js, .py, etc.) that can be run by hooks
313    /// or independently. They are installed to `.claude/scripts/` and can be
314    /// referenced by hook configurations.
315    ///
316    /// See [`ResourceDependency`] for specification format details.
317    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
318    pub scripts: HashMap<String, ResourceDependency>,
319
320    /// Hook dependencies mapping names to their specifications.
321    ///
322    /// Hooks are JSON configuration files that define event-based automation
323    /// in Claude Code. They specify when to run scripts based on tool usage,
324    /// prompts, and other events. Hook configurations are merged into
325    /// `settings.local.json`.
326    ///
327    /// See [`ResourceDependency`] for specification format details.
328    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
329    pub hooks: HashMap<String, ResourceDependency>,
330
331    /// Skill dependencies mapping names to their specifications.
332    ///
333    /// Skills are directory-based resources (unlike single-file agents/snippets)
334    /// that contain a `SKILL.md` file plus supporting files (scripts, templates,
335    /// examples). They are installed to `.claude/skills/<name>/` as complete
336    /// directory structures.
337    ///
338    /// See [`ResourceDependency`] for specification format details.
339    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
340    pub skills: HashMap<String, ResourceDependency>,
341
342    /// Patches for overriding resource metadata.
343    ///
344    /// Patches allow overriding YAML frontmatter fields (like `model`) in
345    /// resources without forking upstream repositories. They are keyed by
346    /// resource type and manifest alias.
347    ///
348    #[serde(default, skip_serializing_if = "ManifestPatches::is_empty", rename = "patch")]
349    pub patches: ManifestPatches,
350
351    /// Project-level patches (from agpm.toml).
352    ///
353    /// This field is not serialized - it's populated during loading to track
354    /// which patches came from the project manifest vs private config.
355    #[serde(skip)]
356    pub project_patches: ManifestPatches,
357
358    /// Private patches (from agpm.private.toml).
359    ///
360    /// This field is not serialized - it's populated during loading to track
361    /// which patches came from private config. These are kept separate from
362    /// project patches to maintain deterministic lockfiles.
363    #[serde(skip)]
364    pub private_patches: ManifestPatches,
365
366    /// Default tool overrides for resource types.
367    ///
368    /// Allows users to override which tool is used by default when a dependency
369    /// doesn't explicitly specify a tool. Keys are resource type names (agents,
370    /// snippets, commands, scripts, hooks, mcp-servers), values are tool names
371    /// (claude-code, opencode, agpm, or custom tool names).
372    ///
373    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "default-tools")]
374    pub default_tools: HashMap<String, String>,
375
376    /// Project-specific template variables.
377    ///
378    /// Custom project configuration that can be referenced in resource templates
379    /// via Tera template syntax. This allows teams to define project-specific
380    /// values like paths, standards, and conventions that are then available
381    /// throughout all installed resources.
382    ///
383    /// Template access: `{{ agpm.project.name }}`, `{{ agpm.project.paths.style_guide }}`
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub project: Option<ProjectConfig>,
386
387    /// Directory containing the manifest file (for resolving relative paths).
388    ///
389    /// This field is populated when loading the manifest and is used to resolve
390    /// relative paths in dependencies, particularly for path-only dependencies
391    /// and their transitive dependencies.
392    ///
393    /// This field is not serialized and only exists at runtime.
394    #[serde(skip)]
395    pub manifest_dir: Option<std::path::PathBuf>,
396
397    /// Names of dependencies that came from agpm.private.toml.
398    ///
399    /// These dependencies will be installed to `{resource_path}/private/` subdirectory
400    /// and tracked in `agpm.private.lock` instead of `agpm.lock`.
401    ///
402    /// This field is populated by `load_with_private()` when merging private dependencies.
403    /// The HashSet contains `(resource_type, name)` pairs where resource_type is one of
404    /// "agents", "snippets", "commands", "scripts", "hooks", "mcp-servers".
405    #[serde(skip)]
406    pub private_dependency_names: std::collections::HashSet<(String, String)>,
407
408    /// Token count warning threshold (project-level override).
409    ///
410    /// Overrides the global `token_warning_threshold` for this project.
411    /// When set, resources exceeding this threshold will emit a warning during installation.
412    ///
413    /// Example:
414    /// ```toml
415    /// token_warning_threshold = 50000  # 50k tokens
416    /// ```
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub token_warning_threshold: Option<u64>,
419
420    /// Whether to enable gitignore validation.
421    ///
422    /// When true (default), AGPM validates that required .gitignore entries exist
423    /// and warns if they're missing. Set to false for private/personal setups
424    /// where you don't want gitignore management.
425    ///
426    /// Example:
427    /// ```toml
428    /// gitignore = false  # Disable gitignore validation
429    /// ```
430    #[serde(default = "default_gitignore")]
431    pub gitignore: bool,
432}
433
434/// Default value for gitignore field (true = enabled).
435fn default_gitignore() -> bool {
436    true
437}
438
439/// A resource dependency specification supporting multiple formats.
440///
441/// Dependencies can be specified in two main formats to balance simplicity
442/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
443/// deserialize the correct variant based on the TOML structure.
444///
445/// # Variants
446///
447/// ## Simple Dependencies
448///
449/// For local file dependencies, just specify the path directly:
450///
451/// # Remote dependency with version
452/// code-reviewer = { source = "official", path = "agents/reviewer.md", version = "v1.0.0" }
453///
454/// # Remote dependency with git reference
455/// experimental = { source = "community", path = "agents/new.md", git = "develop" }
456///
457/// # Local dependency with explicit path (equivalent to simple form)
458/// local-tool = { path = "../tools/agent.md" }
459/// # Validation Rules
460///
461/// - **Local dependencies** (no source): Cannot have version constraints
462/// - **Remote dependencies** (with source): Must have either `version` or `git` field
463/// - **Path field**: Required and cannot be empty
464/// - **Source field**: Must reference an existing source in the `[sources]` section
465///
466/// # Type Safety
467///
468/// The enum ensures type safety at compile time while providing runtime
469/// validation through the [`Manifest::validate`] method.
470///
471impl Manifest {
472    /// Create a new empty manifest with default configuration.
473    ///
474    /// The new manifest will have:
475    /// - No sources defined
476    /// - Default target directories (`.claude/agents` and `.agpm/snippets`)
477    /// - No dependencies
478    ///
479    /// This is typically used when programmatically building a manifest or
480    /// as a starting point for adding dependencies.
481    ///
482    ///
483    #[must_use]
484    #[allow(deprecated)]
485    pub fn new() -> Self {
486        Self {
487            sources: HashMap::new(),
488            tools: None,
489            agents: HashMap::new(),
490            snippets: HashMap::new(),
491            commands: HashMap::new(),
492            mcp_servers: HashMap::new(),
493            scripts: HashMap::new(),
494            hooks: HashMap::new(),
495            skills: HashMap::new(),
496            patches: ManifestPatches::new(),
497            project_patches: ManifestPatches::new(),
498            private_patches: ManifestPatches::new(),
499            default_tools: HashMap::new(),
500            project: None,
501            manifest_dir: None,
502            private_dependency_names: std::collections::HashSet::new(),
503            token_warning_threshold: None,
504            gitignore: true,
505        }
506    }
507
508    /// Load and parse a manifest from a TOML file.
509    ///
510    /// This method reads the specified file, parses it as TOML, deserializes
511    /// it into a [`Manifest`] struct, and validates the result. The entire
512    /// operation is atomic - either the manifest loads successfully or an
513    /// error is returned.
514    ///
515    /// # Validation
516    ///
517    /// After parsing, the manifest is automatically validated to ensure:
518    /// - All dependency sources reference valid entries in the `[sources]` section
519    /// - Required fields are present and non-empty
520    /// - Version constraints are properly specified for remote dependencies
521    /// - Source URLs use supported protocols
522    /// - No version conflicts exist between dependencies
523    ///
524    /// # Error Handling
525    ///
526    /// Returns detailed errors for common problems:
527    /// - **File I/O errors**: File not found, permission denied, etc.
528    /// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
529    /// - **Validation errors**: Logical inconsistencies in the manifest
530    /// - **Security errors**: Unsafe URL patterns or credential leakage
531    ///
532    /// All errors include contextual information and actionable suggestions.
533    ///
534    /// # Ok::<(), anyhow::Error>(())
535    /// # File Format
536    ///
537    /// Expects a valid TOML file following the AGPM manifest format.
538    /// See the module-level documentation for complete format specification.
539    pub fn load(path: &Path) -> Result<Self> {
540        let content = std::fs::read_to_string(path).with_file_context(
541            FileOperation::Read,
542            path,
543            "reading manifest file",
544            "manifest_module",
545        )?;
546
547        let mut manifest: Self = toml::from_str(&content)
548            .map_err(|e| crate::core::AgpmError::ManifestParseError {
549                file: path.display().to_string(),
550                reason: e.to_string(),
551            })
552            .with_context(|| {
553                format!(
554                    "Invalid TOML syntax in manifest file: {}\n\n\
555                    Common TOML syntax errors:\n\
556                    - Missing quotes around strings\n\
557                    - Unmatched brackets [ ] or braces {{ }}\n\
558                    - Invalid characters in keys or values\n\
559                    - Incorrect indentation or structure",
560                    path.display()
561                )
562            })?;
563
564        // Apply resource-type-specific defaults for tool
565        // Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
566        manifest.apply_tool_defaults();
567
568        // Store the manifest directory for resolving relative paths
569        manifest.manifest_dir = Some(
570            path.parent()
571                .ok_or_else(|| anyhow::anyhow!("Manifest path has no parent directory"))?
572                .to_path_buf(),
573        );
574
575        manifest.validate()?;
576
577        Ok(manifest)
578    }
579
580    /// Load manifest with private config merged.
581    ///
582    /// Loads the project manifest from `agpm.toml` and then attempts to load
583    /// `agpm.private.toml` from the same directory. If a private config exists:
584    /// - **Sources** are merged (private sources can use same names, which shadows project sources)
585    /// - **Dependencies** are merged (private deps tracked via `private_dependency_names`)
586    /// - **Patches** are merged (private patches take precedence)
587    ///
588    /// Any conflicts (same field defined in both files with different values) are
589    /// returned for informational purposes only. Private patches always override
590    /// project patches without raising an error.
591    ///
592    /// # Arguments
593    ///
594    /// * `path` - Path to the project manifest file (`agpm.toml`)
595    ///
596    /// # Returns
597    ///
598    /// A manifest with merged sources, dependencies, patches, and a list of any
599    /// patch conflicts detected (for informational/debugging purposes).
600    pub fn load_with_private(path: &Path) -> Result<(Self, Vec<PatchConflict>)> {
601        // Load the main project manifest
602        let mut manifest = Self::load(path)?;
603
604        // Store project patches before merging
605        manifest.project_patches = manifest.patches.clone();
606
607        // Try to load private config
608        let private_path = if let Some(parent) = path.parent() {
609            parent.join("agpm.private.toml")
610        } else {
611            PathBuf::from("agpm.private.toml")
612        };
613
614        if private_path.exists() {
615            let private_manifest = Self::load_private(&private_path)?;
616
617            // Merge sources (private can shadow project sources with same name)
618            for (name, url) in private_manifest.sources {
619                manifest.sources.insert(name, url);
620            }
621
622            // Track which dependencies are from private manifest and merge them
623            let mut private_names = std::collections::HashSet::new();
624
625            // Merge agents
626            for (name, dep) in private_manifest.agents {
627                private_names.insert(("agents".to_string(), name.clone()));
628                manifest.agents.insert(name, dep);
629            }
630
631            // Merge snippets
632            for (name, dep) in private_manifest.snippets {
633                private_names.insert(("snippets".to_string(), name.clone()));
634                manifest.snippets.insert(name, dep);
635            }
636
637            // Merge commands
638            for (name, dep) in private_manifest.commands {
639                private_names.insert(("commands".to_string(), name.clone()));
640                manifest.commands.insert(name, dep);
641            }
642
643            // Merge scripts
644            for (name, dep) in private_manifest.scripts {
645                private_names.insert(("scripts".to_string(), name.clone()));
646                manifest.scripts.insert(name, dep);
647            }
648
649            // Merge hooks
650            for (name, dep) in private_manifest.hooks {
651                private_names.insert(("hooks".to_string(), name.clone()));
652                manifest.hooks.insert(name, dep);
653            }
654
655            // Merge MCP servers
656            for (name, dep) in private_manifest.mcp_servers {
657                private_names.insert(("mcp-servers".to_string(), name.clone()));
658                manifest.mcp_servers.insert(name, dep);
659            }
660
661            manifest.private_dependency_names = private_names;
662
663            // Store private patches
664            manifest.private_patches = private_manifest.patches.clone();
665
666            // Merge patches (private takes precedence)
667            let (merged_patches, conflicts) =
668                manifest.patches.merge_with(&private_manifest.patches);
669            manifest.patches = merged_patches;
670
671            // Re-validate after merge to ensure private dependencies reference valid sources
672            manifest.validate().with_context(|| {
673                format!(
674                    "Validation failed after merging private manifest: {}",
675                    private_path.display()
676                )
677            })?;
678
679            Ok((manifest, conflicts))
680        } else {
681            // No private config, keep private_patches empty
682            manifest.private_patches = ManifestPatches::new();
683            Ok((manifest, Vec::new()))
684        }
685    }
686
687    /// Load a private manifest file.
688    ///
689    /// Private manifests can contain:
690    /// - **Sources**: Private Git repositories with authentication
691    /// - **Dependencies**: User-only resources (agents, snippets, commands, etc.)
692    /// - **Patches**: Customizations to project or private dependencies
693    ///
694    /// Private manifests **cannot** contain:
695    /// - **Tools**: Tool configuration must be in the main manifest
696    ///
697    /// # Arguments
698    ///
699    /// * `path` - Path to the private manifest file (`agpm.private.toml`)
700    ///
701    /// # Errors
702    ///
703    /// Returns an error if:
704    /// - The file cannot be read
705    /// - The TOML syntax is invalid
706    /// - The private config contains tools configuration
707    fn load_private(path: &Path) -> Result<Self> {
708        let content = std::fs::read_to_string(path).with_file_context(
709            FileOperation::Read,
710            path,
711            "reading private manifest file",
712            "manifest_module",
713        )?;
714
715        let mut manifest: Self = toml::from_str(&content)
716            .map_err(|e| crate::core::AgpmError::ManifestParseError {
717                file: path.display().to_string(),
718                reason: e.to_string(),
719            })
720            .with_context(|| {
721                format!(
722                    "Invalid TOML syntax in private manifest file: {}\n\n\
723                    Common TOML syntax errors:\n\
724                    - Missing quotes around strings\n\
725                    - Unmatched brackets [ ] or braces {{ }}\n\
726                    - Invalid characters in keys or values\n\
727                    - Incorrect indentation or structure",
728                    path.display()
729                )
730            })?;
731
732        // Validate that private config doesn't contain tools
733        if manifest.tools.is_some() {
734            anyhow::bail!(
735                "Private manifest file ({}) cannot contain [tools] section. \
736                 Tool configuration must be defined in the project manifest (agpm.toml).",
737                path.display()
738            );
739        }
740
741        // Apply resource-type-specific defaults for tool
742        manifest.apply_tool_defaults();
743
744        // Store the manifest directory for resolving relative paths
745        manifest.manifest_dir = Some(
746            path.parent()
747                .ok_or_else(|| anyhow::anyhow!("Private manifest path has no parent directory"))?
748                .to_path_buf(),
749        );
750
751        Ok(manifest)
752    }
753
754    /// Get the default tool for a resource type.
755    ///
756    /// Checks the `[default-tools]` configuration first, then falls back to
757    /// the built-in defaults:
758    /// - `snippets` → `"agpm"` (shared infrastructure)
759    /// - All other resource types → `"claude-code"`
760    ///
761    /// # Arguments
762    ///
763    /// * `resource_type` - The resource type to get the default tool for
764    ///
765    /// # Returns
766    ///
767    /// The default tool name as a string.
768    ///
769    #[must_use]
770    pub fn get_default_tool(&self, resource_type: crate::core::ResourceType) -> String {
771        // Get the resource name in plural form for consistency with TOML section names
772        // (agents, snippets, commands, etc.)
773        let resource_name = match resource_type {
774            crate::core::ResourceType::Agent => "agents",
775            crate::core::ResourceType::Snippet => "snippets",
776            crate::core::ResourceType::Command => "commands",
777            crate::core::ResourceType::Script => "scripts",
778            crate::core::ResourceType::Hook => "hooks",
779            crate::core::ResourceType::McpServer => "mcp-servers",
780            crate::core::ResourceType::Skill => "skills",
781        };
782
783        // Check if there's a configured override
784        if let Some(tool) = self.default_tools.get(resource_name) {
785            return tool.clone();
786        }
787
788        // Fall back to built-in defaults
789        resource_type.default_tool().to_string()
790    }
791
792    fn apply_tool_defaults(&mut self) {
793        // Apply resource-type-specific defaults only when tool is not explicitly specified
794        for resource_type in [
795            crate::core::ResourceType::Snippet,
796            crate::core::ResourceType::Agent,
797            crate::core::ResourceType::Command,
798            crate::core::ResourceType::Script,
799            crate::core::ResourceType::Hook,
800            crate::core::ResourceType::McpServer,
801        ] {
802            // Get the default tool before the mutable borrow to avoid borrow conflicts
803            let default_tool = self.get_default_tool(resource_type);
804
805            if let Some(deps) = self.get_dependencies_mut(resource_type) {
806                for dependency in deps.values_mut() {
807                    if let ResourceDependency::Detailed(details) = dependency {
808                        if details.tool.is_none() {
809                            details.tool = Some(default_tool.clone());
810                        }
811                    }
812                }
813            }
814        }
815    }
816
817    /// Save the manifest to a TOML file with pretty formatting.
818    ///
819    /// This method serializes the manifest to TOML format and writes it to the
820    /// specified file path. The output is pretty-printed for human readability
821    /// and follows TOML best practices.
822    ///
823    /// # Formatting
824    ///
825    /// The generated TOML file will:
826    /// - Use consistent indentation and spacing
827    /// - Omit empty sections for cleaner output
828    /// - Order sections logically (sources, target, agents, snippets)
829    /// - Include inline tables for detailed dependencies
830    ///
831    /// # Atomic Operation
832    ///
833    /// The save operation is atomic - the file is either completely written
834    /// or left unchanged. This prevents corruption if the operation fails
835    /// partway through.
836    ///
837    /// # Error Handling
838    ///
839    /// Returns detailed errors for common problems:
840    /// - **Permission denied**: Insufficient write permissions
841    /// - **Directory doesn't exist**: Parent directory missing  
842    /// - **Disk full**: Insufficient storage space
843    /// - **File locked**: Another process has the file open
844    ///
845    /// # use tempfile::tempdir;
846    /// # let temp_dir = tempdir()?;
847    /// # let manifest_path = temp_dir.path().join("agpm.toml");
848    /// manifest.save(&manifest_path)?;
849    /// # Ok::<(), anyhow::Error>(())
850    /// # Output Format
851    ///
852    /// The generated file will follow this structure:
853    ///
854    pub fn save(&self, path: &Path) -> Result<()> {
855        // Serialize to a document first so we can control formatting
856        let mut doc = toml_edit::ser::to_document(self)
857            .with_context(|| "Failed to serialize manifest data to TOML format")?;
858
859        // Convert top-level inline tables to regular tables (section headers)
860        // This keeps [sources], [agents], etc. as sections but nested values stay inline
861        for (_key, value) in doc.iter_mut() {
862            if let Some(inline_table) = value.as_inline_table() {
863                // Convert inline table to regular table
864                let table = inline_table.clone().into_table();
865                *value = toml_edit::Item::Table(table);
866            }
867        }
868
869        let content = doc.to_string();
870
871        std::fs::write(path, content).with_file_context(
872            FileOperation::Write,
873            path,
874            "writing manifest file",
875            "manifest_module",
876        )?;
877
878        Ok(())
879    }
880    /// Get all dependencies from both agents and snippets sections.
881    ///
882    /// Returns a vector of tuples containing dependency names and their
883    /// specifications. This is useful for iteration over all dependencies
884    /// without needing to handle agents and snippets separately.
885    ///
886    /// # Return Value
887    ///
888    /// Each tuple contains:
889    /// - `&str`: The dependency name (key from TOML)
890    /// - `&ResourceDependency`: The dependency specification
891    ///
892    /// # Order
893    ///
894    /// Dependencies are returned in the order they appear in the underlying
895    /// `HashMaps` (agents first, then snippets, then commands), which means the order is not
896    /// guaranteed to be stable across runs.
897    /// Get dependencies for a specific resource type
898    ///
899    /// Returns the `HashMap` of dependencies for the specified resource type.
900    /// Note: MCP servers return None as they use a different dependency type.
901    pub fn get_dependencies(
902        &self,
903        resource_type: crate::core::ResourceType,
904    ) -> Option<&HashMap<String, ResourceDependency>> {
905        use crate::core::ResourceType;
906        match resource_type {
907            ResourceType::Agent => Some(&self.agents),
908            ResourceType::Snippet => Some(&self.snippets),
909            ResourceType::Command => Some(&self.commands),
910            ResourceType::Script => Some(&self.scripts),
911            ResourceType::Hook => Some(&self.hooks),
912            ResourceType::McpServer => Some(&self.mcp_servers),
913            ResourceType::Skill => Some(&self.skills),
914        }
915    }
916
917    /// Get mutable dependencies for a specific resource type
918    ///
919    /// Returns a mutable reference to the `HashMap` of dependencies for the specified resource type.
920    #[must_use]
921    pub fn get_dependencies_mut(
922        &mut self,
923        resource_type: crate::core::ResourceType,
924    ) -> Option<&mut HashMap<String, ResourceDependency>> {
925        use crate::core::ResourceType;
926        match resource_type {
927            ResourceType::Agent => Some(&mut self.agents),
928            ResourceType::Snippet => Some(&mut self.snippets),
929            ResourceType::Command => Some(&mut self.commands),
930            ResourceType::Script => Some(&mut self.scripts),
931            ResourceType::Hook => Some(&mut self.hooks),
932            ResourceType::McpServer => Some(&mut self.mcp_servers),
933            ResourceType::Skill => Some(&mut self.skills),
934        }
935    }
936
937    /// Get the tools configuration, returning default if not specified.
938    ///
939    /// This method provides access to the tool configurations which define
940    /// where resources are installed for different tools (claude-code, opencode, agpm).
941    ///
942    /// Returns the configured tools or the default configuration if not specified.
943    pub fn get_tools_config(&self) -> &ToolsConfig {
944        self.tools.as_ref().unwrap_or_else(|| {
945            // Return a static default - this is safe because ToolsConfig::default() is deterministic
946            static DEFAULT: std::sync::OnceLock<ToolsConfig> = std::sync::OnceLock::new();
947            DEFAULT.get_or_init(ToolsConfig::default)
948        })
949    }
950
951    /// Get configuration for a specific tool type.
952    ///
953    /// Returns None if the tool is not configured.
954    pub fn get_tool_config(&self, tool: &str) -> Option<&ArtifactTypeConfig> {
955        self.get_tools_config().types.get(tool)
956    }
957
958    /// Get the installation path for a resource within a tool.
959    ///
960    /// Returns the full installation directory path by combining:
961    /// - Tool's base directory (e.g., ".claude", ".opencode")
962    /// - Resource type's subdirectory (e.g., "agents", "command")
963    ///
964    /// Returns None if:
965    /// - The tool is not configured
966    /// - The resource type is not supported by this tool
967    /// - The resource has no configured path (special handling like MCP merge)
968    pub fn get_artifact_resource_path(
969        &self,
970        tool: &str,
971        resource_type: crate::core::ResourceType,
972    ) -> Option<std::path::PathBuf> {
973        let artifact_config = self.get_tool_config(tool)?;
974        let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
975
976        resource_config.path.as_ref().map(|subdir| {
977            // Split on forward slashes and join with PathBuf for proper platform handling
978            // This ensures all separators are platform-native (backslashes on Windows)
979            let mut result = artifact_config.path.clone();
980            for component in subdir.split('/') {
981                result = result.join(component);
982            }
983            result
984        })
985    }
986
987    /// Get the merge target configuration file path for a resource type.
988    ///
989    /// Returns the path to the configuration file where resources of this type
990    /// should be merged (e.g., hooks, MCP servers). Returns None if the resource
991    /// type doesn't use merge targets or if the tool doesn't support this resource type.
992    ///
993    /// # Arguments
994    ///
995    /// * `tool` - The tool name (e.g., "claude-code", "opencode")
996    /// * `resource_type` - The resource type to look up
997    ///
998    /// # Returns
999    ///
1000    /// The merge target path if configured, otherwise None.
1001    ///
1002    pub fn get_merge_target(
1003        &self,
1004        tool: &str,
1005        resource_type: crate::core::ResourceType,
1006    ) -> Option<PathBuf> {
1007        let artifact_config = self.get_tool_config(tool)?;
1008        let resource_config = artifact_config.resources.get(resource_type.to_plural())?;
1009
1010        resource_config.merge_target.as_ref().map(PathBuf::from)
1011    }
1012
1013    /// Check if a resource type is supported by a tool.
1014    ///
1015    /// A resource type is considered supported if it has either:
1016    /// - A configured installation path (for file-based resources)
1017    /// - A configured merge target (for resources that merge into config files)
1018    ///
1019    /// Returns true if the tool has valid configuration for the given resource type.
1020    pub fn is_resource_supported(
1021        &self,
1022        tool: &str,
1023        resource_type: crate::core::ResourceType,
1024    ) -> bool {
1025        self.get_tool_config(tool)
1026            .and_then(|config| config.resources.get(resource_type.to_plural()))
1027            .map(|res_config| res_config.path.is_some() || res_config.merge_target.is_some())
1028            .unwrap_or(false)
1029    }
1030
1031    /// Returns all dependencies from all resource types.
1032    ///
1033    /// This method collects dependencies from agents, snippets, commands,
1034    /// scripts, hooks, and MCP servers into a single vector. It's commonly used for:
1035    /// - Manifest validation across all dependency types
1036    /// - Dependency resolution operations
1037    /// - Generating reports of all configured dependencies
1038    /// - Bulk operations on all dependencies
1039    ///
1040    /// # Returns
1041    ///
1042    /// A vector of tuples containing the dependency name and its configuration.
1043    /// Each tuple is `(name, dependency)` where:
1044    /// - `name`: The dependency name as specified in the manifest
1045    /// - `dependency`: Reference to the [`ResourceDependency`] configuration
1046    ///
1047    /// The order follows the resource type order defined in [`crate::core::ResourceType::all()`].
1048    ///
1049    /// # use agpm_cli::manifest::Manifest;
1050    /// # let manifest = Manifest::new();
1051    /// for (name, dep) in manifest.all_dependencies() {
1052    ///     println!("Dependency: {} -> {}", name, dep.get_path());
1053    ///     if let Some(source) = dep.get_source() {
1054    ///         println!("  Source: {}", source);
1055    ///     }
1056    /// }
1057    #[must_use]
1058    pub fn all_dependencies(&self) -> Vec<(&str, &ResourceDependency)> {
1059        let mut deps = Vec::new();
1060
1061        // Use ResourceType::all() to iterate through all resource types
1062        for resource_type in crate::core::ResourceType::all() {
1063            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1064                // CRITICAL: Sort for deterministic iteration order
1065                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1066                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1067
1068                for (name, dep) in sorted_deps {
1069                    deps.push((name.as_str(), dep));
1070                }
1071            }
1072        }
1073
1074        deps
1075    }
1076
1077    /// Get all dependencies including MCP servers.
1078    ///
1079    /// All resource types now use standard `ResourceDependency`, so no conversion needed.
1080    #[must_use]
1081    pub fn all_dependencies_with_mcp(
1082        &self,
1083    ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>)> {
1084        let mut deps = Vec::new();
1085
1086        // Use ResourceType::all() to iterate through all resource types
1087        for resource_type in crate::core::ResourceType::all() {
1088            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1089                // CRITICAL: Sort for deterministic iteration order
1090                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1091                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1092
1093                for (name, dep) in sorted_deps {
1094                    deps.push((name.as_str(), std::borrow::Cow::Borrowed(dep)));
1095                }
1096            }
1097        }
1098
1099        deps
1100    }
1101
1102    /// Get all dependencies with their resource types.
1103    ///
1104    /// Returns a vector of tuples containing the dependency name, dependency details,
1105    /// and the resource type. This preserves type information that is lost in
1106    /// `all_dependencies_with_mcp()`.
1107    ///
1108    /// This is used by the resolver to correctly type transitive dependencies without
1109    /// falling back to manifest section order lookups.
1110    ///
1111    /// Dependencies for disabled tools are automatically filtered out.
1112    pub fn all_dependencies_with_types(
1113        &self,
1114    ) -> Vec<(&str, std::borrow::Cow<'_, ResourceDependency>, crate::core::ResourceType)> {
1115        let mut deps = Vec::new();
1116
1117        // Use ResourceType::all() to iterate through all resource types
1118        for resource_type in crate::core::ResourceType::all() {
1119            if let Some(type_deps) = self.get_dependencies(*resource_type) {
1120                // CRITICAL: Sort dependencies for deterministic iteration order!
1121                // HashMap iteration is non-deterministic, so we must sort by name
1122                // to ensure consistent lockfile generation across runs.
1123                let mut sorted_deps: Vec<_> = type_deps.iter().collect();
1124                sorted_deps.sort_by_key(|(name, _)| name.as_str());
1125
1126                for (name, dep) in sorted_deps {
1127                    // Determine the tool for this dependency
1128                    let tool_string = dep
1129                        .get_tool()
1130                        .map(|s| s.to_string())
1131                        .unwrap_or_else(|| self.get_default_tool(*resource_type));
1132                    let tool = tool_string.as_str();
1133
1134                    // Check if the tool is enabled
1135                    if let Some(tool_config) = self.get_tools_config().types.get(tool) {
1136                        if !tool_config.enabled {
1137                            // Skip dependencies for disabled tools
1138                            tracing::debug!(
1139                                "Skipping dependency '{}' for disabled tool '{}'",
1140                                name,
1141                                tool
1142                            );
1143                            continue;
1144                        }
1145                    }
1146
1147                    // Ensure the tool is set on the dependency (apply default if not explicitly set)
1148                    let dep_with_tool = if dep.get_tool().is_none() {
1149                        tracing::debug!(
1150                            "Setting default tool '{}' for dependency '{}' (type: {:?})",
1151                            tool,
1152                            name,
1153                            resource_type
1154                        );
1155                        // Need to set the tool - create a modified copy
1156                        let mut dep_owned = dep.clone();
1157                        dep_owned.set_tool(Some(tool_string.clone()));
1158                        std::borrow::Cow::Owned(dep_owned)
1159                    } else {
1160                        std::borrow::Cow::Borrowed(dep)
1161                    };
1162
1163                    deps.push((name.as_str(), dep_with_tool, *resource_type));
1164                }
1165            }
1166        }
1167
1168        deps
1169    }
1170
1171    /// Check if a dependency with the given name exists in any section.
1172    ///
1173    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1174    /// with the specified name. This is useful for avoiding duplicate names
1175    /// across different resource types.
1176    ///
1177    /// # Performance
1178    ///
1179    /// This method performs up to three `HashMap` lookups, so it's O(1) on average.
1180    ///
1181    /// # Examples
1182    ///
1183    /// ```no_run
1184    /// # use agpm_cli::manifest::Manifest;
1185    /// let manifest = Manifest::new();
1186    /// if manifest.has_dependency("my-agent") {
1187    ///     println!("Dependency exists!");
1188    /// }
1189    /// ```
1190    #[must_use]
1191    pub fn has_dependency(&self, name: &str) -> bool {
1192        self.agents.contains_key(name)
1193            || self.snippets.contains_key(name)
1194            || self.commands.contains_key(name)
1195    }
1196
1197    /// Get a dependency by name from any section.
1198    ///
1199    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1200    /// with the specified name, returning the first match found.
1201    ///
1202    /// # Search Order
1203    ///
1204    /// Dependencies are searched in this order:
1205    /// 1. `[agents]` section
1206    /// 2. `[snippets]` section
1207    /// 3. `[commands]` section
1208    ///
1209    /// If the same name exists in multiple sections, the first match is returned.
1210    ///
1211    /// # Examples
1212    ///
1213    /// ```no_run
1214    /// # use agpm_cli::manifest::Manifest;
1215    /// let manifest = Manifest::new();
1216    /// if let Some(dep) = manifest.get_dependency("my-agent") {
1217    ///     println!("Found dependency!");
1218    /// }
1219    /// ```
1220    #[must_use]
1221    pub fn get_dependency(&self, name: &str) -> Option<&ResourceDependency> {
1222        self.agents
1223            .get(name)
1224            .or_else(|| self.snippets.get(name))
1225            .or_else(|| self.commands.get(name))
1226    }
1227
1228    /// Find a dependency by name from any section (alias for `get_dependency`).
1229    ///
1230    /// Searches the `[agents]`, `[snippets]`, and `[commands]` sections for a dependency
1231    /// with the specified name, returning the first match found.
1232    ///
1233    /// # Examples
1234    ///
1235    /// ```no_run
1236    /// # use agpm_cli::manifest::Manifest;
1237    /// let manifest = Manifest::new();
1238    /// if let Some(dep) = manifest.find_dependency("my-agent") {
1239    ///     println!("Found dependency!");
1240    /// }
1241    /// ```
1242    pub fn find_dependency(&self, name: &str) -> Option<&ResourceDependency> {
1243        self.get_dependency(name)
1244    }
1245
1246    /// Add or update a source repository in the `[sources]` section.
1247    ///
1248    /// Sources map convenient names to Git repository URLs. These names can
1249    /// then be referenced in dependency specifications to avoid repeating
1250    /// long URLs throughout the manifest.
1251    ///
1252    /// # Parameters
1253    ///
1254    /// - `name`: Short, convenient name for the source (e.g., "official", "community")
1255    /// - `url`: Git repository URL (HTTPS, SSH, or file:// protocol)
1256    ///
1257    /// # URL Validation
1258    ///
1259    /// The URL is not validated when added - validation occurs during
1260    /// [`Self::validate`]. Supported URL formats:
1261    /// - `https://github.com/owner/repo.git`
1262    /// - `git@github.com:owner/repo.git`
1263    /// - `file:///absolute/path/to/repo`
1264    /// - `file:///path/to/local/repo`
1265    ///
1266    /// # Security Note
1267    ///
1268    /// Never include authentication tokens in the URL. Use SSH keys or
1269    /// configure authentication globally in `~/.agpm/config.toml`.
1270    pub fn add_source(&mut self, name: String, url: String) {
1271        self.sources.insert(name, url);
1272    }
1273
1274    /// Add or update a dependency in the appropriate section.
1275    ///
1276    /// Adds the dependency to either the `[agents]` or `[snippets]` section
1277    /// based on the `is_agent` parameter. If a dependency with the same name
1278    /// already exists in the target section, it will be replaced.
1279    ///
1280    /// For commands and other resource types, use [`Self::add_typed_dependency`]
1281    /// which provides explicit control over resource types.
1282    ///
1283    /// # Parameters
1284    ///
1285    /// - `name`: Unique name for the dependency within its section
1286    /// - `dep`: The dependency specification (Simple or Detailed)
1287    /// - `is_agent`: If true, adds to `[agents]`; if false, adds to `[snippets]`
1288    ///
1289    /// # Validation
1290    ///
1291    /// The dependency is not validated when added - validation occurs during
1292    /// [`Self::validate`]. This allows for building manifests incrementally
1293    /// before all sources are defined.
1294    ///
1295    /// # Name Conflicts
1296    ///
1297    /// This method allows the same dependency name to exist in both the
1298    /// `[agents]` and `[snippets]` sections. However, some operations like
1299    /// [`Self::get_dependency`] will prefer agents over snippets when
1300    /// searching by name.
1301    pub fn add_dependency(&mut self, name: String, dep: ResourceDependency, is_agent: bool) {
1302        if is_agent {
1303            self.agents.insert(name, dep);
1304        } else {
1305            self.snippets.insert(name, dep);
1306        }
1307    }
1308
1309    /// Add or update a dependency with specific resource type.
1310    ///
1311    /// This is the preferred method for adding dependencies as it explicitly
1312    /// specifies the resource type using the `ResourceType` enum.
1313    ///
1314    ///
1315    /// ```rust,no_run
1316    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
1317    /// use agpm_cli::core::ResourceType;
1318    ///
1319    /// let mut manifest = Manifest::new();
1320    pub fn add_typed_dependency(
1321        &mut self,
1322        name: String,
1323        dep: ResourceDependency,
1324        resource_type: crate::core::ResourceType,
1325    ) {
1326        match resource_type {
1327            crate::core::ResourceType::Agent => {
1328                self.agents.insert(name, dep);
1329            }
1330            crate::core::ResourceType::Snippet => {
1331                self.snippets.insert(name, dep);
1332            }
1333            crate::core::ResourceType::Command => {
1334                self.commands.insert(name, dep);
1335            }
1336            crate::core::ResourceType::McpServer => {
1337                // MCP servers don't use ResourceDependency, they have their own type
1338                // This method shouldn't be called for MCP servers
1339                panic!("Use add_mcp_server() for MCP server dependencies");
1340            }
1341            crate::core::ResourceType::Script => {
1342                self.scripts.insert(name, dep);
1343            }
1344            crate::core::ResourceType::Hook => {
1345                self.hooks.insert(name, dep);
1346            }
1347            crate::core::ResourceType::Skill => {
1348                self.skills.insert(name, dep);
1349            }
1350        }
1351    }
1352
1353    /// Get resource dependencies by type.
1354    ///
1355    /// Returns a reference to the HashMap of dependencies for the specified resource type.
1356    /// This provides a unified interface for accessing different resource collections,
1357    /// similar to `LockFile::get_resources()`.
1358    ///
1359    ///
1360    /// ```rust,no_run
1361    /// use agpm_cli::manifest::Manifest;
1362    /// use agpm_cli::core::ResourceType;
1363    ///
1364    #[must_use]
1365    pub fn get_resources(
1366        &self,
1367        resource_type: &crate::core::ResourceType,
1368    ) -> &HashMap<String, ResourceDependency> {
1369        use crate::core::ResourceType;
1370        match resource_type {
1371            ResourceType::Agent => &self.agents,
1372            ResourceType::Snippet => &self.snippets,
1373            ResourceType::Command => &self.commands,
1374            ResourceType::Script => &self.scripts,
1375            ResourceType::Hook => &self.hooks,
1376            ResourceType::McpServer => &self.mcp_servers,
1377            ResourceType::Skill => &self.skills,
1378        }
1379    }
1380
1381    /// Get all resource dependencies across all types.
1382    ///
1383    /// Returns a vector of tuples containing the resource type, manifest key (name),
1384    /// and the dependency specification. This provides a unified way to iterate over
1385    /// all resources regardless of type.
1386    ///
1387    /// # Returns
1388    ///
1389    /// A vector of `(ResourceType, &str, &ResourceDependency)` tuples where:
1390    /// - The first element is the type of resource (Agent, Snippet, etc.)
1391    /// - The second element is the manifest key (the name in the TOML file)
1392    /// - The third element is the resource dependency specification
1393    ///
1394    #[must_use]
1395    pub fn all_resources(&self) -> Vec<(crate::core::ResourceType, &str, &ResourceDependency)> {
1396        use crate::core::ResourceType;
1397
1398        let mut resources = Vec::new();
1399
1400        for resource_type in ResourceType::all() {
1401            let type_resources = self.get_resources(resource_type);
1402            for (name, dep) in type_resources {
1403                resources.push((*resource_type, name.as_str(), dep));
1404            }
1405        }
1406
1407        resources
1408    }
1409
1410    /// Add or update an MCP server configuration.
1411    ///
1412    /// MCP servers now use standard `ResourceDependency` format,
1413    /// pointing to JSON configuration files in source repositories.
1414    ///
1415    ///
1416    /// ```rust,no_run,ignore
1417    /// use agpm_cli::manifest::{Manifest, ResourceDependency};
1418    ///
1419    /// let mut manifest = Manifest::new();
1420    ///
1421    pub fn add_mcp_server(&mut self, name: String, dependency: ResourceDependency) {
1422        self.mcp_servers.insert(name, dependency);
1423    }
1424
1425    /// Compute a hash of all manifest dependency specifications.
1426    ///
1427    /// This hash is used for fast path detection during subsequent installs.
1428    /// If the hash matches the one stored in the lockfile, and there are no
1429    /// mutable dependencies, we can skip resolution entirely.
1430    ///
1431    /// The hash includes:
1432    /// - All source definitions (name + URL)
1433    /// - All dependency specifications (serialized to canonical JSON)
1434    /// - Patch configurations
1435    /// - Tools configuration
1436    ///
1437    /// # Returns
1438    ///
1439    /// A SHA-256 hash string in "sha256:hex" format
1440    ///
1441    /// # Determinism
1442    ///
1443    /// Direct `serde_json::to_string()` on structs with HashMaps produces non-deterministic
1444    /// output because HashMap iteration order varies between runs. We use the two-step
1445    /// `to_value()` then `to_string()` approach because `serde_json::Map` (used internally
1446    /// by `Value`) is backed by `BTreeMap` when `preserve_order` is disabled (our default),
1447    /// which keeps keys sorted. See: <https://docs.rs/serde_json/latest/serde_json/struct.Map.html>
1448    ///
1449    /// # Stability
1450    ///
1451    /// The hash format is stable across AGPM versions within the same major version.
1452    /// Changes to hash computation require a lockfile format version bump and migration
1453    /// strategy to ensure existing lockfiles continue to work correctly.
1454    #[must_use]
1455    pub fn compute_dependency_hash(&self) -> String {
1456        use sha2::{Digest, Sha256};
1457
1458        let mut hasher = Sha256::new();
1459
1460        // Hash sources (sorted by name)
1461        let mut sources: Vec<_> = self.sources.iter().collect();
1462        sources.sort_by_key(|(k, _)| *k);
1463        for (name, url) in sources {
1464            hasher.update(b"source:");
1465            hasher.update(name.as_bytes());
1466            hasher.update(b"=");
1467            hasher.update(url.as_bytes());
1468            hasher.update(b"\n");
1469        }
1470
1471        // Hash each resource type (sorted by name, then by dependency fields)
1472        for resource_type in crate::core::ResourceType::all() {
1473            let resources = self.get_resources(resource_type);
1474            let mut sorted_resources: Vec<_> = resources.iter().collect();
1475            sorted_resources.sort_by_key(|(k, _)| *k);
1476
1477            for (name, dep) in sorted_resources {
1478                hasher.update(format!("{}:", resource_type).as_bytes());
1479                hasher.update(name.as_bytes());
1480                hasher.update(b"=");
1481                // Convert to Value first, then serialize - serde_json::Map keeps keys sorted
1482                // by default (without preserve_order feature), ensuring deterministic output
1483                match serde_json::to_value(dep).and_then(|v| serde_json::to_string(&v)) {
1484                    Ok(json) => hasher.update(json.as_bytes()),
1485                    Err(e) => {
1486                        tracing::warn!(
1487                            "Failed to serialize dependency '{}' for hashing: {}. Using name fallback.",
1488                            name,
1489                            e
1490                        );
1491                        // Include name in fallback to avoid hash collisions between different deps
1492                        hasher.update(b"<serialization_failed:");
1493                        hasher.update(name.as_bytes());
1494                        hasher.update(b">");
1495                    }
1496                }
1497                hasher.update(b"\n");
1498            }
1499        }
1500
1501        // Hash patches (they affect resolution)
1502        // ManifestPatches uses BTreeMap which is already deterministic
1503        if !self.patches.is_empty() {
1504            match serde_json::to_value(&self.patches).and_then(|v| serde_json::to_string(&v)) {
1505                Ok(json) => {
1506                    hasher.update(b"patches=");
1507                    hasher.update(json.as_bytes());
1508                    hasher.update(b"\n");
1509                }
1510                Err(e) => {
1511                    tracing::warn!(
1512                        "Failed to serialize patches for hashing: {}. Using fallback.",
1513                        e
1514                    );
1515                    hasher.update(b"patches=<serialization_failed>\n");
1516                }
1517            }
1518        }
1519
1520        // Hash tools configuration (affects installation paths)
1521        // Convert to Value first for deterministic HashMap serialization
1522        if let Some(tools) = &self.tools {
1523            match serde_json::to_value(tools).and_then(|v| serde_json::to_string(&v)) {
1524                Ok(json) => {
1525                    hasher.update(b"tools=");
1526                    hasher.update(json.as_bytes());
1527                    hasher.update(b"\n");
1528                }
1529                Err(e) => {
1530                    tracing::warn!("Failed to serialize tools for hashing: {}. Using fallback.", e);
1531                    hasher.update(b"tools=<serialization_failed>\n");
1532                }
1533            }
1534        }
1535
1536        let result = hasher.finalize();
1537        format!("sha256:{}", hex::encode(result))
1538    }
1539
1540    /// Check if any dependencies are mutable (local files or branches).
1541    ///
1542    /// Mutable dependencies can change between installs without manifest changes:
1543    /// - **Local sources**: Files on disk can change at any time
1544    /// - **Branch references**: Git branches can be updated
1545    ///
1546    /// When mutable dependencies exist, the fast path cannot be used because
1547    /// we must re-validate that the content hasn't changed.
1548    ///
1549    /// # Returns
1550    ///
1551    /// - `true` if any dependency uses a local source or branch reference
1552    /// - `false` if all dependencies use immutable references (semver tags, pinned SHAs)
1553    #[must_use]
1554    pub fn has_mutable_dependencies(&self) -> bool {
1555        self.all_resources().into_iter().any(|(_, _, dep)| dep.is_mutable())
1556    }
1557
1558    /// Check if a dependency is from the private manifest (agpm.private.toml).
1559    ///
1560    /// Private dependencies:
1561    /// - Install to `{resource_path}/private/` subdirectory
1562    /// - Are tracked in `agpm.private.lock` instead of `agpm.lock`
1563    /// - Don't affect team lockfile consistency
1564    ///
1565    /// # Arguments
1566    ///
1567    /// * `resource_type` - The resource type (accepts both singular "agent" and plural "agents")
1568    /// * `name` - The dependency name as specified in the manifest
1569    ///
1570    /// # Returns
1571    ///
1572    /// `true` if the dependency came from `agpm.private.toml`, `false` otherwise.
1573    #[must_use]
1574    pub fn is_private_dependency(&self, resource_type: &str, name: &str) -> bool {
1575        // Normalize resource type to plural form (as stored in private_dependency_names)
1576        let plural_type = match resource_type {
1577            "agent" => "agents",
1578            "snippet" => "snippets",
1579            "command" => "commands",
1580            "script" => "scripts",
1581            "hook" => "hooks",
1582            "mcp-server" => "mcp-servers",
1583            // Already plural or unknown
1584            other => other,
1585        };
1586        self.private_dependency_names.contains(&(plural_type.to_string(), name.to_string()))
1587    }
1588}
1589
1590impl Default for Manifest {
1591    fn default() -> Self {
1592        Self::new()
1593    }
1594}