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