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}