agpm_cli/resolver/
mod.rs

1//! Dependency resolution and conflict detection for AGPM.
2//!
3//! This module implements the core dependency resolution algorithm that transforms
4//! manifest dependencies into locked versions. It handles version constraint solving,
5//! conflict detection, redundancy analysis, transitive dependency resolution,
6//! parallel source synchronization, and relative path preservation during installation.
7//!
8//! # Architecture Overview
9//!
10//! The resolver operates using a **two-phase architecture** optimized for SHA-based worktree caching:
11//!
12//! ## Phase 1: Source Synchronization (`pre_sync_sources`)
13//! - **Purpose**: Perform all Git network operations upfront during "Syncing sources" phase
14//! - **Operations**: Clone/fetch repositories, update refs, populate cache
15//! - **Benefits**: Clear progress reporting, batch network operations, error isolation
16//! - **Result**: All required repositories cached locally for phase 2
17//!
18//! ## Phase 2: Version Resolution (`resolve` or `update`)
19//! - **Purpose**: Resolve versions to commit SHAs using cached repositories
20//! - **Operations**: Parse dependencies, resolve constraints, detect conflicts, create worktrees, preserve paths
21//! - **Benefits**: Fast local operations, no network I/O, deterministic behavior
22//! - **Result**: Locked dependencies ready for installation with preserved directory structure
23//!
24//! This two-phase approach replaces the previous three-phase model and provides better
25//! separation of concerns between network operations and dependency resolution logic.
26//!
27//! ## Algorithm Complexity
28//!
29//! - **Time**: O(n + s·log(t)) where:
30//!   - n = number of dependencies
31//!   - s = number of unique sources
32//!   - t = average number of tags/branches per source
33//! - **Space**: O(n + s) for dependency graph and source cache
34//!
35//! ## Parallel Processing
36//!
37//! The resolver leverages async/await for concurrent operations:
38//! - Sources are synchronized in parallel using [`tokio::spawn`]
39//! - Git operations are batched to minimize network roundtrips
40//! - Progress reporting provides real-time feedback on long-running operations
41//!
42//! # Resolution Process
43//!
44//! The two-phase dependency resolution follows these steps:
45//!
46//! ## Phase 1: Source Synchronization
47//! 1. **Dependency Collection**: Extract all dependencies from manifest
48//! 2. **Source Validation**: Verify all referenced sources exist and are accessible
49//! 3. **Repository Preparation**: Use [`version_resolver::VersionResolver`] to collect unique sources
50//! 4. **Source Synchronization**: Clone/update source repositories with single fetch per repository
51//! 5. **Cache Population**: Store bare repository paths for phase 2 operations
52//!
53//! ## Phase 2: Version Resolution & Installation
54//! 1. **Batch SHA Resolution**: Resolve all collected versions to commit SHAs using cached repositories
55//! 2. **SHA-based Worktree Creation**: Create worktrees keyed by commit SHA for maximum deduplication
56//! 3. **Conflict Detection**: Check for path conflicts and incompatible versions
57//! 4. **Redundancy Analysis**: Identify duplicate resources across sources
58//! 5. **Path Processing**: Preserve directory structure via [`extract_relative_path`] for resources from Git sources
59//! 6. **Resource Installation**: Copy resources to target locations with checksums
60//! 7. **Lockfile Generation**: Create deterministic lockfile entries with resolved SHAs and preserved paths
61//!
62//! This separation ensures all network operations complete in phase 1, while phase 2
63//! operates entirely on cached data for fast, deterministic resolution.
64//!
65//! ## Version Resolution Strategy
66//!
67//! Version constraints are resolved using the following precedence:
68//! 1. **Exact commits**: SHA hashes are used directly
69//! 2. **Tags**: Semantic version tags (e.g., `v1.2.3`) are preferred
70//! 3. **Branches**: Branch heads are resolved to current commits
71//! 4. **Latest**: Defaults to the default branch (usually `main` or `master`)
72//!
73//! # Conflict Detection
74//!
75//! The resolver detects several types of conflicts:
76//!
77//! ## Version Conflicts
78//! ```toml
79//! # Incompatible version constraints for the same resource
80//! [agents]
81//! app = { source = "community", path = "agents/helper.md", version = "v1.0.0" }
82//! tool = { source = "community", path = "agents/helper.md", version = "v2.0.0" }
83//! ```
84//!
85//! ## Path Conflicts
86//! ```toml
87//! # Different resources installing to the same location
88//! [agents]
89//! helper-v1 = { source = "old", path = "agents/helper.md" }
90//! helper-v2 = { source = "new", path = "agents/helper.md" }
91//! ```
92//!
93//! ## Source Conflicts
94//! When the same resource path exists in multiple sources with different content,
95//! the resolver uses source precedence (global config sources override local manifest sources).
96//!
97//! # Security Considerations
98//!
99//! The resolver implements several security measures:
100//!
101//! - **Input Validation**: All Git references are validated before checkout
102//! - **Path Sanitization**: Installation paths are validated to prevent directory traversal
103//! - **Credential Isolation**: Authentication tokens are never stored in manifest files
104//! - **Checksum Verification**: Resources are checksummed for integrity validation
105//!
106//! # Performance Optimizations
107//!
108//! - **SHA-based Worktree Caching**: Worktrees keyed by commit SHA maximize reuse across versions
109//! - **Batch Version Resolution**: All versions resolved to SHAs upfront via [`version_resolver::VersionResolver`]
110//! - **Single Fetch Per Repository**: Command-instance fetch caching eliminates redundant network operations
111//! - **Source Caching**: Git repositories are cached globally in `~/.agpm/cache/`
112//! - **Incremental Updates**: Only modified sources are re-synchronized
113//! - **Parallel Operations**: Source syncing and version resolution run concurrently
114//! - **Progress Batching**: UI updates are throttled to prevent performance impact
115//!
116//! # Error Handling
117//!
118//! The resolver provides detailed error context for common failure scenarios:
119//!
120//! - **Network Issues**: Graceful handling of Git clone/fetch failures
121//! - **Authentication**: Clear error messages for credential problems
122//! - **Version Mismatches**: Specific guidance for constraint resolution failures
123//! - **Path Issues**: Detailed information about file system conflicts
124//!
125//! # Example Usage
126//!
127//! ## Two-Phase Resolution Pattern
128//! ```rust,no_run
129//! use agpm_cli::resolver::DependencyResolver;
130//! use agpm_cli::manifest::Manifest;
131//! use agpm_cli::cache::Cache;
132//! use std::path::Path;
133//!
134//! # async fn example() -> anyhow::Result<()> {
135//! let manifest = Manifest::load(Path::new("agpm.toml"))?;
136//! let cache = Cache::new()?;
137//! let mut resolver = DependencyResolver::new_with_global(manifest.clone(), cache).await?;
138//!
139//! // Get all dependencies from manifest
140//! let deps: Vec<(String, agpm_cli::manifest::ResourceDependency)> = manifest
141//!     .all_dependencies()
142//!     .into_iter()
143//!     .map(|(name, dep)| (name.to_string(), dep.clone()))
144//!     .collect();
145//!
146//! // Phase 1: Sync all required sources (network operations)
147//! resolver.pre_sync_sources(&deps).await?;
148//!
149//! // Phase 2: Resolve dependencies using cached repositories (local operations)
150//! let lockfile = resolver.resolve().await?;
151//!
152//! println!("Resolved {} agents and {} snippets",
153//!          lockfile.agents.len(), lockfile.snippets.len());
154//! # Ok(())
155//! # }
156//! ```
157//!
158//! ## Update Pattern
159//! ```rust,no_run
160//! # use agpm_cli::resolver::DependencyResolver;
161//! # use agpm_cli::manifest::Manifest;
162//! # use agpm_cli::cache::Cache;
163//! # use agpm_cli::lockfile::LockFile;
164//! # use std::path::Path;
165//! # async fn update_example() -> anyhow::Result<()> {
166//! let manifest = Manifest::load(Path::new("agpm.toml"))?;
167//! let mut lockfile = LockFile::load(Path::new("agpm.lock"))?;
168//! let cache = Cache::new()?;
169//! let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
170//!
171//! // Get dependencies to update
172//! let deps: Vec<(String, agpm_cli::manifest::ResourceDependency)> = manifest
173//!     .all_dependencies()
174//!     .into_iter()
175//!     .map(|(name, dep)| (name.to_string(), dep.clone()))
176//!     .collect();
177//!
178//! // Phase 1: Sync sources for update
179//! resolver.pre_sync_sources(&deps).await?;
180//!
181//! // Phase 2: Update specific dependencies
182//! resolver.update(&mut lockfile, None).await?;
183//!
184//! lockfile.save(Path::new("agpm.lock"))?;
185//! # Ok(())
186//! # }
187//! ```
188//!
189//! ## Incremental Updates
190//! ```rust,no_run
191//! use agpm_cli::resolver::DependencyResolver;
192//! use agpm_cli::lockfile::LockFile;
193//! use agpm_cli::cache::Cache;
194//! use std::path::Path;
195//!
196//! # async fn update_example() -> anyhow::Result<()> {
197//! let existing = LockFile::load("agpm.lock".as_ref())?;
198//! let manifest = agpm_cli::manifest::Manifest::load("agpm.toml".as_ref())?;
199//! let cache = Cache::new()?;
200//! let mut resolver = DependencyResolver::new(manifest, cache)?;
201//!
202//! // Update specific dependencies only
203//! let deps_to_update = vec!["agent1".to_string(), "snippet2".to_string()];
204//! let deps_count = deps_to_update.len();
205//! let updated = resolver.update(&existing, Some(deps_to_update)).await?;
206//!
207//! println!("Updated {} dependencies", deps_count);
208//! # Ok(())
209//! # }
210//! ```
211
212pub mod dependency_graph;
213pub mod redundancy;
214pub mod version_resolution;
215pub mod version_resolver;
216
217use crate::cache::Cache;
218use crate::core::AgpmError;
219use crate::git::GitRepo;
220use crate::lockfile::{LockFile, LockedResource};
221use crate::manifest::{DependencySpec, DetailedDependency, Manifest, ResourceDependency};
222use crate::metadata::MetadataExtractor;
223use crate::source::SourceManager;
224use crate::version::conflict::ConflictDetector;
225use anyhow::{Context, Result};
226use std::collections::{HashMap, HashSet};
227use std::path::{Path, PathBuf};
228
229use self::dependency_graph::{DependencyGraph, DependencyNode};
230use self::version_resolver::VersionResolver;
231
232/// Type alias for resource lookup key: (`ResourceType`, name, source).
233///
234/// Used internally by the resolver to uniquely identify resources across different sources.
235/// This enables precise lookups when multiple resources share the same name but come from
236/// different sources (common with transitive dependencies).
237///
238/// # Components
239///
240/// * `ResourceType` - The type of resource (Agent, Snippet, Command, Script, Hook, `McpServer`)
241/// * `String` - The resource name as defined in the manifest
242/// * `Option<String>` - The source name (None for local resources without a source)
243///
244/// # Usage
245///
246/// This key is used in `HashMap<ResourceKey, ResourceInfo>` to build a complete map
247/// of all resources in the lockfile for efficient cross-source dependency resolution.
248type ResourceKey = (crate::core::ResourceType, String, Option<String>);
249
250/// Type alias for resource information: (source, version).
251///
252/// Stores the source and version information for a resolved resource. Used in conjunction
253/// with [`ResourceKey`] to enable efficient lookups during transitive dependency resolution.
254///
255/// # Components
256///
257/// * First `Option<String>` - The source name (None for local resources)
258/// * Second `Option<String>` - The version constraint (None for unpinned resources)
259///
260/// # Usage
261///
262/// Paired with [`ResourceKey`] in a `HashMap<ResourceKey, ResourceInfo>` to store
263/// resource metadata for cross-source dependency resolution. This allows the resolver
264/// to construct fully-qualified dependency references like `source:type/name:version`.
265type ResourceInfo = (Option<String>, Option<String>);
266
267/// Core dependency resolver that transforms manifest dependencies into lockfile entries.
268///
269/// The [`DependencyResolver`] is the main entry point for dependency resolution.
270/// It manages source repositories, resolves version constraints, detects conflicts,
271/// and generates deterministic lockfile entries using a centralized SHA-based
272/// resolution strategy for optimal performance.
273///
274/// # SHA-Based Resolution Workflow
275///
276/// Starting in v0.3.2, the resolver uses [`VersionResolver`] for centralized version
277/// resolution that minimizes Git operations and maximizes worktree reuse:
278/// 1. **Collection**: Gather all (source, version) pairs from dependencies
279/// 2. **Batch Resolution**: Resolve all versions to commit SHAs in parallel
280/// 3. **SHA-Based Worktrees**: Create worktrees keyed by commit SHA
281/// 4. **Deduplication**: Multiple refs to same SHA share one worktree
282///
283/// # Configuration
284///
285/// The resolver can be configured in several ways:
286/// - **Standard**: Uses manifest sources only via [`new()`]
287/// - **Global**: Includes global config sources via [`new_with_global()`]
288/// - **Custom Cache**: Uses custom cache directory via [`with_cache()`]
289///
290/// # Thread Safety
291///
292/// The resolver is not thread-safe due to its mutable state during resolution.
293/// Create separate instances for concurrent operations.
294///
295/// [`new()`]: DependencyResolver::new
296/// [`new_with_global()`]: DependencyResolver::new_with_global
297/// [`with_cache()`]: DependencyResolver::with_cache
298pub struct DependencyResolver {
299    manifest: Manifest,
300    /// Manages Git repository operations, source URL resolution, and authentication.
301    ///
302    /// The source manager handles:
303    /// - Mapping source names to Git repository URLs
304    /// - Git operations (clone, fetch, checkout) for dependency resolution
305    /// - Authentication token management for private repositories
306    /// - Source validation and configuration management
307    pub source_manager: SourceManager,
308    cache: Cache,
309    /// Cached per-(source, version) preparation results built during the
310    /// analysis stage so individual dependency resolution can reuse worktrees
311    /// without triggering additional sync operations.
312    prepared_versions: HashMap<String, PreparedSourceVersion>,
313    /// Centralized version resolver for efficient SHA-based dependency resolution.
314    ///
315    /// The `VersionResolver` handles the crucial first phase of dependency resolution
316    /// by batch-resolving all version specifications to commit SHAs before any worktree
317    /// operations. This strategy enables maximum worktree reuse and minimal Git operations.
318    ///
319    /// Used by [`prepare_remote_groups`] to resolve all dependencies upfront.
320    version_resolver: VersionResolver,
321    /// Dependency graph tracking which resources depend on which others.
322    ///
323    /// Maps from (`resource_type`, name, source) to a list of dependencies in the format
324    /// "`resource_type/name`". This is populated during transitive dependency
325    /// resolution and used to fill the dependencies field in `LockedResource` entries.
326    /// The source is included to prevent cross-source dependency contamination.
327    dependency_map: HashMap<(crate::core::ResourceType, String, Option<String>), Vec<String>>,
328    /// Resource type cache for transitive dependencies.
329    ///
330    /// Maps from (name, source) to `ResourceType` for transitive dependencies discovered
331    /// during resolution. This allows `get_resource_type()` to accurately determine the
332    /// type for transitive dependencies without defaulting to Snippet.
333    transitive_types: HashMap<(String, Option<String>), crate::core::ResourceType>,
334    /// Conflict detector for identifying version conflicts.
335    ///
336    /// Tracks version requirements across all dependencies (direct and transitive)
337    /// and detects incompatible version constraints before lockfile creation.
338    conflict_detector: ConflictDetector,
339}
340
341#[derive(Clone, Debug, Default)]
342struct PreparedSourceVersion {
343    worktree_path: PathBuf,
344    resolved_version: Option<String>,
345    resolved_commit: String,
346}
347
348#[derive(Clone, Debug)]
349#[allow(dead_code)]
350struct PreparedGroupDescriptor {
351    key: String,
352    source: String,
353    requested_version: Option<String>,
354    version_key: String,
355}
356
357impl DependencyResolver {
358    fn group_key(source: &str, version: &str) -> String {
359        format!("{source}::{version}")
360    }
361
362    /// Adds or updates a resource entry in the lockfile based on resource type.
363    ///
364    /// This helper method eliminates code duplication between the `resolve()` and `update()`
365    /// methods by centralizing lockfile entry management logic. It automatically determines
366    /// the resource type from the entry name and adds or updates the entry in the appropriate
367    /// collection within the lockfile.
368    ///
369    /// The method performs upsert behavior - if an entry with matching name and source
370    /// already exists in the appropriate collection, it will be updated (including version);
371    /// otherwise, a new entry is added. This allows version updates (e.g., v1.0 → v2.0)
372    /// to replace the existing entry rather than creating duplicates.
373    ///
374    /// # Arguments
375    ///
376    /// * `lockfile` - Mutable reference to the lockfile to modify
377    /// * `name` - The name of the resource entry (used to determine resource type)
378    /// * `entry` - The [`LockedResource`] entry to add or update
379    ///
380    /// # Resource Type Detection
381    ///
382    /// Resource type is determined by calling `get_resource_type()` on the entry name,
383    /// which maps to the following lockfile collections:
384    /// - `"agent"` → `lockfile.agents`
385    /// - `"snippet"` → `lockfile.snippets`
386    /// - `"command"` → `lockfile.commands`
387    /// - `"script"` → `lockfile.scripts`
388    /// - `"hook"` → `lockfile.hooks`
389    /// - `"mcp-server"` → `lockfile.mcp_servers`
390    ///
391    /// # Example
392    ///
393    /// ```ignore
394    /// # use agpm_cli::lockfile::{LockFile, LockedResource};
395    /// # use agpm_cli::core::ResourceType;
396    /// # use agpm_cli::resolver::DependencyResolver;
397    /// # let resolver = DependencyResolver::new();
398    /// let mut lockfile = LockFile::new();
399    /// let entry = LockedResource {
400    ///     name: "my-agent".to_string(),
401    ///     source: Some("github".to_string()),
402    ///     url: Some("https://github.com/org/repo.git".to_string()),
403    ///     path: "agents/my-agent.md".to_string(),
404    ///     version: Some("v1.0.0".to_string()),
405    ///     resolved_commit: Some("abc123def456...".to_string()),
406    ///     checksum: "sha256:a1b2c3d4...".to_string(),
407    ///     installed_at: ".claude/agents/my-agent.md".to_string(),
408    ///     dependencies: vec![],
409    ///     resource_type: ResourceType::Agent,
410    /// };
411    ///
412    /// // Automatically adds to agents collection based on resource type detection
413    /// resolver.add_or_update_lockfile_entry(&mut lockfile, "my-agent", entry);
414    /// assert_eq!(lockfile.agents.len(), 1);
415    ///
416    /// // Subsequent calls update the existing entry
417    /// let updated_entry = LockedResource {
418    ///     name: "my-agent".to_string(),
419    ///     version: Some("v1.1.0".to_string()),
420    ///     // ... other fields
421    /// #   source: Some("github".to_string()),
422    /// #   url: Some("https://github.com/org/repo.git".to_string()),
423    /// #   path: "agents/my-agent.md".to_string(),
424    /// #   resolved_commit: Some("def456789abc...".to_string()),
425    /// #   checksum: "sha256:b2c3d4e5...".to_string()),
426    /// #   installed_at: ".claude/agents/my-agent.md".to_string(),
427    ///     dependencies: vec![],
428    ///     resource_type: ResourceType::Agent,
429    /// };
430    /// resolver.add_or_update_lockfile_entry(&mut lockfile, "my-agent", updated_entry);
431    /// assert_eq!(lockfile.agents.len(), 1); // Still one entry, but updated
432    /// ```
433    fn add_or_update_lockfile_entry(
434        &self,
435        lockfile: &mut LockFile,
436        _name: &str,
437        entry: LockedResource,
438    ) {
439        // Get the appropriate resource collection based on the entry's type
440        let resources = lockfile.get_resources_mut(entry.resource_type);
441
442        // Find existing entry by name and source (excluding version to allow updates)
443        if let Some(existing) =
444            resources.iter_mut().find(|e| e.name == entry.name && e.source == entry.source)
445        {
446            *existing = entry;
447        } else {
448            resources.push(entry);
449        }
450    }
451
452    /// Detects conflicts where multiple dependencies resolve to the same installation path.
453    ///
454    /// This method validates that no two dependencies will overwrite each other during
455    /// installation. It builds a map of all resolved `installed_at` paths and checks for
456    /// collisions across all resource types.
457    ///
458    /// # Arguments
459    ///
460    /// * `lockfile` - The lockfile containing all resolved dependencies
461    ///
462    /// # Returns
463    ///
464    /// Returns `Ok(())` if no conflicts are detected, or an error describing the conflicts.
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if:
469    /// - Two or more dependencies resolve to the same `installed_at` path
470    /// - The error message lists all conflicting dependency names and the shared path
471    ///
472    /// # Examples
473    ///
474    /// ```rust,ignore
475    /// // This would cause a conflict error:
476    /// // [agents]
477    /// // v1 = { source = "repo", path = "agents/example.md", version = "v1.0" }
478    /// // v2 = { source = "repo", path = "agents/example.md", version = "v2.0" }
479    /// // Both resolve to .claude/agents/example.md
480    /// ```
481    fn detect_target_conflicts(&self, lockfile: &LockFile) -> Result<()> {
482        use std::collections::HashMap;
483
484        // Map of (installed_at path, resolved_commit) -> list of dependency names
485        // Two dependencies with the same path AND same commit are NOT a conflict
486        let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
487
488        // Collect all resources from lockfile
489        // Note: Hooks and MCP servers are excluded because they're configuration-only
490        // resources that are designed to share config files (.claude/settings.local.json
491        // for hooks, .mcp.json for MCP servers), not individual files that would conflict.
492        let all_resources: Vec<(&str, &LockedResource)> = lockfile
493            .agents
494            .iter()
495            .map(|r| (r.name.as_str(), r))
496            .chain(lockfile.snippets.iter().map(|r| (r.name.as_str(), r)))
497            .chain(lockfile.commands.iter().map(|r| (r.name.as_str(), r)))
498            .chain(lockfile.scripts.iter().map(|r| (r.name.as_str(), r)))
499            // Hooks and MCP servers intentionally omitted - they share config files
500            .collect();
501
502        // Build the path map with commit information
503        for (name, resource) in &all_resources {
504            let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
505            path_map.entry(key).or_default().push((*name).to_string());
506        }
507
508        // Now check for actual conflicts: same path but DIFFERENT commits
509        // Group by path only to find potential conflicts
510        let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
511        for (name, resource) in &all_resources {
512            path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
513        }
514
515        // Find conflicts (same path with different commits)
516        let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
517        for (path, resources) in path_only_map {
518            if resources.len() > 1 {
519                // Check if they have different commits
520                let commits: std::collections::HashSet<_> =
521                    resources.iter().map(|(_, r)| &r.resolved_commit).collect();
522
523                // Only a conflict if different commits
524                if commits.len() > 1 {
525                    let names: Vec<String> =
526                        resources.iter().map(|(n, _)| (*n).to_string()).collect();
527                    conflicts.push((path, names));
528                }
529            }
530        }
531
532        if !conflicts.is_empty() {
533            // Build a detailed error message
534            let mut error_msg = String::from(
535                "Target path conflicts detected:\n\n\
536                 Multiple dependencies resolve to the same installation path.\n\
537                 This would cause files to overwrite each other.\n\n",
538            );
539
540            for (path, names) in conflicts {
541                error_msg.push_str(&format!(
542                    "  Path: {}\n  Conflicts: {}\n\n",
543                    path,
544                    names.join(", ")
545                ));
546            }
547
548            error_msg.push_str(
549                "To resolve:\n\
550                 1. Use different dependency names for different versions\n\
551                 2. Use custom 'target' field to specify different installation paths\n\
552                 3. Ensure pattern dependencies don't overlap with single-file dependencies",
553            );
554
555            return Err(anyhow::anyhow!(error_msg));
556        }
557
558        Ok(())
559    }
560
561    /// Pre-syncs all sources needed for the given dependencies.
562    ///
563    /// This method implements the first phase of the two-phase resolution architecture.
564    /// It should be called during the "Syncing sources" phase to perform all Git
565    /// clone/fetch operations upfront, before actual dependency resolution.
566    ///
567    /// This separation provides several benefits:
568    /// - Clear separation of network operations from version resolution logic
569    /// - Better progress reporting with distinct phases
570    /// - Enables batch processing of Git operations for efficiency
571    /// - Allows the `resolve_all()` method to work purely with local cached data
572    ///
573    /// After calling this method, the internal [`VersionResolver`] will have all
574    /// necessary source repositories cached and ready for version-to-SHA resolution.
575    ///
576    /// # Arguments
577    ///
578    /// * `deps` - A slice of tuples containing dependency names and their definitions.
579    ///   Only dependencies with Git sources will be processed.
580    ///
581    /// # Example
582    ///
583    /// ```no_run
584    /// # use agpm_cli::resolver::DependencyResolver;
585    /// # use agpm_cli::manifest::{Manifest, ResourceDependency};
586    /// # use agpm_cli::cache::Cache;
587    /// # async fn example() -> anyhow::Result<()> {
588    /// let manifest = Manifest::new();
589    /// let cache = Cache::new()?;
590    /// let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
591    ///
592    /// // Get all dependencies from manifest
593    /// let deps: Vec<(String, ResourceDependency)> = manifest
594    ///     .all_dependencies()
595    ///     .into_iter()
596    ///     .map(|(name, dep)| (name.to_string(), dep.clone()))
597    ///     .collect();
598    ///
599    /// // Phase 1: Pre-sync all sources (performs Git clone/fetch operations)
600    /// resolver.pre_sync_sources(&deps).await?;
601    ///
602    /// // Phase 2: Now sources are ready for version resolution (no network I/O)
603    /// let resolved = resolver.resolve().await?;
604    /// # Ok(())
605    /// # }
606    /// ```
607    ///
608    /// # Two-Phase Resolution Pattern
609    ///
610    /// This method is part of AGPM's two-phase resolution architecture:
611    ///
612    /// 1. **Sync Phase** (`pre_sync_sources`): Clone/fetch all Git repositories
613    /// 2. **Resolution Phase** (`resolve` or `update`): Resolve versions to SHAs locally
614    ///
615    /// This pattern ensures all network operations happen upfront with clear progress
616    /// reporting, while version resolution can proceed quickly using cached data.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if:
621    /// - Source repository cloning or fetching fails
622    /// - Network connectivity issues occur
623    /// - Authentication fails for private repositories
624    /// - Source names in dependencies don't match configured sources
625    /// - Git operations fail due to repository corruption or disk space issues
626    pub async fn pre_sync_sources(&mut self, deps: &[(String, ResourceDependency)]) -> Result<()> {
627        // Clear and rebuild the version resolver entries
628        self.version_resolver.clear();
629
630        // Collect all unique (source, version) pairs
631        for (_, dep) in deps {
632            if let Some(source_name) = dep.get_source() {
633                let source_url =
634                    self.source_manager.get_source_url(source_name).ok_or_else(|| {
635                        AgpmError::SourceNotFound {
636                            name: source_name.to_string(),
637                        }
638                    })?;
639
640                let version = dep.get_version();
641
642                // Add to version resolver for batch syncing
643                self.version_resolver.add_version(source_name, &source_url, version);
644            }
645        }
646
647        // Pre-sync all sources (performs Git operations)
648        self.version_resolver.pre_sync_sources().await.context("Failed to sync sources")?;
649
650        Ok(())
651    }
652
653    /// Get available versions (tags) for a repository.
654    ///
655    /// Lists all tags from a Git repository, which typically represent available versions.
656    /// This is useful for checking what versions are available for updates.
657    ///
658    /// # Arguments
659    ///
660    /// * `repo_path` - Path to the Git repository (typically in the cache directory)
661    ///
662    /// # Returns
663    ///
664    /// A vector of version strings (tag names) available in the repository.
665    ///
666    /// # Examples
667    ///
668    /// ```rust,no_run,ignore
669    /// use agpm_cli::resolver::DependencyResolver;
670    /// use agpm_cli::cache::Cache;
671    ///
672    /// # async fn example() -> anyhow::Result<()> {
673    /// let cache = Cache::new()?;
674    /// let repo_path = cache.get_repo_path("community");
675    /// let resolver = DependencyResolver::new(manifest, cache, 10)?;
676    ///
677    /// let versions = resolver.get_available_versions(&repo_path).await?;
678    /// for version in versions {
679    ///     println!("Available: {}", version);
680    /// }
681    /// # Ok(())
682    /// # }
683    /// ```
684    pub async fn get_available_versions(&self, repo_path: &Path) -> Result<Vec<String>> {
685        let repo = GitRepo::new(repo_path);
686        repo.list_tags()
687            .await
688            .with_context(|| format!("Failed to list tags from repository at {repo_path:?}"))
689    }
690
691    /// Creates worktrees for all resolved SHAs in parallel.
692    ///
693    /// This helper method is part of AGPM's SHA-based worktree architecture, processing
694    /// all resolved versions from the [`VersionResolver`] and creating Git worktrees
695    /// for each unique commit SHA. It leverages async concurrency to create multiple
696    /// worktrees in parallel while maintaining proper error propagation.
697    ///
698    /// The method implements SHA-based deduplication - multiple versions (tags, branches)
699    /// that resolve to the same commit SHA will share a single worktree, maximizing
700    /// disk space efficiency and reducing clone operations.
701    ///
702    /// # Implementation Details
703    ///
704    /// 1. **Parallel Execution**: Uses `futures::future::join_all()` for concurrent worktree creation
705    /// 2. **SHA-based Keys**: Worktrees are keyed by commit SHA rather than version strings
706    /// 3. **Deduplication**: Multiple refs pointing to the same commit share one worktree
707    /// 4. **Error Handling**: Fails fast if any worktree creation fails
708    ///
709    /// # Returns
710    ///
711    /// Returns a [`HashMap`] mapping repository keys (format: `"source::version"`) to
712    /// [`PreparedSourceVersion`] structs containing:
713    /// - `worktree_path`: Absolute path to the created worktree directory
714    /// - `resolved_version`: The resolved Git reference (tag, branch, or SHA)
715    /// - `resolved_commit`: The final commit SHA for the worktree
716    ///
717    /// # Example Usage
718    ///
719    /// ```ignore
720    /// // This is called internally after version resolution
721    /// let prepared = resolver.create_worktrees_for_resolved_versions().await?;
722    ///
723    /// // Access worktree for a specific dependency
724    /// let key = DependencyResolver::group_key("my-source", "v1.0.0");
725    /// if let Some(prepared_version) = prepared.get(&key) {
726    ///     println!("Worktree at: {}", prepared_version.worktree_path.display());
727    ///     println!("Commit SHA: {}", prepared_version.resolved_commit);
728    /// }
729    /// ```
730    ///
731    /// # Errors
732    ///
733    /// Returns an error if:
734    /// - Source URL cannot be found for a resolved version (indicates configuration issue)
735    /// - Worktree creation fails for any SHA (disk space, permissions, Git errors)
736    /// - File system operations fail (I/O errors, permission denied)
737    /// - Git operations fail (corrupted repository, invalid SHA)
738    async fn create_worktrees_for_resolved_versions(
739        &self,
740    ) -> Result<HashMap<String, PreparedSourceVersion>> {
741        let resolved_full = self.version_resolver.get_all_resolved_full().clone();
742        let mut prepared_versions = HashMap::new();
743
744        // Build futures for parallel worktree creation
745        let mut futures = Vec::new();
746
747        for ((source_name, version_key), resolved_version) in resolved_full {
748            let sha = resolved_version.sha;
749            let resolved_ref = resolved_version.resolved_ref;
750            let repo_key = Self::group_key(&source_name, &version_key);
751            let cache_clone = self.cache.clone();
752            let source_name_clone = source_name.clone();
753
754            // Get the source URL for this source
755            let source_url_clone = self
756                .source_manager
757                .get_source_url(&source_name)
758                .ok_or_else(|| AgpmError::SourceNotFound {
759                    name: source_name.to_string(),
760                })?
761                .to_string();
762
763            let sha_clone = sha.clone();
764            let resolved_ref_clone = resolved_ref.clone();
765
766            let future = async move {
767                // Use SHA-based worktree creation
768                // The version resolver has already handled fetching and SHA resolution
769                let worktree_path = cache_clone
770                    .get_or_create_worktree_for_sha(
771                        &source_name_clone,
772                        &source_url_clone,
773                        &sha_clone,
774                        Some(&source_name_clone), // context for logging
775                    )
776                    .await?;
777
778                Ok::<_, anyhow::Error>((
779                    repo_key,
780                    PreparedSourceVersion {
781                        worktree_path,
782                        resolved_version: Some(resolved_ref_clone),
783                        resolved_commit: sha_clone,
784                    },
785                ))
786            };
787
788            futures.push(future);
789        }
790
791        // Execute all futures concurrently and collect results
792        let results = futures::future::join_all(futures).await;
793
794        // Process results and build the map
795        for result in results {
796            let (key, prepared) = result?;
797            prepared_versions.insert(key, prepared);
798        }
799
800        Ok(prepared_versions)
801    }
802
803    async fn prepare_remote_groups(&mut self, deps: &[(String, ResourceDependency)]) -> Result<()> {
804        self.prepared_versions.clear();
805
806        // Check if we need to rebuild version resolver entries
807        // This happens when prepare_remote_groups is called without pre_sync_sources
808        // (e.g., during tests or backward compatibility)
809        if !self.version_resolver.has_entries() {
810            // Rebuild entries for version resolution
811            for (_, dep) in deps {
812                if let Some(source_name) = dep.get_source() {
813                    let source_url =
814                        self.source_manager.get_source_url(source_name).ok_or_else(|| {
815                            AgpmError::SourceNotFound {
816                                name: source_name.to_string(),
817                            }
818                        })?;
819
820                    let version = dep.get_version();
821
822                    // Add to version resolver for batch resolution
823                    self.version_resolver.add_version(source_name, &source_url, version);
824                }
825            }
826
827            // If entries were rebuilt, we need to sync sources first
828            self.version_resolver.pre_sync_sources().await.context("Failed to sync sources")?;
829        }
830
831        // Now resolve all versions to SHAs
832        self.version_resolver.resolve_all().await.context("Failed to resolve versions to SHAs")?;
833
834        // Step 3: Create worktrees for all resolved SHAs in parallel
835        let prepared_versions = self.create_worktrees_for_resolved_versions().await?;
836
837        // Store the prepared versions
838        self.prepared_versions.extend(prepared_versions);
839
840        // Step 4: Handle local sources separately (they don't need worktrees)
841        for (_, dep) in deps {
842            if let Some(source_name) = dep.get_source() {
843                let source_url =
844                    self.source_manager.get_source_url(source_name).ok_or_else(|| {
845                        AgpmError::SourceNotFound {
846                            name: source_name.to_string(),
847                        }
848                    })?;
849
850                // Check if this is a local directory source
851                if crate::utils::is_local_path(&source_url) {
852                    let version_key = dep.get_version().unwrap_or("HEAD");
853                    let group_key = Self::group_key(source_name, version_key);
854
855                    // Add to prepared_versions with the local path
856                    self.prepared_versions.insert(
857                        group_key,
858                        PreparedSourceVersion {
859                            worktree_path: PathBuf::from(&source_url),
860                            resolved_version: Some("local".to_string()),
861                            resolved_commit: String::new(), // No commit for local sources
862                        },
863                    );
864                }
865            }
866        }
867
868        // Phase completion is handled by the caller
869
870        Ok(())
871    }
872
873    /// Creates a new resolver using only manifest-defined sources.
874    ///
875    /// This constructor creates a resolver that only considers sources defined
876    /// in the manifest file. Global configuration sources from `~/.agpm/config.toml`
877    /// are ignored, which may cause resolution failures for private repositories
878    /// that require authentication.
879    ///
880    /// # Usage
881    ///
882    /// Use this constructor for:
883    /// - Public repositories only
884    /// - Testing and development
885    /// - Backward compatibility with older workflows
886    ///
887    /// For production use with private repositories, prefer [`new_with_global()`].
888    ///
889    /// # Errors
890    ///
891    /// Returns an error if the cache cannot be created.
892    ///
893    /// [`new_with_global()`]: DependencyResolver::new_with_global
894    pub fn new(manifest: Manifest, cache: Cache) -> Result<Self> {
895        let source_manager = SourceManager::from_manifest(&manifest)?;
896        let version_resolver = VersionResolver::new(cache.clone());
897
898        Ok(Self {
899            manifest,
900            source_manager,
901            cache,
902            prepared_versions: HashMap::new(),
903            version_resolver,
904            dependency_map: HashMap::new(),
905            transitive_types: HashMap::new(),
906            conflict_detector: ConflictDetector::new(),
907        })
908    }
909
910    /// Creates a new resolver with global configuration support.
911    ///
912    /// This is the recommended constructor for most use cases. It loads both
913    /// manifest sources and global sources from `~/.agpm/config.toml`, enabling
914    /// access to private repositories with authentication tokens.
915    ///
916    /// # Source Priority
917    ///
918    /// When sources are defined in both locations:
919    /// 1. **Global sources** (from `~/.agpm/config.toml`) are loaded first
920    /// 2. **Local sources** (from `agpm.toml`) can override global sources
921    ///
922    /// This allows teams to share project configurations while keeping
923    /// authentication tokens in user-specific global config.
924    ///
925    /// # Errors
926    ///
927    /// Returns an error if:
928    /// - The cache cannot be created
929    /// - The global config file exists but cannot be parsed
930    /// - Network errors occur while validating global sources
931    pub async fn new_with_global(manifest: Manifest, cache: Cache) -> Result<Self> {
932        let source_manager = SourceManager::from_manifest_with_global(&manifest).await?;
933        let version_resolver = VersionResolver::new(cache.clone());
934
935        Ok(Self {
936            manifest,
937            source_manager,
938            cache,
939            prepared_versions: HashMap::new(),
940            version_resolver,
941            dependency_map: HashMap::new(),
942            transitive_types: HashMap::new(),
943            conflict_detector: ConflictDetector::new(),
944        })
945    }
946
947    /// Creates a new resolver with a custom cache.
948    ///
949    /// This constructor is primarily used for testing and specialized deployments
950    /// where the default cache location (`~/.agpm/cache/`) is not suitable.
951    ///
952    /// # Use Cases
953    ///
954    /// - **Testing**: Isolated cache for test environments
955    /// - **CI/CD**: Custom cache locations for build systems
956    /// - **Containers**: Non-standard filesystem layouts
957    /// - **Multi-user**: Shared cache directories
958    ///
959    /// # Note
960    ///
961    /// This constructor does not load global configuration. If you need both
962    /// custom cache location and global config support, create the resolver
963    /// with [`new_with_global()`] and manually configure the source manager.
964    ///
965    /// [`new_with_global()`]: DependencyResolver::new_with_global
966    #[must_use]
967    pub fn with_cache(manifest: Manifest, cache: Cache) -> Self {
968        let cache_dir = cache.get_cache_location().to_path_buf();
969        let source_manager = SourceManager::from_manifest_with_cache(&manifest, cache_dir);
970        let version_resolver = VersionResolver::new(cache.clone());
971
972        Self {
973            manifest,
974            source_manager,
975            cache,
976            prepared_versions: HashMap::new(),
977            version_resolver,
978            dependency_map: HashMap::new(),
979            transitive_types: HashMap::new(),
980            conflict_detector: ConflictDetector::new(),
981        }
982    }
983
984    /// Resolves all dependencies and generates a complete lockfile.
985    ///
986    /// This is the main resolution method that processes all dependencies from
987    /// the manifest and produces a deterministic lockfile. The process includes:
988    ///
989    /// 1. **Source Validation**: Verify all referenced sources exist
990    /// 2. **Parallel Sync**: Clone/update source repositories concurrently
991    /// 3. **Version Resolution**: Resolve constraints to specific commits
992    /// 4. **Entry Generation**: Create lockfile entries with checksums
993    ///
994    /// # Algorithm Details
995    ///
996    /// The resolution algorithm processes dependencies in dependency order to ensure
997    /// consistency. For each dependency:
998    /// - Local dependencies are processed immediately (no network access)
999    /// - Remote dependencies trigger source synchronization
1000    /// - Version constraints are resolved using Git tag/branch lookup
1001    /// - Installation paths are determined based on resource type
1002    ///
1003    /// # Parameters
1004    ///
1005    /// - `progress`: Optional progress manager for user feedback during long operations
1006    ///
1007    /// # Returns
1008    ///
1009    /// A [`LockFile`] containing all resolved dependencies with:
1010    /// - Exact commit hashes for reproducible installations
1011    /// - Source URLs for traceability
1012    /// - Installation paths for each resource
1013    /// - Checksums for integrity verification (computed later during installation)
1014    ///
1015    /// # Errors
1016    ///
1017    /// Resolution can fail due to:
1018    /// - **Network Issues**: Git clone/fetch failures
1019    /// - **Authentication**: Missing or invalid credentials for private sources
1020    /// - **Version Conflicts**: Incompatible version constraints
1021    /// - **Missing Resources**: Referenced files don't exist in sources
1022    /// - **Path Conflicts**: Multiple resources installing to same location
1023    ///
1024    /// # Performance
1025    ///
1026    /// - **Parallel Source Sync**: Multiple sources are processed concurrently
1027    /// - **Cache Utilization**: Previously cloned sources are reused
1028    /// - **Progress Reporting**: Non-blocking UI updates during resolution
1029    ///
1030    /// [`LockFile`]: crate::lockfile::LockFile
1031    ///
1032    /// Resolve transitive dependencies by extracting metadata from resource files.
1033    ///
1034    /// This method builds a dependency graph by:
1035    /// 1. Starting with direct manifest dependencies
1036    /// 2. Extracting metadata from each resolved resource
1037    /// 3. Adding discovered transitive dependencies to the graph
1038    /// 4. Checking for circular dependencies
1039    /// 5. Returning all dependencies in topological order
1040    ///
1041    /// # Cross-Source Handling
1042    ///
1043    /// Resources are uniquely identified by `(ResourceType, name, source)`, allowing multiple
1044    /// resources with the same name from different sources to coexist. During topological
1045    /// ordering, all resources with matching name and type are included, even if they come
1046    /// from different sources.
1047    ///
1048    /// # Arguments
1049    /// * `base_deps` - The initial dependencies from the manifest
1050    /// * `enable_transitive` - Whether to resolve transitive dependencies
1051    ///
1052    /// # Returns
1053    /// A vector of all dependencies (direct + transitive) in topological order, including
1054    /// all same-named resources from different sources
1055    async fn resolve_transitive_dependencies(
1056        &mut self,
1057        base_deps: &[(String, ResourceDependency)],
1058        enable_transitive: bool,
1059    ) -> Result<Vec<(String, ResourceDependency)>> {
1060        // Clear state from any previous resolution to prevent stale data
1061        // IMPORTANT: Must clear before early return to avoid contaminating non-transitive runs
1062        self.dependency_map.clear();
1063        self.transitive_types.clear();
1064        // NOTE: Don't reset conflict_detector here - it was already populated with direct dependencies
1065
1066        if !enable_transitive {
1067            // If transitive resolution is disabled, return base dependencies as-is
1068            return Ok(base_deps.to_vec());
1069        }
1070
1071        let mut graph = DependencyGraph::new();
1072        // Use (resource_type, name, source) as key to distinguish same-named resources from different sources
1073        let mut all_deps: HashMap<
1074            (crate::core::ResourceType, String, Option<String>),
1075            ResourceDependency,
1076        > = HashMap::new();
1077        let mut processed: HashSet<(crate::core::ResourceType, String, Option<String>)> =
1078            HashSet::new();
1079        let mut queue: Vec<(String, ResourceDependency, Option<crate::core::ResourceType>)> =
1080            Vec::new();
1081
1082        // Add initial dependencies to queue
1083        for (name, dep) in base_deps {
1084            let resource_type = self.get_resource_type(name);
1085            let source = dep.get_source().map(std::string::ToString::to_string);
1086            queue.push((name.clone(), dep.clone(), Some(resource_type)));
1087            all_deps.insert((resource_type, name.clone(), source), dep.clone());
1088        }
1089
1090        // Process queue to discover transitive dependencies
1091        while let Some((name, dep, resource_type)) = queue.pop() {
1092            let source = dep.get_source().map(std::string::ToString::to_string);
1093            let resource_type = resource_type
1094                .unwrap_or_else(|| self.get_resource_type_with_source(&name, source.as_deref()));
1095            let key = (resource_type, name.clone(), source.clone());
1096
1097            if processed.contains(&key) {
1098                continue;
1099            }
1100            processed.insert(key.clone());
1101
1102            // Skip pattern dependencies for transitive resolution (too complex for now)
1103            if dep.is_pattern() {
1104                continue;
1105            }
1106
1107            // Get the resource content to extract metadata
1108            let content = match self.fetch_resource_content(&name, &dep).await {
1109                Ok(content) => content,
1110                Err(e) => {
1111                    // If we can't fetch the resource, skip its transitive deps
1112                    eprintln!(
1113                        "Warning: Failed to fetch resource '{name}' for transitive dependency extraction: {e}"
1114                    );
1115                    continue;
1116                }
1117            };
1118
1119            // Extract metadata from the resource
1120            let path = PathBuf::from(dep.get_path());
1121            let metadata = MetadataExtractor::extract(&path, &content)?;
1122
1123            // Process transitive dependencies if present
1124            if let Some(deps_map) = metadata.dependencies {
1125                // Check if this is a path-only dependency (Simple variant)
1126                if matches!(dep, ResourceDependency::Simple(_)) {
1127                    // Warn user that transitive dependencies are not supported for path-only deps
1128                    eprintln!(
1129                        "Warning: Resource '{}' at '{}' declares transitive dependencies, but path-only dependencies do not support this.",
1130                        name,
1131                        dep.get_path()
1132                    );
1133                    eprintln!(
1134                        "         To enable transitive dependency resolution, create a local source with 'agpm add source <name> <path>'"
1135                    );
1136                    eprintln!(
1137                        "         then reference this resource using the source instead of a direct path."
1138                    );
1139                    continue; // Skip processing transitive deps for this resource
1140                }
1141
1142                for (dep_resource_type_str, dep_specs) in deps_map {
1143                    // Convert plural form from YAML (e.g., "agents") to ResourceType enum
1144                    // The ResourceType::FromStr accepts both plural and singular forms
1145                    let dep_resource_type: crate::core::ResourceType =
1146                        dep_resource_type_str.parse().unwrap_or(crate::core::ResourceType::Snippet);
1147
1148                    for dep_spec in dep_specs {
1149                        // Convert DependencySpec to ResourceDependency
1150                        // This will only be called for Detailed dependencies now
1151                        let trans_dep = self.spec_to_dependency(&dep, &dep_spec)?;
1152
1153                        // Generate a name for the transitive dependency
1154                        let trans_name = self.generate_dependency_name(&dep_spec.path);
1155
1156                        // Add to graph (use source-aware nodes to prevent false cycles)
1157                        let trans_source =
1158                            trans_dep.get_source().map(std::string::ToString::to_string);
1159                        let from_node =
1160                            DependencyNode::with_source(resource_type, &name, source.clone());
1161                        let to_node = DependencyNode::with_source(
1162                            dep_resource_type,
1163                            &trans_name,
1164                            trans_source.clone(),
1165                        );
1166                        graph.add_dependency(from_node.clone(), to_node.clone());
1167
1168                        // Track in dependency map (use singular form from enum for dependency references)
1169                        // Include source to prevent cross-source contamination
1170                        let from_key = (resource_type, name.clone(), source.clone());
1171                        let dep_ref = format!("{dep_resource_type}/{trans_name}");
1172                        self.dependency_map.entry(from_key).or_default().push(dep_ref);
1173
1174                        // Cache the resource type for this transitive dependency
1175                        let type_key = (trans_name.clone(), trans_source.clone());
1176                        self.transitive_types.insert(type_key, dep_resource_type);
1177
1178                        // Add to conflict detector for tracking version requirements
1179                        self.add_to_conflict_detector(&trans_name, &trans_dep, &name);
1180
1181                        // Check for version conflicts and resolve them
1182                        let trans_key =
1183                            (dep_resource_type, trans_name.clone(), trans_source.clone());
1184
1185                        if let Some(existing_dep) = all_deps.get(&trans_key) {
1186                            // Version conflict detected (same name and source, different version)
1187                            let resolved_dep = self.resolve_version_conflict(
1188                                &trans_name,
1189                                existing_dep,
1190                                &trans_dep,
1191                                &name, // Who requires this version
1192                            )?;
1193                            all_deps.insert(trans_key.clone(), resolved_dep);
1194                        } else {
1195                            // No conflict, add the dependency
1196                            all_deps.insert(trans_key.clone(), trans_dep.clone());
1197                            queue.push((trans_name, trans_dep, Some(dep_resource_type)));
1198                        }
1199                    }
1200                }
1201            }
1202        }
1203
1204        // Check for circular dependencies
1205        graph.detect_cycles()?;
1206
1207        // Get topological order for dependencies that have relationships
1208        let ordered_nodes = graph.topological_order()?;
1209
1210        // Build result: start with topologically ordered dependencies
1211        let mut result = Vec::new();
1212        let mut added_keys = HashSet::new();
1213
1214        for node in ordered_nodes {
1215            // Find matching dependency - now that nodes include source, we can match precisely
1216            for (key, dep) in &all_deps {
1217                if key.0 == node.resource_type && key.1 == node.name && key.2 == node.source {
1218                    result.push((node.name.clone(), dep.clone()));
1219                    added_keys.insert(key.clone());
1220                    break; // Exact match found, no need to continue
1221                }
1222            }
1223        }
1224
1225        // Add remaining dependencies that weren't in the graph (no transitive deps)
1226        // These can be added in any order since they have no dependencies
1227        for (key, dep) in all_deps {
1228            if !added_keys.contains(&key) {
1229                result.push((key.1.clone(), dep.clone()));
1230            }
1231        }
1232
1233        Ok(result)
1234    }
1235
1236    /// Fetch the content of a resource for metadata extraction.
1237    async fn fetch_resource_content(
1238        &mut self,
1239        _name: &str,
1240        dep: &ResourceDependency,
1241    ) -> Result<String> {
1242        match dep {
1243            ResourceDependency::Simple(path) => {
1244                // Local file - path is relative to where agpm was invoked
1245                // Since we don't track the manifest path, assume relative path
1246                let full_path = PathBuf::from(path);
1247                std::fs::read_to_string(&full_path)
1248                    .with_context(|| format!("Failed to read local file: {}", full_path.display()))
1249            }
1250            ResourceDependency::Detailed(detailed) => {
1251                if let Some(source_name) = &detailed.source {
1252                    let source_url = self
1253                        .source_manager
1254                        .get_source_url(source_name)
1255                        .ok_or_else(|| anyhow::anyhow!("Source '{source_name}' not found"))?;
1256
1257                    // Check if this is a local directory source
1258                    if crate::utils::is_local_path(&source_url) {
1259                        // Local directory source - read directly from path
1260                        let file_path = PathBuf::from(&source_url).join(&detailed.path);
1261                        std::fs::read_to_string(&file_path).with_context(|| {
1262                            format!("Failed to read local file: {}", file_path.display())
1263                        })
1264                    } else {
1265                        // Git-based remote dependency - need to checkout and read
1266                        // Use get_version() to respect rev > branch > version precedence
1267                        let version = dep.get_version().unwrap_or("main").to_string();
1268
1269                        // Check if we already have this version resolved
1270                        let sha = if let Some(prepared) =
1271                            self.prepared_versions.get(&Self::group_key(source_name, &version))
1272                        {
1273                            prepared.resolved_commit.clone()
1274                        } else {
1275                            // Need to resolve this version
1276                            self.version_resolver.add_version(
1277                                source_name,
1278                                &source_url,
1279                                Some(&version),
1280                            );
1281                            self.version_resolver.resolve_all().await?;
1282
1283                            self.version_resolver
1284                                .get_resolved_sha(source_name, &version)
1285                                .ok_or_else(|| {
1286                                    anyhow::anyhow!(
1287                                        "Failed to resolve version for {source_name} @ {version}"
1288                                    )
1289                                })?
1290                        };
1291
1292                        // Get worktree for this SHA
1293                        let worktree_path = self
1294                            .cache
1295                            .get_or_create_worktree_for_sha(source_name, &source_url, &sha, None)
1296                            .await?;
1297
1298                        // Read the file from worktree
1299                        let file_path = worktree_path.join(&detailed.path);
1300                        std::fs::read_to_string(&file_path).with_context(|| {
1301                            format!("Failed to read file from worktree: {}", file_path.display())
1302                        })
1303                    }
1304                } else {
1305                    // Local dependency with detailed spec
1306                    let full_path = PathBuf::from(&detailed.path);
1307                    std::fs::read_to_string(&full_path).with_context(|| {
1308                        format!("Failed to read local file: {}", full_path.display())
1309                    })
1310                }
1311            }
1312        }
1313    }
1314
1315    /// Convert a `DependencySpec` to a `ResourceDependency`.
1316    ///
1317    /// Inherits the source from the parent dependency.
1318    ///
1319    /// For source-based dependencies (Detailed variant), transitive dependencies
1320    /// inherit the source and paths are relative to the source's root directory.
1321    ///
1322    /// For path-only dependencies (Simple variant), this method should not be called
1323    /// as transitive dependencies are not supported for them.
1324    fn spec_to_dependency(
1325        &self,
1326        parent: &ResourceDependency,
1327        spec: &DependencySpec,
1328    ) -> Result<ResourceDependency> {
1329        match parent {
1330            ResourceDependency::Simple(_) => {
1331                // Path-only dependencies don't support transitive deps
1332                // This case should be filtered out before calling this method
1333                Err(anyhow::anyhow!(
1334                    "Transitive dependencies are not supported for path-only dependencies"
1335                ))
1336            }
1337            ResourceDependency::Detailed(parent_detail) => {
1338                // Inherit source and artifact_type from parent
1339                Ok(ResourceDependency::Detailed(Box::new(DetailedDependency {
1340                    source: parent_detail.source.clone(),
1341                    path: spec.path.clone(),
1342                    version: spec.version.clone().or_else(|| parent_detail.version.clone()),
1343                    branch: None,
1344                    rev: None,
1345                    command: None,
1346                    args: None,
1347                    target: None,
1348                    filename: None,
1349                    dependencies: None, // Will be filled when fetched
1350                    tool: parent_detail.tool.clone(),
1351                })))
1352            }
1353        }
1354    }
1355
1356    /// Generate a dependency name from a path.
1357    fn generate_dependency_name(&self, path: &str) -> String {
1358        // Extract filename without extension
1359        Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or(path).to_string()
1360    }
1361
1362    /// Resolve all manifest dependencies into a deterministic lockfile.
1363    ///
1364    /// This is the primary entry point for dependency resolution. It resolves all
1365    /// dependencies from the manifest (including transitive dependencies) and
1366    /// generates a complete lockfile with resolved versions and commit SHAs.
1367    ///
1368    /// By default, this method enables transitive dependency resolution. Resources
1369    /// can declare their own dependencies via YAML frontmatter (Markdown) or JSON
1370    /// fields, which will be automatically discovered and resolved.
1371    ///
1372    /// # Transitive Dependency Resolution
1373    ///
1374    /// When enabled (default), the resolver:
1375    /// 1. Resolves direct manifest dependencies
1376    /// 2. Extracts dependency metadata from resource files
1377    /// 3. Builds a dependency graph with cycle detection
1378    /// 4. Resolves transitive dependencies in topological order
1379    ///
1380    /// # Returns
1381    ///
1382    /// A complete [`LockFile`] with all resolved dependencies including:
1383    /// - Resolved commit SHAs for reproducible installations
1384    /// - Checksums for integrity verification
1385    /// - Installation paths for all resources
1386    /// - Source repository information
1387    ///
1388    /// # Errors
1389    ///
1390    /// Returns an error if:
1391    /// - Source repositories cannot be accessed
1392    /// - Version constraints cannot be satisfied
1393    /// - Circular dependencies are detected
1394    /// - Resource files cannot be read or parsed
1395    ///
1396    /// # Example
1397    ///
1398    /// ```rust,no_run
1399    /// # use agpm_cli::resolver::DependencyResolver;
1400    /// # use agpm_cli::manifest::Manifest;
1401    /// # use agpm_cli::cache::Cache;
1402    /// # async fn example() -> anyhow::Result<()> {
1403    /// let manifest = Manifest::load("agpm.toml".as_ref())?;
1404    /// let cache = Cache::new()?;
1405    /// let mut resolver = DependencyResolver::new(manifest, cache)?;
1406    ///
1407    /// // Resolve all dependencies including transitive ones
1408    /// let lockfile = resolver.resolve().await?;
1409    ///
1410    /// lockfile.save("agpm.lock".as_ref())?;
1411    /// println!("Resolved {} total resources",
1412    ///          lockfile.agents.len() + lockfile.snippets.len());
1413    /// # Ok(())
1414    /// # }
1415    /// ```
1416    pub async fn resolve(&mut self) -> Result<LockFile> {
1417        self.resolve_with_options(true).await
1418    }
1419
1420    /// Resolve dependencies with configurable transitive dependency support.
1421    ///
1422    /// This method provides fine-grained control over dependency resolution behavior,
1423    /// allowing you to disable transitive dependency resolution when needed. This is
1424    /// useful for debugging, testing, or when you want to install only direct
1425    /// dependencies without their transitive requirements.
1426    ///
1427    /// # Arguments
1428    ///
1429    /// * `enable_transitive` - Whether to resolve transitive dependencies
1430    ///   - `true`: Full transitive resolution (default behavior)
1431    ///   - `false`: Only direct manifest dependencies
1432    ///
1433    /// # Transitive Resolution Details
1434    ///
1435    /// When `enable_transitive` is `true`:
1436    /// - Resources are checked for embedded dependency metadata
1437    /// - Markdown files (.md): YAML frontmatter between `---` delimiters
1438    /// - JSON files (.json): Top-level `dependencies` field
1439    /// - Dependency graph is built with cycle detection
1440    /// - Dependencies are resolved in topological order
1441    ///
1442    /// When `enable_transitive` is `false`:
1443    /// - Only dependencies explicitly declared in `agpm.toml` are resolved
1444    /// - Resource metadata is not extracted or processed
1445    /// - Faster resolution for known dependency trees
1446    ///
1447    /// # Returns
1448    ///
1449    /// A [`LockFile`] containing all resolved dependencies according to the
1450    /// configuration. When transitive resolution is disabled, the lockfile will
1451    /// only contain direct dependencies from the manifest.
1452    ///
1453    /// # Errors
1454    ///
1455    /// Returns an error if:
1456    /// - Source repositories are inaccessible or invalid
1457    /// - Version constraints conflict or cannot be satisfied
1458    /// - Circular dependencies are detected (when `enable_transitive` is true)
1459    /// - Resource files cannot be read or contain invalid metadata
1460    /// - Network operations fail during source synchronization
1461    ///
1462    /// # Performance Considerations
1463    ///
1464    /// Disabling transitive resolution (`enable_transitive = false`) can improve
1465    /// performance when:
1466    /// - You know all required dependencies are explicitly listed
1467    /// - Testing specific dependency combinations
1468    /// - Debugging dependency resolution issues
1469    /// - Working with large resources that have expensive metadata extraction
1470    ///
1471    /// # Example
1472    ///
1473    /// ```rust,no_run
1474    /// # use agpm_cli::resolver::DependencyResolver;
1475    /// # use agpm_cli::manifest::Manifest;
1476    /// # use agpm_cli::cache::Cache;
1477    /// # async fn example() -> anyhow::Result<()> {
1478    /// let manifest = Manifest::load("agpm.toml".as_ref())?;
1479    /// let cache = Cache::new()?;
1480    /// let mut resolver = DependencyResolver::new(manifest, cache)?;
1481    ///
1482    /// // Resolve only direct dependencies without transitive resolution
1483    /// let lockfile = resolver.resolve_with_options(false).await?;
1484    ///
1485    /// println!("Resolved {} direct dependencies",
1486    ///          lockfile.agents.len() + lockfile.snippets.len());
1487    /// # Ok(())
1488    /// # }
1489    /// ```
1490    ///
1491    /// # See Also
1492    ///
1493    /// - [`resolve()`]: Convenience method that enables transitive resolution by default
1494    /// - [`DependencyGraph`]: Graph structure used for cycle detection and ordering
1495    /// - [`DependencySpec`]: Specification format for transitive dependencies
1496    ///
1497    /// [`resolve()`]: DependencyResolver::resolve
1498    pub async fn resolve_with_options(&mut self, enable_transitive: bool) -> Result<LockFile> {
1499        let mut lockfile = LockFile::new();
1500
1501        // Add sources to lockfile
1502        for (name, url) in &self.manifest.sources {
1503            lockfile.add_source(name.clone(), url.clone(), String::new());
1504        }
1505
1506        // Get all dependencies to resolve including MCP servers (clone to avoid borrow checker issues)
1507        let base_deps: Vec<(String, ResourceDependency)> = self
1508            .manifest
1509            .all_dependencies_with_mcp()
1510            .into_iter()
1511            .map(|(name, dep)| (name.to_string(), dep.into_owned()))
1512            .collect();
1513
1514        // Add direct dependencies to conflict detector
1515        for (name, dep) in &base_deps {
1516            self.add_to_conflict_detector(name, dep, "manifest");
1517        }
1518
1519        // Show initial message about what we're doing
1520        // Sync sources (phase management is handled by caller)
1521        self.prepare_remote_groups(&base_deps).await?;
1522
1523        // Resolve transitive dependencies if enabled
1524        let deps = self.resolve_transitive_dependencies(&base_deps, enable_transitive).await?;
1525
1526        // Resolve each dependency (including transitive ones)
1527        for (name, dep) in &deps {
1528            // Progress is tracked at the phase level
1529
1530            // Check if this is a pattern dependency
1531            if dep.is_pattern() {
1532                // Pattern dependencies resolve to multiple resources
1533                let entries = self.resolve_pattern_dependency(name, dep).await?;
1534
1535                // Add each resolved entry to the appropriate resource type with deduplication
1536                // Use source-aware lookup to correctly resolve transitive dependency types
1537                let source = dep.get_source();
1538                let resource_type = self.get_resource_type_with_source(name, source);
1539                for entry in entries {
1540                    match resource_type {
1541                        crate::core::ResourceType::Agent => {
1542                            // Match by (name, source) to allow same-named resources from different sources
1543                            if let Some(existing) = lockfile
1544                                .agents
1545                                .iter_mut()
1546                                .find(|e| e.name == entry.name && e.source == entry.source)
1547                            {
1548                                *existing = entry;
1549                            } else {
1550                                lockfile.agents.push(entry);
1551                            }
1552                        }
1553                        crate::core::ResourceType::Snippet => {
1554                            if let Some(existing) = lockfile
1555                                .snippets
1556                                .iter_mut()
1557                                .find(|e| e.name == entry.name && e.source == entry.source)
1558                            {
1559                                *existing = entry;
1560                            } else {
1561                                lockfile.snippets.push(entry);
1562                            }
1563                        }
1564                        crate::core::ResourceType::Command => {
1565                            if let Some(existing) = lockfile
1566                                .commands
1567                                .iter_mut()
1568                                .find(|e| e.name == entry.name && e.source == entry.source)
1569                            {
1570                                *existing = entry;
1571                            } else {
1572                                lockfile.commands.push(entry);
1573                            }
1574                        }
1575                        crate::core::ResourceType::Script => {
1576                            if let Some(existing) = lockfile
1577                                .scripts
1578                                .iter_mut()
1579                                .find(|e| e.name == entry.name && e.source == entry.source)
1580                            {
1581                                *existing = entry;
1582                            } else {
1583                                lockfile.scripts.push(entry);
1584                            }
1585                        }
1586                        crate::core::ResourceType::Hook => {
1587                            if let Some(existing) = lockfile
1588                                .hooks
1589                                .iter_mut()
1590                                .find(|e| e.name == entry.name && e.source == entry.source)
1591                            {
1592                                *existing = entry;
1593                            } else {
1594                                lockfile.hooks.push(entry);
1595                            }
1596                        }
1597                        crate::core::ResourceType::McpServer => {
1598                            if let Some(existing) = lockfile
1599                                .mcp_servers
1600                                .iter_mut()
1601                                .find(|e| e.name == entry.name && e.source == entry.source)
1602                            {
1603                                *existing = entry;
1604                            } else {
1605                                lockfile.mcp_servers.push(entry);
1606                            }
1607                        }
1608                    }
1609                }
1610            } else {
1611                // Regular single dependency
1612                let entry = self.resolve_dependency(name, dep).await?;
1613                // Add directly to lockfile to preserve (name, source) uniqueness
1614                self.add_or_update_lockfile_entry(&mut lockfile, name, entry);
1615            }
1616
1617            // Progress is tracked by updating messages, no need to increment
1618        }
1619
1620        // Progress is tracked at the phase level
1621
1622        // Progress completion is handled by the caller
1623
1624        // Detect version conflicts before creating lockfile
1625        let conflicts = self.conflict_detector.detect_conflicts();
1626        if !conflicts.is_empty() {
1627            let mut error_msg = String::from("Version conflicts detected:\n\n");
1628            for conflict in &conflicts {
1629                error_msg.push_str(&format!("{conflict}\n"));
1630            }
1631            return Err(AgpmError::Other {
1632                message: error_msg,
1633            }
1634            .into());
1635        }
1636
1637        // Post-process dependencies to add version information
1638        self.add_version_to_dependencies(&mut lockfile)?;
1639
1640        // Detect target-path conflicts before finalizing
1641        self.detect_target_conflicts(&lockfile)?;
1642
1643        Ok(lockfile)
1644    }
1645
1646    /// Resolves a single dependency to a lockfile entry.
1647    ///
1648    /// This internal method handles the resolution of one dependency, including
1649    /// source synchronization, version resolution, and entry creation.
1650    ///
1651    /// # Algorithm
1652    ///
1653    /// For local dependencies:
1654    /// 1. Validate the path format
1655    /// 2. Determine installation location based on resource type
1656    /// 3. Preserve relative directory structure from source path
1657    /// 4. Create entry with relative path (no source sync required)
1658    ///
1659    /// For remote dependencies:
1660    /// 1. Validate source exists in manifest or global config
1661    /// 2. Synchronize source repository (clone or fetch)
1662    /// 3. Resolve version constraint to specific commit
1663    /// 4. Preserve relative directory structure from dependency path
1664    /// 5. Create entry with resolved commit and source information
1665    ///
1666    /// # Parameters
1667    ///
1668    /// - `name`: The dependency name from the manifest
1669    /// - `dep`: The dependency specification with source, path, and version
1670    ///
1671    /// # Returns
1672    ///
1673    /// A [`LockedResource`] with:
1674    /// - Resolved commit hash (for remote dependencies)
1675    /// - Source and URL information
1676    /// - Installation path in the project
1677    /// - Empty checksum (computed during actual installation)
1678    ///
1679    /// # Errors
1680    ///
1681    /// Returns an error if:
1682    /// - Source is not found in manifest or global config
1683    /// - Source repository cannot be cloned or accessed
1684    /// - Version constraint cannot be resolved (tag/branch not found)
1685    /// - Git operations fail due to network or authentication issues
1686    ///
1687    /// [`LockedResource`]: crate::lockfile::LockedResource
1688    async fn resolve_dependency(
1689        &mut self,
1690        name: &str,
1691        dep: &ResourceDependency,
1692    ) -> Result<LockedResource> {
1693        // Check if this is a pattern-based dependency
1694        if dep.is_pattern() {
1695            // Pattern dependencies resolve to multiple resources
1696            // This should be handled by a separate method
1697            return Err(anyhow::anyhow!(
1698                "Pattern dependency '{name}' should be resolved using resolve_pattern_dependency"
1699            ));
1700        }
1701
1702        if dep.is_local() {
1703            // Local dependency - just create entry with path
1704            // Determine resource type from manifest (already returns enum)
1705            // Use source-aware lookup to correctly resolve transitive dependency types
1706            let source = dep.get_source();
1707            let resource_type = self.get_resource_type_with_source(name, source);
1708
1709            // Determine the filename to use
1710            let filename = if let Some(custom_filename) = dep.get_filename() {
1711                // Use custom filename as-is (includes extension)
1712                custom_filename.to_string()
1713            } else {
1714                // Extract relative path from the dependency path to preserve directory structure
1715                let dep_path = Path::new(dep.get_path());
1716                let relative_path = extract_relative_path(dep_path, &resource_type);
1717
1718                // If a relative path exists, preserve it; otherwise use dependency name
1719                if relative_path.as_os_str().is_empty() || relative_path == dep_path {
1720                    // No relative path preserved, use default filename
1721                    let extension = match resource_type {
1722                        crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
1723                            "json"
1724                        }
1725                        crate::core::ResourceType::Script => {
1726                            // Scripts maintain their original extension
1727                            dep_path.extension().and_then(|e| e.to_str()).unwrap_or("sh")
1728                        }
1729                        _ => "md",
1730                    };
1731                    format!("{name}.{extension}")
1732                } else {
1733                    // Preserve the relative path structure
1734                    relative_path.to_string_lossy().to_string()
1735                }
1736            };
1737
1738            // Determine artifact type
1739            let artifact_type = match dep {
1740                crate::manifest::ResourceDependency::Detailed(d) => &d.tool,
1741                _ => "claude-code",
1742            };
1743
1744            // Determine the target directory using artifact configuration
1745            // Normalize to forward slashes for cross-platform consistency in lockfile
1746            let installed_at = if let Some(custom_target) = dep.get_target() {
1747                // Custom target is relative to the artifact's resource directory
1748                if let Some(artifact_path) =
1749                    self.manifest.get_artifact_resource_path(artifact_type, resource_type)
1750                {
1751                    let base_target = artifact_path.display().to_string();
1752                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
1753                        .replace("//", "/")
1754                        + "/"
1755                        + &filename
1756                } else {
1757                    // Fallback to legacy target config
1758                    #[allow(deprecated)]
1759                    let base_target = match resource_type {
1760                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
1761                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
1762                        crate::core::ResourceType::Command => &self.manifest.target.commands,
1763                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
1764                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
1765                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
1766                    };
1767                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
1768                        .replace("//", "/")
1769                        + "/"
1770                        + &filename
1771                }
1772            } else {
1773                // Use artifact configuration for default path
1774                if let Some(artifact_path) =
1775                    self.manifest.get_artifact_resource_path(artifact_type, resource_type)
1776                {
1777                    format!("{}/{}", artifact_path.display(), filename)
1778                } else {
1779                    // Fallback to legacy target config
1780                    #[allow(deprecated)]
1781                    let target_dir = match resource_type {
1782                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
1783                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
1784                        crate::core::ResourceType::Command => &self.manifest.target.commands,
1785                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
1786                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
1787                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
1788                    };
1789                    format!("{target_dir}/{filename}")
1790                }
1791            }
1792            .replace('\\', "/");
1793
1794            // For local resources without a source, just use the name (no version suffix)
1795            let unique_name = name.to_string();
1796
1797            // Hooks and MCP servers are configured in config files, not installed as artifact files
1798            let installed_at = match resource_type {
1799                crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
1800                crate::core::ResourceType::McpServer => {
1801                    // Determine config file based on tool type
1802                    match dep {
1803                        crate::manifest::ResourceDependency::Detailed(d)
1804                            if d.tool == "opencode" =>
1805                        {
1806                            ".opencode/opencode.json".to_string()
1807                        }
1808                        _ => ".mcp.json".to_string(), // Default to claude-code
1809                    }
1810                }
1811                _ => installed_at,
1812            };
1813
1814            Ok(LockedResource {
1815                name: unique_name,
1816                source: None,
1817                url: None,
1818                path: dep.get_path().to_string(),
1819                version: None,
1820                resolved_commit: None,
1821                checksum: String::new(),
1822                installed_at,
1823                dependencies: self.get_dependencies_for(name, None),
1824                resource_type,
1825                tool: match dep {
1826                    crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
1827                    _ => "claude-code".to_string(),
1828                },
1829            })
1830        } else {
1831            // Remote dependency - need to sync and resolve
1832            let source_name = dep.get_source().ok_or_else(|| AgpmError::ConfigError {
1833                message: format!("Dependency '{name}' has no source specified"),
1834            })?;
1835
1836            // Get source URL
1837            let source_url = self.source_manager.get_source_url(source_name).ok_or_else(|| {
1838                AgpmError::SourceNotFound {
1839                    name: source_name.to_string(),
1840                }
1841            })?;
1842
1843            let version_key = dep
1844                .get_version()
1845                .map_or_else(|| "HEAD".to_string(), std::string::ToString::to_string);
1846            let prepared_key = Self::group_key(source_name, &version_key);
1847
1848            // Check if this dependency has been prepared
1849            let (resolved_version, resolved_commit) = if let Some(prepared) =
1850                self.prepared_versions.get(&prepared_key)
1851            {
1852                // Use prepared version
1853                (prepared.resolved_version.clone(), prepared.resolved_commit.clone())
1854            } else {
1855                // This dependency wasn't prepared (e.g., when called from `agpm add`)
1856                // We need to prepare it on-demand
1857                let deps = vec![(name.to_string(), dep.clone())];
1858                self.prepare_remote_groups(&deps).await?;
1859
1860                // Now it should be prepared
1861                if let Some(prepared) = self.prepared_versions.get(&prepared_key) {
1862                    (prepared.resolved_version.clone(), prepared.resolved_commit.clone())
1863                } else {
1864                    return Err(anyhow::anyhow!(
1865                        "Failed to prepare dependency '{name}' from source '{source_name}' @ '{version_key}'"
1866                    ));
1867                }
1868            };
1869
1870            // Determine resource type from manifest (already returns enum)
1871            // Use source-aware lookup to correctly resolve transitive dependency types
1872            let source = dep.get_source();
1873            let resource_type = self.get_resource_type_with_source(name, source);
1874
1875            // Determine the filename to use
1876            let filename = if let Some(custom_filename) = dep.get_filename() {
1877                // Use custom filename as-is (includes extension)
1878                custom_filename.to_string()
1879            } else {
1880                // Extract relative path from the dependency path to preserve directory structure
1881                let dep_path = Path::new(dep.get_path());
1882                let relative_path = extract_relative_path(dep_path, &resource_type);
1883
1884                // If a relative path exists, preserve it; otherwise use dependency name
1885                if relative_path.as_os_str().is_empty() || relative_path == dep_path {
1886                    // No relative path preserved, use default filename
1887                    let extension = match resource_type {
1888                        crate::core::ResourceType::Hook | crate::core::ResourceType::McpServer => {
1889                            "json"
1890                        }
1891                        crate::core::ResourceType::Script => {
1892                            // Scripts maintain their original extension
1893                            dep_path.extension().and_then(|e| e.to_str()).unwrap_or("sh")
1894                        }
1895                        _ => "md",
1896                    };
1897                    format!("{name}.{extension}")
1898                } else {
1899                    // Preserve the relative path structure
1900                    relative_path.to_string_lossy().to_string()
1901                }
1902            };
1903
1904            // Determine artifact type
1905            let artifact_type = match dep {
1906                crate::manifest::ResourceDependency::Detailed(d) => &d.tool,
1907                _ => "claude-code",
1908            };
1909
1910            // Determine the target directory using artifact configuration
1911            // Normalize to forward slashes for cross-platform consistency in lockfile
1912            let installed_at = if let Some(custom_target) = dep.get_target() {
1913                // Custom target is relative to the artifact's resource directory
1914                if let Some(artifact_path) =
1915                    self.manifest.get_artifact_resource_path(artifact_type, resource_type)
1916                {
1917                    let base_target = artifact_path.display().to_string();
1918                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
1919                        .replace("//", "/")
1920                        + "/"
1921                        + &filename
1922                } else {
1923                    // Fallback to legacy target config
1924                    #[allow(deprecated)]
1925                    let base_target = match resource_type {
1926                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
1927                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
1928                        crate::core::ResourceType::Command => &self.manifest.target.commands,
1929                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
1930                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
1931                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
1932                    };
1933                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
1934                        .replace("//", "/")
1935                        + "/"
1936                        + &filename
1937                }
1938            } else {
1939                // Use artifact configuration for default path
1940                if let Some(artifact_path) =
1941                    self.manifest.get_artifact_resource_path(artifact_type, resource_type)
1942                {
1943                    format!("{}/{}", artifact_path.display(), filename)
1944                } else {
1945                    // Fallback to legacy target config
1946                    #[allow(deprecated)]
1947                    let target_dir = match resource_type {
1948                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
1949                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
1950                        crate::core::ResourceType::Command => &self.manifest.target.commands,
1951                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
1952                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
1953                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
1954                    };
1955                    format!("{target_dir}/{filename}")
1956                }
1957            }
1958            .replace('\\', "/");
1959
1960            // Use simple name from manifest - lockfile entries are identified by (name, source)
1961            // Multiple entries with the same name but different sources can coexist
1962            // Version updates replace the existing entry for the same (name, source) pair
1963            let unique_name = name.to_string();
1964
1965            // Extract artifact_type from dependency
1966            let artifact_type = match dep {
1967                crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
1968                _ => "claude-code".to_string(),
1969            };
1970
1971            // Hooks and MCP servers are configured in config files, not installed as artifact files
1972            let installed_at = match resource_type {
1973                crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
1974                crate::core::ResourceType::McpServer => {
1975                    // Determine config file based on tool type
1976                    match dep {
1977                        crate::manifest::ResourceDependency::Detailed(d)
1978                            if d.tool == "opencode" =>
1979                        {
1980                            ".opencode/opencode.json".to_string()
1981                        }
1982                        _ => ".mcp.json".to_string(), // Default to claude-code
1983                    }
1984                }
1985                _ => installed_at,
1986            };
1987
1988            Ok(LockedResource {
1989                name: unique_name,
1990                source: Some(source_name.to_string()),
1991                url: Some(source_url.clone()),
1992                path: dep.get_path().to_string(),
1993                version: resolved_version, // Resolved version (tag/branch like "v2.1.4" or "main")
1994                resolved_commit: Some(resolved_commit),
1995                checksum: String::new(), // Will be calculated during installation
1996                installed_at,
1997                dependencies: self.get_dependencies_for(name, Some(source_name)),
1998                resource_type,
1999                tool: artifact_type,
2000            })
2001        }
2002    }
2003
2004    /// Gets the dependencies for a resource from the dependency map.
2005    ///
2006    /// Returns a list of dependencies in the format "`resource_type/name`".
2007    ///
2008    /// # Parameters
2009    /// - `name`: The resource name
2010    /// - `source`: The source name (None for local dependencies)
2011    fn get_dependencies_for(&self, name: &str, source: Option<&str>) -> Vec<String> {
2012        let resource_type = self.get_resource_type_with_source(name, source);
2013        let key = (resource_type, name.to_string(), source.map(std::string::ToString::to_string));
2014        self.dependency_map.get(&key).cloned().unwrap_or_default()
2015    }
2016
2017    /// Resolves a pattern-based dependency to multiple locked resources.
2018    ///
2019    /// Pattern dependencies match multiple resources using glob patterns,
2020    /// enabling batch installation of related resources.
2021    ///
2022    /// # Process
2023    ///
2024    /// 1. Sync the source repository
2025    /// 2. Checkout the specified version (if any)
2026    /// 3. Search for files matching the pattern
2027    /// 4. Preserve relative directory structure for each matched file
2028    /// 5. Create a locked resource for each match
2029    ///
2030    /// # Parameters
2031    ///
2032    /// - `name`: The dependency name (used for the collection)
2033    /// - `dep`: The pattern-based dependency specification
2034    ///
2035    /// # Returns
2036    ///
2037    /// A vector of [`LockedResource`] entries, one for each matched file.
2038    async fn resolve_pattern_dependency(
2039        &mut self,
2040        name: &str,
2041        dep: &ResourceDependency,
2042    ) -> Result<Vec<LockedResource>> {
2043        // Pattern dependencies use the path field with glob characters
2044        if !dep.is_pattern() {
2045            return Err(anyhow::anyhow!(
2046                "Expected pattern dependency but no glob characters found in path"
2047            ));
2048        }
2049
2050        let pattern = dep.get_path();
2051
2052        if dep.is_local() {
2053            // Local pattern dependency - search in filesystem
2054            // Extract base path from the pattern if it contains an absolute path
2055            let (base_path, pattern_str) = if pattern.contains('/') || pattern.contains('\\') {
2056                // Pattern contains path separators, extract base path
2057                let pattern_path = Path::new(pattern);
2058                if let Some(parent) = pattern_path.parent() {
2059                    if parent.is_absolute() || parent.starts_with("..") || parent.starts_with(".") {
2060                        // Use the parent as base path and just the filename pattern
2061                        (
2062                            parent.to_path_buf(),
2063                            pattern_path
2064                                .file_name()
2065                                .and_then(|s| s.to_str())
2066                                .unwrap_or(pattern)
2067                                .to_string(),
2068                        )
2069                    } else {
2070                        // Relative path, use current directory as base
2071                        (PathBuf::from("."), pattern.to_string())
2072                    }
2073                } else {
2074                    // No parent, use current directory
2075                    (PathBuf::from("."), pattern.to_string())
2076                }
2077            } else {
2078                // Simple pattern without path separators
2079                (PathBuf::from("."), pattern.to_string())
2080            };
2081
2082            let pattern_resolver = crate::pattern::PatternResolver::new();
2083            let matches = pattern_resolver.resolve(&pattern_str, &base_path)?;
2084
2085            let resource_type = self.get_resource_type(name);
2086            let mut resources = Vec::new();
2087
2088            for matched_path in matches {
2089                let resource_name = crate::pattern::extract_resource_name(&matched_path);
2090
2091                // Extract relative path to preserve directory structure
2092                let relative_path = extract_relative_path(&matched_path, &resource_type);
2093
2094                // Determine the target directory
2095                #[allow(deprecated)]
2096                let target_dir = if let Some(custom_target) = dep.get_target() {
2097                    // Custom target is relative to the default resource directory
2098                    let base_target = match resource_type {
2099                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
2100                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
2101                        crate::core::ResourceType::Command => &self.manifest.target.commands,
2102                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
2103                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
2104                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
2105                    };
2106                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
2107                        .replace("//", "/")
2108                } else {
2109                    match resource_type {
2110                        crate::core::ResourceType::Agent => self.manifest.target.agents.clone(),
2111                        crate::core::ResourceType::Snippet => self.manifest.target.snippets.clone(),
2112                        crate::core::ResourceType::Command => self.manifest.target.commands.clone(),
2113                        crate::core::ResourceType::Script => self.manifest.target.scripts.clone(),
2114                        crate::core::ResourceType::Hook => self.manifest.target.hooks.clone(),
2115                        crate::core::ResourceType::McpServer => {
2116                            self.manifest.target.mcp_servers.clone()
2117                        }
2118                    }
2119                };
2120
2121                // Use relative path if it exists, otherwise use resource name
2122                let filename =
2123                    if relative_path.as_os_str().is_empty() || relative_path == matched_path {
2124                        let extension =
2125                            matched_path.extension().and_then(|e| e.to_str()).unwrap_or("md");
2126                        format!("{resource_name}.{extension}")
2127                    } else {
2128                        relative_path.to_string_lossy().to_string()
2129                    };
2130
2131                let installed_at = format!("{target_dir}/{filename}");
2132
2133                // Construct full relative path from base_path and matched_path
2134                let full_relative_path = if base_path == Path::new(".") {
2135                    matched_path.to_string_lossy().to_string()
2136                } else {
2137                    format!("{}/{}", base_path.display(), matched_path.display())
2138                };
2139
2140                // Determine resource type (pattern dependencies inherit from parent name)
2141                let resource_type = self.get_resource_type(name);
2142
2143                // Hooks and MCP servers are configured in config files, not installed as artifact files
2144                let installed_at = match resource_type {
2145                    crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
2146                    crate::core::ResourceType::McpServer => {
2147                        // Determine config file based on tool type
2148                        match dep {
2149                            crate::manifest::ResourceDependency::Detailed(d)
2150                                if d.tool == "opencode" =>
2151                            {
2152                                ".opencode/opencode.json".to_string()
2153                            }
2154                            _ => ".mcp.json".to_string(), // Default to claude-code
2155                        }
2156                    }
2157                    _ => installed_at,
2158                };
2159
2160                resources.push(LockedResource {
2161                    name: resource_name.clone(),
2162                    source: None,
2163                    url: None,
2164                    path: full_relative_path,
2165                    version: None,
2166                    resolved_commit: None,
2167                    checksum: String::new(),
2168                    installed_at,
2169                    dependencies: self.get_dependencies_for(&resource_name, None),
2170                    resource_type,
2171                    tool: match dep {
2172                        crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
2173                        _ => "claude-code".to_string(),
2174                    },
2175                });
2176            }
2177
2178            Ok(resources)
2179        } else {
2180            // Remote pattern dependency - need to sync and search
2181            let source_name = dep.get_source().ok_or_else(|| AgpmError::ConfigError {
2182                message: format!("Pattern dependency '{name}' has no source specified"),
2183            })?;
2184
2185            let source_url = self.source_manager.get_source_url(source_name).ok_or_else(|| {
2186                AgpmError::SourceNotFound {
2187                    name: source_name.to_string(),
2188                }
2189            })?;
2190
2191            let version_key = dep
2192                .get_version()
2193                .map_or_else(|| "HEAD".to_string(), std::string::ToString::to_string);
2194            let prepared_key = Self::group_key(source_name, &version_key);
2195
2196            let prepared = self
2197                .prepared_versions
2198                .get(&prepared_key)
2199                .ok_or_else(|| {
2200                    anyhow::anyhow!(
2201                        "Prepared state missing for source '{source_name}' @ '{version_key}'. Stage 1 preparation should have populated this entry."
2202                    )
2203                })?;
2204
2205            let repo_path = prepared.worktree_path.clone();
2206            let resolved_version = prepared.resolved_version.clone();
2207            let resolved_commit = prepared.resolved_commit.clone();
2208
2209            // Search for matching files in the repository
2210            let pattern_resolver = crate::pattern::PatternResolver::new();
2211            let repo_path_ref = Path::new(&repo_path);
2212            let matches = pattern_resolver.resolve(pattern, repo_path_ref)?;
2213
2214            let resource_type = self.get_resource_type(name);
2215            let mut resources = Vec::new();
2216
2217            for matched_path in matches {
2218                let resource_name = crate::pattern::extract_resource_name(&matched_path);
2219
2220                // Extract relative path to preserve directory structure
2221                let relative_path = extract_relative_path(&matched_path, &resource_type);
2222
2223                // Determine the target directory
2224                #[allow(deprecated)]
2225                let target_dir = if let Some(custom_target) = dep.get_target() {
2226                    // Custom target is relative to the default resource directory
2227                    let base_target = match resource_type {
2228                        crate::core::ResourceType::Agent => &self.manifest.target.agents,
2229                        crate::core::ResourceType::Snippet => &self.manifest.target.snippets,
2230                        crate::core::ResourceType::Command => &self.manifest.target.commands,
2231                        crate::core::ResourceType::Script => &self.manifest.target.scripts,
2232                        crate::core::ResourceType::Hook => &self.manifest.target.hooks,
2233                        crate::core::ResourceType::McpServer => &self.manifest.target.mcp_servers,
2234                    };
2235                    format!("{}/{}", base_target, custom_target.trim_start_matches('/'))
2236                        .replace("//", "/")
2237                } else {
2238                    match resource_type {
2239                        crate::core::ResourceType::Agent => self.manifest.target.agents.clone(),
2240                        crate::core::ResourceType::Snippet => self.manifest.target.snippets.clone(),
2241                        crate::core::ResourceType::Command => self.manifest.target.commands.clone(),
2242                        crate::core::ResourceType::Script => self.manifest.target.scripts.clone(),
2243                        crate::core::ResourceType::Hook => self.manifest.target.hooks.clone(),
2244                        crate::core::ResourceType::McpServer => {
2245                            self.manifest.target.mcp_servers.clone()
2246                        }
2247                    }
2248                };
2249
2250                // Use relative path if it exists, otherwise use resource name
2251                let filename =
2252                    if relative_path.as_os_str().is_empty() || relative_path == matched_path {
2253                        let extension =
2254                            matched_path.extension().and_then(|e| e.to_str()).unwrap_or("md");
2255                        format!("{resource_name}.{extension}")
2256                    } else {
2257                        relative_path.to_string_lossy().to_string()
2258                    };
2259
2260                let installed_at = format!("{target_dir}/{filename}");
2261
2262                // Determine resource type (pattern dependencies inherit from parent name)
2263                let resource_type = self.get_resource_type(name);
2264
2265                // Hooks and MCP servers are configured in config files, not installed as artifact files
2266                let installed_at = match resource_type {
2267                    crate::core::ResourceType::Hook => ".claude/settings.local.json".to_string(),
2268                    crate::core::ResourceType::McpServer => {
2269                        // Determine config file based on tool type
2270                        match dep {
2271                            crate::manifest::ResourceDependency::Detailed(d)
2272                                if d.tool == "opencode" =>
2273                            {
2274                                ".opencode/opencode.json".to_string()
2275                            }
2276                            _ => ".mcp.json".to_string(), // Default to claude-code
2277                        }
2278                    }
2279                    _ => installed_at,
2280                };
2281
2282                resources.push(LockedResource {
2283                    name: resource_name.clone(),
2284                    source: Some(source_name.to_string()),
2285                    url: Some(source_url.clone()),
2286                    path: matched_path.to_string_lossy().to_string(),
2287                    version: resolved_version.clone(), // Use the resolved version (e.g., "main")
2288                    resolved_commit: Some(resolved_commit.clone()),
2289                    checksum: String::new(),
2290                    installed_at,
2291                    dependencies: self.get_dependencies_for(&resource_name, Some(source_name)),
2292                    resource_type,
2293                    tool: match dep {
2294                        crate::manifest::ResourceDependency::Detailed(d) => d.tool.clone(),
2295                        _ => "claude-code".to_string(),
2296                    },
2297                });
2298            }
2299
2300            Ok(resources)
2301        }
2302    }
2303
2304    /// Checks out a specific version in a Git repository.
2305    ///
2306    /// This method implements the version resolution strategy by attempting
2307    /// to checkout Git references in order of preference:
2308    ///
2309    /// 1. **Tags**: Exact tag matches (e.g., `v1.2.3`)
2310    /// 2. **Branches**: Branch heads (e.g., `main`, `develop`)
2311    /// 3. **Commits**: Direct commit hashes (40-character SHA)
2312    ///
2313    /// # Algorithm
2314    ///
2315    /// ```text
2316    /// 1. List all tags in repository
2317    /// 2. If version matches a tag, checkout tag
2318    /// 3. Else attempt branch checkout
2319    /// 4. Else attempt commit hash checkout
2320    /// 5. Return current HEAD commit hash
2321    /// ```
2322    ///
2323    /// # Performance Note
2324    ///
2325    /// Tag listing is cached by Git, making tag lookups efficient.
2326    /// The method avoids unnecessary network operations by checking
2327    /// local references first.
2328    ///
2329    /// # Parameters
2330    ///
2331    /// - `repo`: Git repository handle
2332    /// - `version`: Version constraint (tag, branch, or commit hash)
2333    ///
2334    /// # Returns
2335    ///
2336    /// The commit hash (SHA) of the checked out version.
2337    ///
2338    /// # Errors
2339    ///
2340    /// Returns an error if:
2341    /// - Git repository is in an invalid state
2342    /// - Version string doesn't match any tag, branch, or valid commit
2343    /// - Git checkout fails due to conflicts or permissions
2344    /// - Repository is corrupted or inaccessible
2345    ///
2346    /// Determines the resource type (agent or snippet) from a dependency name.
2347    ///
2348    /// This method checks which manifest section contains the dependency
2349    /// to determine where it should be installed in the project.
2350    ///
2351    /// # Resource Type Mapping
2352    ///
2353    /// - **agents**: Dependencies listed in `[agents]` section
2354    /// - **snippets**: Dependencies listed in `[snippets]` section
2355    ///
2356    /// # Installation Paths
2357    ///
2358    /// Resource types determine installation directories:
2359    /// - Agents install to `{manifest.target.agents}/{name}.md`
2360    /// - Snippets install to `{manifest.target.snippets}/{name}.md`
2361    ///
2362    /// # Parameters
2363    ///
2364    /// - `name`: Dependency name as defined in manifest
2365    ///
2366    /// # Returns
2367    ///
2368    /// Resource type as a string: `"agent"` or `"snippet"`.
2369    ///
2370    /// # Default Behavior
2371    ///
2372    /// If a dependency is not found in the agents section, it defaults
2373    /// to `"snippet"`. This handles edge cases and maintains backward compatibility.
2374    fn get_resource_type(&self, name: &str) -> crate::core::ResourceType {
2375        self.get_resource_type_with_source(name, None)
2376    }
2377
2378    /// Get resource type with optional source information for accurate transitive dependency lookup.
2379    fn get_resource_type_with_source(
2380        &self,
2381        name: &str,
2382        source: Option<&str>,
2383    ) -> crate::core::ResourceType {
2384        // First check the manifest for direct dependencies
2385        if self.manifest.agents.contains_key(name) {
2386            crate::core::ResourceType::Agent
2387        } else if self.manifest.snippets.contains_key(name) {
2388            crate::core::ResourceType::Snippet
2389        } else if self.manifest.commands.contains_key(name) {
2390            crate::core::ResourceType::Command
2391        } else if self.manifest.scripts.contains_key(name) {
2392            crate::core::ResourceType::Script
2393        } else if self.manifest.hooks.contains_key(name) {
2394            crate::core::ResourceType::Hook
2395        } else if self.manifest.mcp_servers.contains_key(name) {
2396            crate::core::ResourceType::McpServer
2397        } else {
2398            // Check transitive_types cache for discovered transitive dependencies
2399            let type_key = (name.to_string(), source.map(std::string::ToString::to_string));
2400            if let Some(&resource_type) = self.transitive_types.get(&type_key) {
2401                return resource_type;
2402            }
2403
2404            // Fallback: check dependency_map keys (less precise, doesn't use source)
2405            for (resource_type, dep_name, _dep_source) in self.dependency_map.keys() {
2406                if dep_name == name {
2407                    return *resource_type;
2408                }
2409            }
2410
2411            crate::core::ResourceType::Snippet // Default fallback
2412        }
2413    }
2414
2415    /// Resolve version conflicts between two dependencies.
2416    ///
2417    /// This method implements version conflict resolution strategies when the same
2418    /// resource is required with different versions by different dependencies.
2419    ///
2420    /// # Resolution Strategy
2421    ///
2422    /// The current implementation uses a "highest compatible version" strategy:
2423    /// 1. If one dependency has no version (latest), use the other's version
2424    /// 2. If both have versions, prefer semantic version comparison
2425    /// 3. For incompatible versions, warn and use the higher version
2426    ///
2427    /// # Future Enhancements
2428    ///
2429    /// - Support for version ranges (^1.0.0, ~2.1.0)
2430    /// - User-configurable resolution strategies
2431    /// - Interactive conflict resolution
2432    ///
2433    /// # Parameters
2434    ///
2435    /// - `resource_name`: Name of the conflicting resource
2436    /// - `existing`: Current version in the dependency map
2437    /// - `new_dep`: New version being requested
2438    /// - `requester`: Name of the dependency requesting the new version
2439    ///
2440    /// # Returns
2441    ///
2442    /// The resolved dependency that satisfies both requirements if possible,
2443    /// or the higher version with a warning if not compatible.
2444    fn resolve_version_conflict(
2445        &self,
2446        resource_name: &str,
2447        existing: &ResourceDependency,
2448        new_dep: &ResourceDependency,
2449        requester: &str,
2450    ) -> Result<ResourceDependency> {
2451        let existing_version = existing.get_version();
2452        let new_version = new_dep.get_version();
2453
2454        // If versions are identical, no conflict
2455        if existing_version == new_version {
2456            return Ok(existing.clone());
2457        }
2458
2459        // Check if either version is a semver range (not an exact version)
2460        let is_existing_range = existing_version.is_some_and(|v| {
2461            v.starts_with('^') || v.starts_with('~') || v.starts_with('>') || v.starts_with('<')
2462        });
2463        let is_new_range = new_version.is_some_and(|v| {
2464            v.starts_with('^') || v.starts_with('~') || v.starts_with('>') || v.starts_with('<')
2465        });
2466
2467        if is_existing_range || is_new_range {
2468            // Don't try to resolve semver ranges here - that should be handled by conflict detector
2469            return Err(AgpmError::Other {
2470                message: format!(
2471                    "Version conflict for '{}': cannot resolve semver ranges automatically. \
2472                     Existing: {:?}, Required by '{}': {:?}. \
2473                     This should have been caught by conflict detection.",
2474                    resource_name,
2475                    existing_version.unwrap_or("HEAD"),
2476                    requester,
2477                    new_version.unwrap_or("HEAD")
2478                ),
2479            }
2480            .into());
2481        }
2482
2483        // Log the conflict for user awareness
2484        tracing::warn!(
2485            "Version conflict for '{}': existing version {:?} vs {:?} required by '{}'",
2486            resource_name,
2487            existing_version.unwrap_or("HEAD"),
2488            new_version.unwrap_or("HEAD"),
2489            requester
2490        );
2491
2492        // Resolution strategy
2493        match (existing_version, new_version) {
2494            (None, Some(_)) => {
2495                // Existing wants HEAD, new wants specific - use specific
2496                Ok(new_dep.clone())
2497            }
2498            (Some(_), None) => {
2499                // Existing wants specific, new wants HEAD - keep specific
2500                Ok(existing.clone())
2501            }
2502            (Some(v1), Some(v2)) => {
2503                // Both have versions - use semver-aware comparison
2504                use semver::Version;
2505
2506                // Try to parse as semver (strip 'v' prefix if present)
2507                let v1_semver = Version::parse(v1.trim_start_matches('v')).ok();
2508                let v2_semver = Version::parse(v2.trim_start_matches('v')).ok();
2509
2510                match (v1_semver, v2_semver) {
2511                    (Some(sv1), Some(sv2)) => {
2512                        // Both are valid semver - use proper semver comparison
2513                        if sv1 >= sv2 {
2514                            tracing::info!(
2515                                "Resolving conflict: using version {} for {} (semver: {} >= {})",
2516                                v1,
2517                                resource_name,
2518                                sv1,
2519                                sv2
2520                            );
2521                            Ok(existing.clone())
2522                        } else {
2523                            tracing::info!(
2524                                "Resolving conflict: using version {} for {} (semver: {} < {})",
2525                                v2,
2526                                resource_name,
2527                                sv1,
2528                                sv2
2529                            );
2530                            Ok(new_dep.clone())
2531                        }
2532                    }
2533                    (Some(_), None) => {
2534                        // v1 is semver, v2 is not (branch/commit) - prefer semver
2535                        tracing::info!(
2536                            "Resolving conflict: preferring semver version {} over git ref {} for {}",
2537                            v1,
2538                            v2,
2539                            resource_name
2540                        );
2541                        Ok(existing.clone())
2542                    }
2543                    (None, Some(_)) => {
2544                        // v1 is not semver (branch/commit), v2 is - prefer semver
2545                        tracing::info!(
2546                            "Resolving conflict: preferring semver version {} over git ref {} for {}",
2547                            v2,
2548                            v1,
2549                            resource_name
2550                        );
2551                        Ok(new_dep.clone())
2552                    }
2553                    (None, None) => {
2554                        // Neither is semver (both branches/commits)
2555                        // Use deterministic ordering: alphabetical
2556                        if v1 <= v2 {
2557                            tracing::info!(
2558                                "Resolving conflict: using git ref {} for {} (alphabetically first)",
2559                                v1,
2560                                resource_name
2561                            );
2562                            Ok(existing.clone())
2563                        } else {
2564                            tracing::info!(
2565                                "Resolving conflict: using git ref {} for {} (alphabetically first)",
2566                                v2,
2567                                resource_name
2568                            );
2569                            Ok(new_dep.clone())
2570                        }
2571                    }
2572                }
2573            }
2574            (None, None) => {
2575                // Both want HEAD - no conflict
2576                Ok(existing.clone())
2577            }
2578        }
2579    }
2580
2581    /// Updates an existing lockfile with new or changed dependencies.
2582    ///
2583    /// This method performs incremental dependency resolution by comparing
2584    /// the current manifest against an existing lockfile and updating only
2585    /// the specified dependencies (or all if none specified).
2586    ///
2587    /// # Update Strategy
2588    ///
2589    /// The update process follows these steps:
2590    /// 1. **Selective Resolution**: Only resolve specified dependencies
2591    /// 2. **Preserve Existing**: Keep unchanged dependencies from existing lockfile
2592    /// 3. **In-place Updates**: Replace matching entries with new versions
2593    /// 4. **New Additions**: Append newly added dependencies
2594    ///
2595    /// # Use Cases
2596    ///
2597    /// - **Selective Updates**: Update specific outdated dependencies
2598    /// - **Security Patches**: Update dependencies with known vulnerabilities
2599    /// - **Feature Updates**: Pull latest versions for active development
2600    /// - **Manifest Changes**: Reflect additions/modifications to agpm.toml
2601    ///
2602    /// # Parameters
2603    ///
2604    /// - `existing`: Current lockfile to update
2605    /// - `deps_to_update`: Optional list of specific dependencies to update.
2606    ///   If `None`, all dependencies are updated.
2607    /// - `progress`: Optional progress bar for user feedback
2608    ///
2609    /// # Returns
2610    ///
2611    /// A new [`LockFile`] with updated dependencies. The original lockfile
2612    /// structure is preserved, with only specified entries modified.
2613    ///
2614    /// # Algorithm Complexity
2615    ///
2616    /// - **Time**: O(u + s·log(t)) where u = dependencies to update
2617    /// - **Space**: O(n) where n = total dependencies in lockfile
2618    ///
2619    /// # Performance Benefits
2620    ///
2621    /// - **Network Optimization**: Only syncs sources for updated dependencies
2622    /// - **Cache Utilization**: Reuses existing source repositories
2623    /// - **Parallel Processing**: Updates multiple dependencies concurrently
2624    ///
2625    /// # Errors
2626    ///
2627    /// Update can fail due to:
2628    /// - Network issues accessing source repositories
2629    /// - Version constraints that cannot be satisfied
2630    /// - Authentication failures for private sources
2631    /// - Corrupted or inaccessible cache directories
2632    ///
2633    /// [`LockFile`]: crate::lockfile::LockFile
2634    pub async fn update(
2635        &mut self,
2636        existing: &LockFile,
2637        deps_to_update: Option<Vec<String>>,
2638    ) -> Result<LockFile> {
2639        let mut lockfile = existing.clone();
2640
2641        // Determine which dependencies to update
2642        let deps_to_check: HashSet<String> = if let Some(specific) = deps_to_update {
2643            specific.into_iter().collect()
2644        } else {
2645            // Update all dependencies
2646            self.manifest.all_dependencies().iter().map(|(name, _)| (*name).to_string()).collect()
2647        };
2648
2649        // Get all base dependencies including MCP servers (clone to avoid borrow checker issues)
2650        let base_deps: Vec<(String, ResourceDependency)> = self
2651            .manifest
2652            .all_dependencies_with_mcp()
2653            .into_iter()
2654            .map(|(name, dep)| (name.to_string(), dep.into_owned()))
2655            .collect();
2656
2657        // Note: We assume the update command has already called pre_sync_sources
2658        // during the "Syncing sources" phase, so repositories are already available.
2659        // We just need to prepare and resolve versions now.
2660
2661        // Prepare remote groups to resolve versions (reuses pre-synced repos)
2662        self.prepare_remote_groups(&base_deps).await?;
2663
2664        // Resolve transitive dependencies (always enabled for update to maintain consistency)
2665        let deps = self.resolve_transitive_dependencies(&base_deps, true).await?;
2666
2667        for (name, dep) in deps {
2668            if !deps_to_check.contains(&name) {
2669                // Skip this dependency
2670                continue;
2671            }
2672
2673            // Check if this is a pattern dependency
2674            if dep.is_pattern() {
2675                // Pattern dependencies resolve to multiple resources
2676                let entries = self.resolve_pattern_dependency(&name, &dep).await?;
2677
2678                // Add each resolved entry to the appropriate resource type with deduplication
2679                let resource_type = self.get_resource_type(&name);
2680                for entry in entries {
2681                    match resource_type {
2682                        crate::core::ResourceType::Agent => {
2683                            // Match by (name, source) to allow same-named resources from different sources
2684                            if let Some(existing) = lockfile
2685                                .agents
2686                                .iter_mut()
2687                                .find(|e| e.name == entry.name && e.source == entry.source)
2688                            {
2689                                *existing = entry;
2690                            } else {
2691                                lockfile.agents.push(entry);
2692                            }
2693                        }
2694                        crate::core::ResourceType::Snippet => {
2695                            if let Some(existing) = lockfile
2696                                .snippets
2697                                .iter_mut()
2698                                .find(|e| e.name == entry.name && e.source == entry.source)
2699                            {
2700                                *existing = entry;
2701                            } else {
2702                                lockfile.snippets.push(entry);
2703                            }
2704                        }
2705                        crate::core::ResourceType::Command => {
2706                            if let Some(existing) = lockfile
2707                                .commands
2708                                .iter_mut()
2709                                .find(|e| e.name == entry.name && e.source == entry.source)
2710                            {
2711                                *existing = entry;
2712                            } else {
2713                                lockfile.commands.push(entry);
2714                            }
2715                        }
2716                        crate::core::ResourceType::Script => {
2717                            if let Some(existing) = lockfile
2718                                .scripts
2719                                .iter_mut()
2720                                .find(|e| e.name == entry.name && e.source == entry.source)
2721                            {
2722                                *existing = entry;
2723                            } else {
2724                                lockfile.scripts.push(entry);
2725                            }
2726                        }
2727                        crate::core::ResourceType::Hook => {
2728                            if let Some(existing) = lockfile
2729                                .hooks
2730                                .iter_mut()
2731                                .find(|e| e.name == entry.name && e.source == entry.source)
2732                            {
2733                                *existing = entry;
2734                            } else {
2735                                lockfile.hooks.push(entry);
2736                            }
2737                        }
2738                        crate::core::ResourceType::McpServer => {
2739                            if let Some(existing) = lockfile
2740                                .mcp_servers
2741                                .iter_mut()
2742                                .find(|e| e.name == entry.name && e.source == entry.source)
2743                            {
2744                                *existing = entry;
2745                            } else {
2746                                lockfile.mcp_servers.push(entry);
2747                            }
2748                        }
2749                    }
2750                }
2751            } else {
2752                // Regular single dependency
2753                let entry = self.resolve_dependency(&name, &dep).await?;
2754
2755                // Use the helper method to add or update the entry
2756                self.add_or_update_lockfile_entry(&mut lockfile, &name, entry);
2757            }
2758        }
2759
2760        // Progress bar completion is handled by the caller
2761
2762        // Post-process dependencies to add version information
2763        self.add_version_to_dependencies(&mut lockfile)?;
2764
2765        // Detect target-path conflicts before finalizing
2766        self.detect_target_conflicts(&lockfile)?;
2767
2768        Ok(lockfile)
2769    }
2770
2771    /// Adds a dependency to the conflict detector.
2772    ///
2773    /// Builds a resource identifier from the dependency's source and path,
2774    /// and records it along with the requirer and version constraint.
2775    ///
2776    /// # Parameters
2777    ///
2778    /// - `_name`: The dependency name (unused, kept for consistency)
2779    /// - `dep`: The dependency specification
2780    /// - `required_by`: Identifier of the resource requiring this dependency
2781    fn add_to_conflict_detector(
2782        &mut self,
2783        _name: &str,
2784        dep: &ResourceDependency,
2785        required_by: &str,
2786    ) {
2787        // Skip local dependencies (no version conflicts possible)
2788        if dep.is_local() {
2789            return;
2790        }
2791
2792        // Build resource identifier: source:path
2793        let source = dep.get_source().unwrap_or("unknown");
2794        let path = dep.get_path();
2795        let resource_id = format!("{source}:{path}");
2796
2797        // Get version constraint (None means HEAD/unspecified)
2798        if let Some(version) = dep.get_version() {
2799            // Add to conflict detector
2800            self.conflict_detector.add_requirement(&resource_id, required_by, version);
2801        } else {
2802            // No version specified - use HEAD marker
2803            self.conflict_detector.add_requirement(&resource_id, required_by, "HEAD");
2804        }
2805    }
2806
2807    /// Post-processes lockfile entries to add version information to dependencies.
2808    ///
2809    /// Updates the `dependencies` field in each lockfile entry from the format
2810    /// `"resource_type/name"` to `"resource_type/name@version"` by looking up
2811    /// the resolved version in the lockfile.
2812    fn add_version_to_dependencies(&self, lockfile: &mut LockFile) -> Result<()> {
2813        // Build a lookup map: (resource_type, path, source) -> unique_name
2814        // This allows us to resolve dependency paths to lockfile names
2815        // We store both the full path and just the filename for flexible lookup
2816        let mut lookup_map: HashMap<(crate::core::ResourceType, String, Option<String>), String> =
2817            HashMap::new();
2818
2819        // Helper to normalize path (strip leading ./, etc.)
2820        let normalize_path = |path: &str| -> String { path.trim_start_matches("./").to_string() };
2821
2822        // Helper to extract filename from path
2823        let extract_filename = |path: &str| -> Option<String> {
2824            path.split('/').next_back().map(std::string::ToString::to_string)
2825        };
2826
2827        // Build lookup map from all lockfile entries
2828        for entry in &lockfile.agents {
2829            let normalized_path = normalize_path(&entry.path);
2830            // Store by full path
2831            lookup_map.insert(
2832                (crate::core::ResourceType::Agent, normalized_path.clone(), entry.source.clone()),
2833                entry.name.clone(),
2834            );
2835            // Also store by filename for backward compatibility
2836            if let Some(filename) = extract_filename(&entry.path) {
2837                lookup_map.insert(
2838                    (crate::core::ResourceType::Agent, filename, entry.source.clone()),
2839                    entry.name.clone(),
2840                );
2841            }
2842        }
2843        for entry in &lockfile.snippets {
2844            let normalized_path = normalize_path(&entry.path);
2845            lookup_map.insert(
2846                (crate::core::ResourceType::Snippet, normalized_path.clone(), entry.source.clone()),
2847                entry.name.clone(),
2848            );
2849            if let Some(filename) = extract_filename(&entry.path) {
2850                lookup_map.insert(
2851                    (crate::core::ResourceType::Snippet, filename, entry.source.clone()),
2852                    entry.name.clone(),
2853                );
2854            }
2855        }
2856        for entry in &lockfile.commands {
2857            let normalized_path = normalize_path(&entry.path);
2858            lookup_map.insert(
2859                (crate::core::ResourceType::Command, normalized_path.clone(), entry.source.clone()),
2860                entry.name.clone(),
2861            );
2862            if let Some(filename) = extract_filename(&entry.path) {
2863                lookup_map.insert(
2864                    (crate::core::ResourceType::Command, filename, entry.source.clone()),
2865                    entry.name.clone(),
2866                );
2867            }
2868        }
2869        for entry in &lockfile.scripts {
2870            let normalized_path = normalize_path(&entry.path);
2871            lookup_map.insert(
2872                (crate::core::ResourceType::Script, normalized_path.clone(), entry.source.clone()),
2873                entry.name.clone(),
2874            );
2875            if let Some(filename) = extract_filename(&entry.path) {
2876                lookup_map.insert(
2877                    (crate::core::ResourceType::Script, filename, entry.source.clone()),
2878                    entry.name.clone(),
2879                );
2880            }
2881        }
2882        for entry in &lockfile.hooks {
2883            let normalized_path = normalize_path(&entry.path);
2884            lookup_map.insert(
2885                (crate::core::ResourceType::Hook, normalized_path.clone(), entry.source.clone()),
2886                entry.name.clone(),
2887            );
2888            if let Some(filename) = extract_filename(&entry.path) {
2889                lookup_map.insert(
2890                    (crate::core::ResourceType::Hook, filename, entry.source.clone()),
2891                    entry.name.clone(),
2892                );
2893            }
2894        }
2895        for entry in &lockfile.mcp_servers {
2896            let normalized_path = normalize_path(&entry.path);
2897            lookup_map.insert(
2898                (
2899                    crate::core::ResourceType::McpServer,
2900                    normalized_path.clone(),
2901                    entry.source.clone(),
2902                ),
2903                entry.name.clone(),
2904            );
2905            if let Some(filename) = extract_filename(&entry.path) {
2906                lookup_map.insert(
2907                    (crate::core::ResourceType::McpServer, filename, entry.source.clone()),
2908                    entry.name.clone(),
2909                );
2910            }
2911        }
2912
2913        // Build a complete map of (resource_type, name, source) -> (source, version) for cross-source lookup
2914        // This needs to be done before we start mutating entries
2915        let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
2916
2917        for entry in &lockfile.agents {
2918            resource_info_map.insert(
2919                (crate::core::ResourceType::Agent, entry.name.clone(), entry.source.clone()),
2920                (entry.source.clone(), entry.version.clone()),
2921            );
2922        }
2923        for entry in &lockfile.snippets {
2924            resource_info_map.insert(
2925                (crate::core::ResourceType::Snippet, entry.name.clone(), entry.source.clone()),
2926                (entry.source.clone(), entry.version.clone()),
2927            );
2928        }
2929        for entry in &lockfile.commands {
2930            resource_info_map.insert(
2931                (crate::core::ResourceType::Command, entry.name.clone(), entry.source.clone()),
2932                (entry.source.clone(), entry.version.clone()),
2933            );
2934        }
2935        for entry in &lockfile.scripts {
2936            resource_info_map.insert(
2937                (crate::core::ResourceType::Script, entry.name.clone(), entry.source.clone()),
2938                (entry.source.clone(), entry.version.clone()),
2939            );
2940        }
2941        for entry in &lockfile.hooks {
2942            resource_info_map.insert(
2943                (crate::core::ResourceType::Hook, entry.name.clone(), entry.source.clone()),
2944                (entry.source.clone(), entry.version.clone()),
2945            );
2946        }
2947        for entry in &lockfile.mcp_servers {
2948            resource_info_map.insert(
2949                (crate::core::ResourceType::McpServer, entry.name.clone(), entry.source.clone()),
2950                (entry.source.clone(), entry.version.clone()),
2951            );
2952        }
2953
2954        // Helper function to update dependencies in a vector of entries
2955        let update_deps = |entries: &mut Vec<LockedResource>| {
2956            for entry in entries {
2957                let parent_source = entry.source.clone();
2958
2959                let updated_deps: Vec<String> =
2960                    entry
2961                        .dependencies
2962                        .iter()
2963                        .map(|dep| {
2964                            // Parse "resource_type/path" format (e.g., "agent/rust-haiku.md" or "snippet/utils.md")
2965                            if let Some((_resource_type_str, dep_path)) = dep.split_once('/') {
2966                                // Parse resource type from string form (accepts both singular and plural)
2967                                if let Ok(resource_type) =
2968                                    _resource_type_str.parse::<crate::core::ResourceType>()
2969                                {
2970                                    // Normalize the path for lookup
2971                                    let dep_filename = normalize_path(dep_path);
2972
2973                                    // Look up the resource in the lookup map (same source as parent)
2974                                    if let Some(dep_name) = lookup_map.get(&(
2975                                        resource_type,
2976                                        dep_filename.clone(),
2977                                        parent_source.clone(),
2978                                    )) {
2979                                        // Found resource in same source - use singular form from enum
2980                                        return format!("{resource_type}/{dep_name}");
2981                                    }
2982
2983                                    // If not found with same source, try adding .md extension
2984                                    let dep_filename_with_ext = format!("{dep_filename}.md");
2985                                    if let Some(dep_name) = lookup_map.get(&(
2986                                        resource_type,
2987                                        dep_filename_with_ext.clone(),
2988                                        parent_source.clone(),
2989                                    )) {
2990                                        return format!("{resource_type}/{dep_name}");
2991                                    }
2992
2993                                    // Try looking for resource from ANY source (cross-source dependency)
2994                                    // Format: source:type/name:version
2995                                    for ((rt, filename, src), name) in &lookup_map {
2996                                        if *rt == resource_type
2997                                            && (filename == &dep_filename
2998                                                || filename == &dep_filename_with_ext)
2999                                        {
3000                                            // Found in different source - need to include source and version
3001                                            // Use the pre-built resource info map
3002                                            if let Some((source, version)) = resource_info_map
3003                                                .get(&(resource_type, name.clone(), src.clone()))
3004                                            {
3005                                                // Build full reference: source:type/name:version
3006                                                let mut dep_ref = String::new();
3007                                                if let Some(src) = source {
3008                                                    dep_ref.push_str(src);
3009                                                    dep_ref.push(':');
3010                                                }
3011                                                dep_ref.push_str(&resource_type.to_string());
3012                                                dep_ref.push('/');
3013                                                dep_ref.push_str(name);
3014                                                if let Some(ver) = version {
3015                                                    dep_ref.push(':');
3016                                                    dep_ref.push_str(ver);
3017                                                }
3018                                                return dep_ref;
3019                                            }
3020                                        }
3021                                    }
3022                                }
3023                            }
3024                            // If parsing fails or resource not found, return as-is
3025                            dep.clone()
3026                        })
3027                        .collect();
3028
3029                entry.dependencies = updated_deps;
3030            }
3031        };
3032
3033        // Update all entry types
3034        update_deps(&mut lockfile.agents);
3035        update_deps(&mut lockfile.snippets);
3036        update_deps(&mut lockfile.commands);
3037        update_deps(&mut lockfile.scripts);
3038        update_deps(&mut lockfile.hooks);
3039        update_deps(&mut lockfile.mcp_servers);
3040
3041        Ok(())
3042    }
3043
3044    /// Verifies that all dependencies can be resolved without performing resolution.
3045    ///
3046    /// This method performs a "dry run" validation of the manifest to detect
3047    /// issues before attempting actual resolution. It's faster than full resolution
3048    /// since it doesn't clone repositories or resolve specific versions.
3049    ///
3050    /// # Validation Steps
3051    ///
3052    /// 1. **Local Path Validation**: Verify local dependencies exist (for absolute paths)
3053    /// 2. **Source Validation**: Ensure all referenced sources are defined
3054    /// 3. **Constraint Validation**: Basic syntax checking of version constraints
3055    ///
3056    /// # Validation Scope
3057    ///
3058    /// - **Manifest Structure**: Validate TOML structure and required fields
3059    /// - **Source References**: Ensure all sources used by dependencies exist
3060    /// - **Local Dependencies**: Check absolute paths exist on filesystem
3061    ///
3062    /// # Performance
3063    ///
3064    /// Verification is designed to be fast:
3065    /// - No network operations (doesn't validate remote repositories)
3066    /// - No Git operations (doesn't check if versions exist)
3067    /// - Only filesystem access for absolute local paths
3068    ///
3069    /// # Parameters
3070    ///
3071    /// - `progress`: Optional progress bar for user feedback
3072    ///
3073    /// # Returns
3074    ///
3075    /// `Ok(())` if all dependencies pass basic validation.
3076    ///
3077    /// # Errors
3078    ///
3079    /// Verification fails if:
3080    /// - Local dependencies reference non-existent absolute paths
3081    /// - Dependencies reference undefined sources
3082    /// - Manifest structure is invalid or corrupted
3083    ///
3084    /// # Note
3085    ///
3086    /// Successful verification doesn't guarantee resolution will succeed,
3087    /// since network issues or missing versions can still cause failures.
3088    /// Use this method for fast validation before expensive resolution operations.
3089    pub fn verify(&mut self) -> Result<()> {
3090        // Redundancy checking removed - conflicts are now automatically resolved
3091        // if let Some(warning) = self.check_redundancies() {
3092        //     eprintln!("{warning}");
3093        // }
3094
3095        // Then try to resolve all dependencies (clone to avoid borrow checker issues)
3096        let deps: Vec<(String, ResourceDependency)> = self
3097            .manifest
3098            .all_dependencies()
3099            .into_iter()
3100            .map(|(name, dep)| (name.to_string(), dep.clone()))
3101            .collect();
3102        for (name, dep) in deps {
3103            if dep.is_local() {
3104                // Check if local path exists or is relative
3105                let path = Path::new(dep.get_path());
3106                if path.is_absolute() && !path.exists() {
3107                    anyhow::bail!("Local dependency '{}' not found at: {}", name, path.display());
3108                }
3109            } else {
3110                // Verify source exists
3111                let source_name = dep.get_source().ok_or_else(|| AgpmError::ConfigError {
3112                    message: format!("Dependency '{name}' has no source specified"),
3113                })?;
3114
3115                if !self.manifest.sources.contains_key(source_name) {
3116                    anyhow::bail!(
3117                        "Dependency '{name}' references undefined source: '{source_name}'"
3118                    );
3119                }
3120            }
3121        }
3122
3123        // Progress bar completion is handled by the caller
3124
3125        Ok(())
3126    }
3127}
3128
3129/// Extracts the relative path from a resource by removing the resource type directory prefix.
3130///
3131/// This function preserves directory structure when installing resources from Git sources
3132/// by intelligently stripping the resource type directory (e.g., "agents/", "snippets/")
3133/// from source repository paths. This allows subdirectories within a resource category
3134/// to be maintained in the installation target, enabling organized source repositories
3135/// to retain their structure.
3136///
3137/// # Path Processing Strategy
3138///
3139/// The function implements a **prefix-aware extraction** algorithm:
3140/// 1. Converts the resource type string to its expected directory name (e.g., "agent" → "agents")
3141/// 2. Checks if the path starts with this directory name as its first component
3142/// 3. If matched, returns the path with the first component stripped
3143/// 4. If not matched, returns the original path unchanged
3144///
3145/// This approach ensures that:
3146/// - Nested directories within a category are preserved (e.g., `agents/ai/helper.md` → `ai/helper.md`)
3147/// - Paths without the expected prefix remain unchanged (backwards compatibility)
3148/// - Cross-platform path handling works correctly (Windows and Unix separators)
3149///
3150/// # Arguments
3151///
3152/// * `path` - The original resource path from the dependency specification (e.g., from a Git repository)
3153/// * `resource_type` - The resource type string: `"agent"`, `"snippet"`, `"command"`, `"script"`, `"hook"`, or `"mcp-server"`
3154///
3155/// # Returns
3156///
3157/// A [`PathBuf`] containing:
3158/// - The path with the resource type prefix removed (if the prefix matched)
3159/// - The original path unchanged (if no prefix matched)
3160///
3161/// # Resource Type Mapping
3162///
3163/// | Input Type   | Expected Directory | Example Input Path      | Example Output Path    |
3164/// |--------------|-------------------|-------------------------|------------------------|
3165/// | `"agent"`    | `agents/`         | `agents/ai/helper.md`   | `ai/helper.md`         |
3166/// | `"snippet"`  | `snippets/`       | `snippets/tools/fmt.md` | `tools/fmt.md`         |
3167/// | `"command"`  | `commands/`       | `commands/build.md`     | `build.md`             |
3168/// | `"script"`   | `scripts/`        | `scripts/test.sh`       | `test.sh`              |
3169/// | `"hook"`     | `hooks/`          | `hooks/pre-commit.json` | `pre-commit.json`      |
3170/// | `"mcp-server"` | `mcp-servers/`  | `mcp-servers/db.json`   | `db.json`              |
3171///
3172/// # Examples
3173///
3174/// ## Basic Path Extraction
3175///
3176/// ```no_run
3177/// use std::path::{Path, PathBuf};
3178/// # use agpm_cli::resolver::extract_relative_path;
3179/// # use agpm_cli::core::ResourceType;
3180///
3181/// // Resource type prefix is removed
3182/// let path = Path::new("snippets/directives/thing.md");
3183/// let result = extract_relative_path(path, &ResourceType::Snippet);
3184/// assert_eq!(result, PathBuf::from("directives/thing.md"));
3185///
3186/// // No matching prefix - path unchanged
3187/// let path = Path::new("directives/thing.md");
3188/// let result = extract_relative_path(path, &ResourceType::Snippet);
3189/// assert_eq!(result, PathBuf::from("directives/thing.md"));
3190///
3191/// // Works with deeply nested directories
3192/// let path = Path::new("agents/ai/helper.md");
3193/// let result = extract_relative_path(path, &ResourceType::Agent);
3194/// assert_eq!(result, PathBuf::from("ai/helper.md"));
3195/// ```
3196///
3197/// ## Preserving Directory Structure
3198///
3199/// ```no_run
3200/// # use std::path::{Path, PathBuf};
3201/// # use agpm_cli::resolver::extract_relative_path;
3202/// # use agpm_cli::core::ResourceType;
3203///
3204/// // Multi-level nested directories are fully preserved
3205/// let path = Path::new("agents/languages/rust/expert.md");
3206/// let result = extract_relative_path(path, &ResourceType::Agent);
3207/// assert_eq!(result, PathBuf::from("languages/rust/expert.md"));
3208/// // This will install to: .claude/agents/languages/rust/expert.md
3209/// ```
3210///
3211/// ## Pattern Matching Use Case
3212///
3213/// When used with glob patterns like `agents/**/*.md`, this function ensures each
3214/// matched file preserves its subdirectory structure:
3215///
3216/// ```no_run
3217/// # use std::path::{Path, PathBuf};
3218/// # use agpm_cli::resolver::extract_relative_path;
3219/// # use agpm_cli::core::ResourceType;
3220///
3221/// // Example: glob pattern "agents/**/*.md" matches these paths
3222/// let matched_paths = vec![
3223///     "agents/rust/expert.md",
3224///     "agents/rust/testing.md",
3225///     "agents/python/async.md",
3226///     "agents/go/concurrency.md",
3227/// ];
3228///
3229/// for path_str in matched_paths {
3230///     let path = Path::new(path_str);
3231///     let relative = extract_relative_path(path, &ResourceType::Agent);
3232///     // Produces: "rust/expert.md", "rust/testing.md", "python/async.md", "go/concurrency.md"
3233///     // Each installs to: .claude/agents/<relative_path>
3234/// }
3235/// ```
3236///
3237/// ## Integration with Custom Targets
3238///
3239/// Custom targets work in conjunction with relative path extraction:
3240///
3241/// ```toml
3242/// # In agpm.toml
3243/// [agents]
3244/// # Path: agents/rust/expert.md → extract → rust/expert.md
3245/// # Target: custom → combined → custom/rust/expert.md
3246/// # Final: .claude/agents/custom/rust/expert.md
3247/// rust-agents = {
3248///     source = "community",
3249///     path = "agents/rust/*.md",
3250///     target = "custom",
3251///     version = "v1.0.0"
3252/// }
3253/// ```
3254///
3255/// # Use Cases
3256///
3257/// ## Organized Source Repository
3258///
3259/// For a source repository with categorized resources:
3260/// ```text
3261/// agpm-community/
3262/// ├── agents/
3263/// │   ├── languages/
3264/// │   │   ├── rust/
3265/// │   │   │   ├── expert.md
3266/// │   │   │   └── testing.md
3267/// │   │   └── python/
3268/// │   │       └── async.md
3269/// │   └── tools/
3270/// │       └── git-helper.md
3271/// └── snippets/
3272///     ├── directives/
3273///     │   └── custom.md
3274///     └── templates/
3275///         └── api.md
3276/// ```
3277///
3278/// After installation, the structure is preserved:
3279/// ```text
3280/// .claude/
3281/// ├── agents/
3282/// │   ├── languages/
3283/// │   │   ├── rust/
3284/// │   │   │   ├── expert.md
3285/// │   │   │   └── testing.md
3286/// │   │   └── python/
3287/// │   │       └── async.md
3288/// │   └── tools/
3289/// │       └── git-helper.md
3290/// └── snippets/
3291///     ├── directives/
3292///     │   └── custom.md
3293///     └── templates/
3294///         └── api.md
3295/// ```
3296///
3297/// ## Pattern-Based Installation
3298///
3299/// Bulk installation with patterns preserves organization:
3300/// ```toml
3301/// [agents]
3302/// # Installs all Rust agents with subdirectory structure intact
3303/// rust-tools = { source = "community", path = "agents/languages/rust/**/*.md", version = "v1.0.0" }
3304/// # Results in: .claude/agents/<files from rust/ and subdirectories>
3305/// ```
3306///
3307/// # Implementation Notes
3308///
3309/// - Uses path component analysis for cross-platform compatibility
3310/// - Only examines the first path component to determine prefix match
3311/// - Empty paths or invalid components are handled gracefully
3312/// - Unknown resource types cause the path to be returned unchanged
3313/// - Works with both absolute and relative paths from source repositories
3314///
3315/// # Version History
3316///
3317/// - **v0.3.18**: Introduced to support relative path preservation during installation
3318/// - Works in conjunction with updated lockfile `installed_at` path generation
3319pub fn extract_relative_path(path: &Path, resource_type: &crate::core::ResourceType) -> PathBuf {
3320    // Convert resource type to expected directory name
3321    let expected_prefix = match resource_type {
3322        crate::core::ResourceType::Agent => "agents",
3323        crate::core::ResourceType::Snippet => "snippets",
3324        crate::core::ResourceType::Command => "commands",
3325        crate::core::ResourceType::Script => "scripts",
3326        crate::core::ResourceType::Hook => "hooks",
3327        crate::core::ResourceType::McpServer => "mcp-servers",
3328    };
3329
3330    // Check if path starts with the expected prefix
3331    let components: Vec<_> = path.components().collect();
3332    if let Some(first) = components.first()
3333        && let std::path::Component::Normal(name) = first
3334        && name.to_str() == Some(expected_prefix)
3335    {
3336        // Skip the first component and collect the rest
3337        let remaining: PathBuf = components[1..].iter().collect();
3338        return remaining;
3339    }
3340
3341    path.to_path_buf()
3342}
3343
3344#[cfg(test)]
3345mod tests {
3346    use super::*;
3347    use tempfile::TempDir;
3348
3349    #[test]
3350    fn test_resolver_new() {
3351        let manifest = Manifest::new();
3352        let temp_dir = TempDir::new().unwrap();
3353        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3354        let resolver = DependencyResolver::with_cache(manifest, cache);
3355
3356        assert_eq!(resolver.cache.get_cache_location(), temp_dir.path());
3357    }
3358
3359    #[tokio::test]
3360    async fn test_resolve_local_dependency() {
3361        let mut manifest = Manifest::new();
3362        manifest.add_dependency(
3363            "local-agent".to_string(),
3364            ResourceDependency::Simple("../agents/local.md".to_string()),
3365            true,
3366        );
3367
3368        let temp_dir = TempDir::new().unwrap();
3369        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3370        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3371
3372        let lockfile = resolver.resolve().await.unwrap();
3373        assert_eq!(lockfile.agents.len(), 1);
3374
3375        let entry = &lockfile.agents[0];
3376        assert_eq!(entry.name, "local-agent");
3377        assert_eq!(entry.path, "../agents/local.md");
3378        assert!(entry.source.is_none());
3379        assert!(entry.url.is_none());
3380    }
3381
3382    // Redundancy tests removed - using automatic conflict resolution
3383    #[test]
3384    #[ignore = "Redundancy checking removed - using automatic conflict resolution"]
3385    fn test_check_redundancies() {
3386        let mut manifest = Manifest::new();
3387        manifest.add_source("official".to_string(), "https://github.com/test/repo.git".to_string());
3388
3389        // Add two dependencies with different versions of the same resource
3390        manifest.add_dependency(
3391            "agent1".to_string(),
3392            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3393                source: Some("official".to_string()),
3394                path: "agents/test.md".to_string(),
3395                version: Some("v1.0.0".to_string()),
3396                branch: None,
3397                rev: None,
3398                command: None,
3399                args: None,
3400                target: None,
3401                filename: None,
3402                dependencies: None,
3403                tool: "claude-code".to_string(),
3404            })),
3405            true,
3406        );
3407
3408        manifest.add_dependency(
3409            "agent2".to_string(),
3410            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3411                source: Some("official".to_string()),
3412                path: "agents/test.md".to_string(),
3413                version: Some("v2.0.0".to_string()),
3414                branch: None,
3415                rev: None,
3416                command: None,
3417                args: None,
3418                target: None,
3419                filename: None,
3420                dependencies: None,
3421                tool: "claude-code".to_string(),
3422            })),
3423            true,
3424        );
3425
3426        let temp_dir = TempDir::new().unwrap();
3427        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3428        let _resolver = DependencyResolver::with_cache(manifest, cache);
3429    }
3430
3431    #[tokio::test]
3432    async fn test_pre_sync_sources() {
3433        // Skip test if git is not available
3434        if std::process::Command::new("git").arg("--version").output().is_err() {
3435            eprintln!("Skipping test: git not available");
3436            return;
3437        }
3438
3439        // Create a test Git repository with resources
3440        let temp_dir = TempDir::new().unwrap();
3441        let repo_dir = temp_dir.path().join("test-repo");
3442        std::fs::create_dir(&repo_dir).unwrap();
3443
3444        // Initialize git repo
3445        std::process::Command::new("git").args(["init"]).current_dir(&repo_dir).output().unwrap();
3446
3447        std::process::Command::new("git")
3448            .args(["config", "user.email", "test@example.com"])
3449            .current_dir(&repo_dir)
3450            .output()
3451            .unwrap();
3452
3453        std::process::Command::new("git")
3454            .args(["config", "user.name", "Test User"])
3455            .current_dir(&repo_dir)
3456            .output()
3457            .unwrap();
3458
3459        // Create test files
3460        std::fs::create_dir_all(repo_dir.join("agents")).unwrap();
3461        std::fs::write(repo_dir.join("agents/test.md"), "# Test Agent\n\nTest content").unwrap();
3462
3463        // Commit files
3464        std::process::Command::new("git")
3465            .args(["add", "."])
3466            .current_dir(&repo_dir)
3467            .output()
3468            .unwrap();
3469
3470        std::process::Command::new("git")
3471            .args(["commit", "-m", "Initial commit"])
3472            .current_dir(&repo_dir)
3473            .output()
3474            .unwrap();
3475
3476        std::process::Command::new("git")
3477            .args(["tag", "v1.0.0"])
3478            .current_dir(&repo_dir)
3479            .output()
3480            .unwrap();
3481
3482        // Create a manifest with a dependency from this source
3483        let mut manifest = Manifest::new();
3484        let source_url = format!("file://{}", repo_dir.display());
3485        manifest.add_source("test-source".to_string(), source_url.clone());
3486
3487        manifest.add_dependency(
3488            "test-agent".to_string(),
3489            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3490                source: Some("test-source".to_string()),
3491                path: "agents/test.md".to_string(),
3492                version: Some("v1.0.0".to_string()),
3493                branch: None,
3494                rev: None,
3495                command: None,
3496                args: None,
3497                target: None,
3498                filename: None,
3499                dependencies: None,
3500                tool: "claude-code".to_string(),
3501            })),
3502            true,
3503        );
3504
3505        // Create resolver with test cache
3506        let cache_dir = TempDir::new().unwrap();
3507        let cache = Cache::with_dir(cache_dir.path().to_path_buf()).unwrap();
3508        let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
3509
3510        // Prepare dependencies for pre-sync
3511        let deps: Vec<(String, ResourceDependency)> = manifest
3512            .all_dependencies()
3513            .into_iter()
3514            .map(|(name, dep)| (name.to_string(), dep.clone()))
3515            .collect();
3516
3517        // Call pre_sync_sources - this should clone the repository and prepare entries
3518        resolver.pre_sync_sources(&deps).await.unwrap();
3519
3520        // Verify that entries and repos are prepared
3521        assert!(
3522            resolver.version_resolver.pending_count() > 0,
3523            "Should have entries after pre-sync"
3524        );
3525
3526        let bare_repo = resolver.version_resolver.get_bare_repo_path("test-source");
3527        assert!(bare_repo.is_some(), "Should have bare repo path cached");
3528
3529        // Verify the repository exists in cache (uses normalized name)
3530        let cached_repo_path = resolver.cache.get_cache_location().join("sources");
3531
3532        // The cache normalizes the source name, so we check if any .git directory exists
3533        let mut found_repo = false;
3534        if let Ok(entries) = std::fs::read_dir(&cached_repo_path) {
3535            for entry in entries.flatten() {
3536                if let Some(name) = entry.file_name().to_str()
3537                    && name.ends_with(".git")
3538                {
3539                    found_repo = true;
3540                    break;
3541                }
3542            }
3543        }
3544        assert!(found_repo, "Repository should be cloned to cache");
3545
3546        // Now call resolve_all() - it should work without cloning again
3547        resolver.version_resolver.resolve_all().await.unwrap();
3548
3549        // Verify resolution succeeded by checking we have resolved versions
3550        let all_resolved = resolver.version_resolver.get_all_resolved();
3551        assert!(!all_resolved.is_empty(), "Resolution should produce resolved versions");
3552
3553        // Check that v1.0.0 was resolved to a SHA
3554        let key = ("test-source".to_string(), "v1.0.0".to_string());
3555        assert!(all_resolved.contains_key(&key), "Should have resolved v1.0.0");
3556
3557        let sha = all_resolved.get(&key).unwrap();
3558        assert_eq!(sha.len(), 40, "SHA should be 40 characters");
3559    }
3560
3561    #[test]
3562    fn test_verify_missing_source() {
3563        let mut manifest = Manifest::new();
3564
3565        // Add dependency without corresponding source
3566        manifest.add_dependency(
3567            "remote-agent".to_string(),
3568            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3569                source: Some("nonexistent".to_string()),
3570                path: "agents/test.md".to_string(),
3571                version: None,
3572                branch: None,
3573                rev: None,
3574                command: None,
3575                args: None,
3576                target: None,
3577                filename: None,
3578                dependencies: None,
3579                tool: "claude-code".to_string(),
3580            })),
3581            true,
3582        );
3583
3584        let temp_dir = TempDir::new().unwrap();
3585        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3586        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3587
3588        let result = resolver.verify();
3589        assert!(result.is_err());
3590        assert!(result.unwrap_err().to_string().contains("undefined source"));
3591    }
3592
3593    #[test]
3594    fn test_get_resource_type() {
3595        let mut manifest = Manifest::new();
3596        manifest.add_dependency(
3597            "agent1".to_string(),
3598            ResourceDependency::Simple("a.md".to_string()),
3599            true,
3600        );
3601        manifest.add_dependency(
3602            "snippet1".to_string(),
3603            ResourceDependency::Simple("s.md".to_string()),
3604            false,
3605        );
3606        // Remove dev-snippet1 test as dev concept is removed
3607
3608        let temp_dir = TempDir::new().unwrap();
3609        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3610        let resolver = DependencyResolver::with_cache(manifest, cache);
3611
3612        assert_eq!(resolver.get_resource_type("agent1"), crate::core::ResourceType::Agent);
3613        assert_eq!(resolver.get_resource_type("snippet1"), crate::core::ResourceType::Snippet);
3614        // Dev concept removed - no longer testing dev-agent1 and dev-snippet1
3615    }
3616
3617    #[tokio::test]
3618    async fn test_resolve_with_source_dependency() {
3619        let temp_dir = TempDir::new().unwrap();
3620
3621        // Create a local mock git repository
3622        let source_dir = temp_dir.path().join("test-source");
3623        std::fs::create_dir_all(&source_dir).unwrap();
3624        std::process::Command::new("git")
3625            .args(["init"])
3626            .current_dir(&source_dir)
3627            .output()
3628            .expect("Failed to initialize git repository");
3629
3630        // Create the agents directory and test file
3631        let agents_dir = source_dir.join("agents");
3632        std::fs::create_dir_all(&agents_dir).unwrap();
3633        std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
3634
3635        // Add and commit the file
3636        std::process::Command::new("git")
3637            .args(["add", "."])
3638            .current_dir(&source_dir)
3639            .output()
3640            .unwrap();
3641        std::process::Command::new("git")
3642            .args(["config", "user.email", "test@example.com"])
3643            .current_dir(&source_dir)
3644            .output()
3645            .unwrap();
3646        std::process::Command::new("git")
3647            .args(["config", "user.name", "Test User"])
3648            .current_dir(&source_dir)
3649            .output()
3650            .unwrap();
3651        std::process::Command::new("git")
3652            .args(["commit", "-m", "Initial commit"])
3653            .current_dir(&source_dir)
3654            .output()
3655            .unwrap();
3656
3657        // Create a tag for version
3658        std::process::Command::new("git")
3659            .args(["tag", "v1.0.0"])
3660            .current_dir(&source_dir)
3661            .output()
3662            .unwrap();
3663
3664        let mut manifest = Manifest::new();
3665        // Use file:// URL to ensure it's treated as a Git source, not a local path
3666        let source_url = format!("file://{}", source_dir.display());
3667        manifest.add_source("test".to_string(), source_url);
3668        manifest.add_dependency(
3669            "remote-agent".to_string(),
3670            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3671                source: Some("test".to_string()),
3672                path: "agents/test.md".to_string(),
3673                version: Some("v1.0.0".to_string()),
3674                branch: None,
3675                rev: None,
3676                command: None,
3677                args: None,
3678                target: None,
3679                filename: None,
3680                dependencies: None,
3681                tool: "claude-code".to_string(),
3682            })),
3683            true,
3684        );
3685
3686        let cache_dir = temp_dir.path().join("cache");
3687        let cache = Cache::with_dir(cache_dir).unwrap();
3688        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3689
3690        // This should now succeed with the local repository
3691        let result = resolver.resolve().await;
3692        assert!(result.is_ok());
3693    }
3694
3695    #[tokio::test]
3696    async fn test_resolve_with_progress() {
3697        let mut manifest = Manifest::new();
3698        manifest.add_dependency(
3699            "local".to_string(),
3700            ResourceDependency::Simple("test.md".to_string()),
3701            true,
3702        );
3703
3704        let temp_dir = TempDir::new().unwrap();
3705        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3706        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3707
3708        let lockfile = resolver.resolve().await.unwrap();
3709        assert_eq!(lockfile.agents.len(), 1);
3710    }
3711
3712    #[test]
3713    fn test_verify_with_progress() {
3714        let mut manifest = Manifest::new();
3715        manifest.add_source("test".to_string(), "https://github.com/test/repo.git".to_string());
3716        manifest.add_dependency(
3717            "agent".to_string(),
3718            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3719                source: Some("test".to_string()),
3720                path: "agents/test.md".to_string(),
3721                version: None,
3722                branch: None,
3723                rev: None,
3724                command: None,
3725                args: None,
3726                target: None,
3727                filename: None,
3728                dependencies: None,
3729                tool: "claude-code".to_string(),
3730            })),
3731            true,
3732        );
3733
3734        let temp_dir = TempDir::new().unwrap();
3735        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3736        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3737
3738        let result = resolver.verify();
3739        assert!(result.is_ok());
3740    }
3741
3742    #[tokio::test]
3743    async fn test_resolve_with_git_ref() {
3744        let temp_dir = TempDir::new().unwrap();
3745
3746        // Create a local mock git repository
3747        let source_dir = temp_dir.path().join("test-source");
3748        std::fs::create_dir_all(&source_dir).unwrap();
3749        std::process::Command::new("git")
3750            .args(["init"])
3751            .current_dir(&source_dir)
3752            .output()
3753            .expect("Failed to initialize git repository");
3754
3755        // Configure git
3756        std::process::Command::new("git")
3757            .args(["config", "user.email", "test@example.com"])
3758            .current_dir(&source_dir)
3759            .output()
3760            .unwrap();
3761        std::process::Command::new("git")
3762            .args(["config", "user.name", "Test User"])
3763            .current_dir(&source_dir)
3764            .output()
3765            .unwrap();
3766
3767        // Create the agents directory and test file
3768        let agents_dir = source_dir.join("agents");
3769        std::fs::create_dir_all(&agents_dir).unwrap();
3770        std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
3771
3772        // Add and commit the file
3773        std::process::Command::new("git")
3774            .args(["add", "."])
3775            .current_dir(&source_dir)
3776            .output()
3777            .unwrap();
3778        std::process::Command::new("git")
3779            .args(["commit", "-m", "Initial commit"])
3780            .current_dir(&source_dir)
3781            .output()
3782            .unwrap();
3783
3784        // Create main branch (git may have created master)
3785        std::process::Command::new("git")
3786            .args(["branch", "-M", "main"])
3787            .current_dir(&source_dir)
3788            .output()
3789            .unwrap();
3790
3791        let mut manifest = Manifest::new();
3792        // Use file:// URL to ensure it's treated as a Git source, not a local path
3793        let source_url = format!("file://{}", source_dir.display());
3794        manifest.add_source("test".to_string(), source_url);
3795        manifest.add_dependency(
3796            "git-agent".to_string(),
3797            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3798                source: Some("test".to_string()),
3799                path: "agents/test.md".to_string(),
3800                version: None,
3801                branch: None,
3802                rev: None,
3803                command: None,
3804                args: None,
3805                target: None,
3806                filename: None,
3807                dependencies: None,
3808                tool: "claude-code".to_string(),
3809            })),
3810            true,
3811        );
3812
3813        let cache_dir = temp_dir.path().join("cache");
3814        let cache = Cache::with_dir(cache_dir).unwrap();
3815        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3816
3817        // This should now succeed with the local repository
3818        let result = resolver.resolve().await;
3819        if let Err(e) = &result {
3820            eprintln!("Test failed with error: {:#}", e);
3821        }
3822        assert!(result.is_ok());
3823    }
3824
3825    #[tokio::test]
3826    async fn test_new_with_global() {
3827        let manifest = Manifest::new();
3828        let cache = Cache::new().unwrap();
3829        let result = DependencyResolver::new_with_global(manifest, cache).await;
3830        assert!(result.is_ok());
3831    }
3832
3833    #[test]
3834    fn test_resolver_new_default() {
3835        let manifest = Manifest::new();
3836        let cache = Cache::new().unwrap();
3837        let result = DependencyResolver::new(manifest, cache);
3838        assert!(result.is_ok());
3839    }
3840
3841    #[tokio::test]
3842    async fn test_resolve_multiple_dependencies() {
3843        let mut manifest = Manifest::new();
3844        manifest.add_dependency(
3845            "agent1".to_string(),
3846            ResourceDependency::Simple("a1.md".to_string()),
3847            true,
3848        );
3849        manifest.add_dependency(
3850            "agent2".to_string(),
3851            ResourceDependency::Simple("a2.md".to_string()),
3852            true,
3853        );
3854        manifest.add_dependency(
3855            "snippet1".to_string(),
3856            ResourceDependency::Simple("s1.md".to_string()),
3857            false,
3858        );
3859
3860        let temp_dir = TempDir::new().unwrap();
3861        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3862        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3863
3864        let lockfile = resolver.resolve().await.unwrap();
3865        assert_eq!(lockfile.agents.len(), 2);
3866        assert_eq!(lockfile.snippets.len(), 1);
3867    }
3868
3869    #[test]
3870    #[ignore = "Redundancy checking removed - using automatic conflict resolution"]
3871    fn test_check_redundancies_no_redundancy() {
3872        let mut manifest = Manifest::new();
3873        manifest.add_source("official".to_string(), "https://github.com/test/repo.git".to_string());
3874        manifest.add_dependency(
3875            "agent1".to_string(),
3876            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3877                source: Some("official".to_string()),
3878                path: "agents/test1.md".to_string(),
3879                version: Some("v1.0.0".to_string()),
3880                branch: None,
3881                rev: None,
3882                command: None,
3883                args: None,
3884                target: None,
3885                filename: None,
3886                dependencies: None,
3887                tool: "claude-code".to_string(),
3888            })),
3889            true,
3890        );
3891        manifest.add_dependency(
3892            "agent2".to_string(),
3893            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3894                source: Some("official".to_string()),
3895                path: "agents/test2.md".to_string(),
3896                version: Some("v1.0.0".to_string()),
3897                branch: None,
3898                rev: None,
3899                command: None,
3900                args: None,
3901                target: None,
3902                filename: None,
3903                dependencies: None,
3904                tool: "claude-code".to_string(),
3905            })),
3906            true,
3907        );
3908
3909        let temp_dir = TempDir::new().unwrap();
3910        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3911        let _resolver = DependencyResolver::with_cache(manifest, cache);
3912    }
3913
3914    #[test]
3915    fn test_verify_local_dependency() {
3916        let mut manifest = Manifest::new();
3917        manifest.add_dependency(
3918            "local-agent".to_string(),
3919            ResourceDependency::Simple("../local/agent.md".to_string()),
3920            true,
3921        );
3922
3923        let temp_dir = TempDir::new().unwrap();
3924        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3925        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3926
3927        let result = resolver.verify();
3928        assert!(result.is_ok());
3929    }
3930
3931    #[tokio::test]
3932    async fn test_resolve_with_empty_manifest() {
3933        let manifest = Manifest::new();
3934        let temp_dir = TempDir::new().unwrap();
3935        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3936        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3937
3938        let lockfile = resolver.resolve().await.unwrap();
3939        assert_eq!(lockfile.agents.len(), 0);
3940        assert_eq!(lockfile.snippets.len(), 0);
3941        assert_eq!(lockfile.sources.len(), 0);
3942    }
3943
3944    #[tokio::test]
3945    async fn test_resolve_with_custom_target() {
3946        let mut manifest = Manifest::new();
3947
3948        // Add local dependency with custom target
3949        manifest.add_dependency(
3950            "custom-agent".to_string(),
3951            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3952                source: None,
3953                path: "../test.md".to_string(),
3954                version: None,
3955                branch: None,
3956                rev: None,
3957                command: None,
3958                args: None,
3959                target: Some("integrations/custom".to_string()),
3960                filename: None,
3961                dependencies: None,
3962                tool: "claude-code".to_string(),
3963            })),
3964            true,
3965        );
3966
3967        let temp_dir = TempDir::new().unwrap();
3968        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
3969        let mut resolver = DependencyResolver::with_cache(manifest, cache);
3970
3971        let lockfile = resolver.resolve().await.unwrap();
3972        assert_eq!(lockfile.agents.len(), 1);
3973
3974        let agent = &lockfile.agents[0];
3975        assert_eq!(agent.name, "custom-agent");
3976        // Verify the custom target is relative to the default agents directory
3977        // Normalize path separators for cross-platform testing
3978        let normalized_path = agent.installed_at.replace('\\', "/");
3979        assert!(normalized_path.contains(".claude/agents/integrations/custom"));
3980        assert_eq!(normalized_path, ".claude/agents/integrations/custom/custom-agent.md");
3981    }
3982
3983    #[tokio::test]
3984    async fn test_resolve_without_custom_target() {
3985        let mut manifest = Manifest::new();
3986
3987        // Add local dependency without custom target
3988        manifest.add_dependency(
3989            "standard-agent".to_string(),
3990            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
3991                source: None,
3992                path: "../test.md".to_string(),
3993                version: None,
3994                branch: None,
3995                rev: None,
3996                command: None,
3997                args: None,
3998                target: None,
3999                filename: None,
4000                dependencies: None,
4001                tool: "claude-code".to_string(),
4002            })),
4003            true,
4004        );
4005
4006        let temp_dir = TempDir::new().unwrap();
4007        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4008        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4009
4010        let lockfile = resolver.resolve().await.unwrap();
4011        assert_eq!(lockfile.agents.len(), 1);
4012
4013        let agent = &lockfile.agents[0];
4014        assert_eq!(agent.name, "standard-agent");
4015        // Verify the default target is used
4016        // Normalize path separators for cross-platform testing
4017        let normalized_path = agent.installed_at.replace('\\', "/");
4018        assert_eq!(normalized_path, ".claude/agents/standard-agent.md");
4019    }
4020
4021    #[tokio::test]
4022    async fn test_resolve_with_custom_filename() {
4023        let mut manifest = Manifest::new();
4024
4025        // Add local dependency with custom filename
4026        manifest.add_dependency(
4027            "my-agent".to_string(),
4028            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4029                source: None,
4030                path: "../test.md".to_string(),
4031                version: None,
4032                branch: None,
4033                rev: None,
4034                command: None,
4035                args: None,
4036                target: None,
4037                filename: Some("ai-assistant.txt".to_string()),
4038                dependencies: None,
4039                tool: "claude-code".to_string(),
4040            })),
4041            true,
4042        );
4043
4044        let temp_dir = TempDir::new().unwrap();
4045        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4046        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4047
4048        let lockfile = resolver.resolve().await.unwrap();
4049        assert_eq!(lockfile.agents.len(), 1);
4050
4051        let agent = &lockfile.agents[0];
4052        assert_eq!(agent.name, "my-agent");
4053        // Verify the custom filename is used
4054        // Normalize path separators for cross-platform testing
4055        let normalized_path = agent.installed_at.replace('\\', "/");
4056        assert_eq!(normalized_path, ".claude/agents/ai-assistant.txt");
4057    }
4058
4059    #[tokio::test]
4060    async fn test_resolve_with_custom_filename_and_target() {
4061        let mut manifest = Manifest::new();
4062
4063        // Add local dependency with both custom filename and target
4064        manifest.add_dependency(
4065            "special-tool".to_string(),
4066            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4067                source: None,
4068                path: "../test.md".to_string(),
4069                version: None,
4070                branch: None,
4071                rev: None,
4072                command: None,
4073                args: None,
4074                target: Some("tools/ai".to_string()),
4075                filename: Some("assistant.markdown".to_string()),
4076                dependencies: None,
4077                tool: "claude-code".to_string(),
4078            })),
4079            true,
4080        );
4081
4082        let temp_dir = TempDir::new().unwrap();
4083        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4084        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4085
4086        let lockfile = resolver.resolve().await.unwrap();
4087        assert_eq!(lockfile.agents.len(), 1);
4088
4089        let agent = &lockfile.agents[0];
4090        assert_eq!(agent.name, "special-tool");
4091        // Verify both custom target and filename are used
4092        // Custom target is relative to default agents directory
4093        // Normalize path separators for cross-platform testing
4094        let normalized_path = agent.installed_at.replace('\\', "/");
4095        assert_eq!(normalized_path, ".claude/agents/tools/ai/assistant.markdown");
4096    }
4097
4098    #[tokio::test]
4099    async fn test_resolve_script_with_custom_filename() {
4100        let mut manifest = Manifest::new();
4101
4102        // Add script with custom filename (different extension)
4103        manifest.add_dependency(
4104            "analyzer".to_string(),
4105            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4106                source: None,
4107                path: "../scripts/data-analyzer-v3.py".to_string(),
4108                version: None,
4109                branch: None,
4110                rev: None,
4111                command: None,
4112                args: None,
4113                target: None,
4114                filename: Some("analyze.py".to_string()),
4115                dependencies: None,
4116                tool: "claude-code".to_string(),
4117            })),
4118            false, // script (not agent)
4119        );
4120
4121        let temp_dir = TempDir::new().unwrap();
4122        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4123        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4124
4125        let lockfile = resolver.resolve().await.unwrap();
4126        // Scripts should be in snippets array for now (based on false flag)
4127        assert_eq!(lockfile.snippets.len(), 1);
4128
4129        let script = &lockfile.snippets[0];
4130        assert_eq!(script.name, "analyzer");
4131        // Verify custom filename is used (with custom extension)
4132        // Normalize path separators for cross-platform testing
4133        let normalized_path = script.installed_at.replace('\\', "/");
4134        assert_eq!(normalized_path, ".claude/agpm/snippets/analyze.py");
4135    }
4136
4137    // ============ NEW TESTS FOR UNCOVERED AREAS ============
4138
4139    // Disable pattern tests for now as they require changing directory which breaks parallel test safety
4140    // These tests would need to be rewritten to not use pattern dependencies or
4141    // the resolver would need to support absolute base paths for pattern resolution
4142
4143    #[tokio::test]
4144    async fn test_resolve_pattern_dependency_local() {
4145        let temp_dir = TempDir::new().unwrap();
4146        let project_dir = temp_dir.path();
4147
4148        // Create local agent files
4149        let agents_dir = project_dir.join("agents");
4150        std::fs::create_dir_all(&agents_dir).unwrap();
4151        std::fs::write(agents_dir.join("helper.md"), "# Helper Agent").unwrap();
4152        std::fs::write(agents_dir.join("assistant.md"), "# Assistant Agent").unwrap();
4153        std::fs::write(agents_dir.join("tester.md"), "# Tester Agent").unwrap();
4154
4155        // Create manifest with local pattern dependency
4156        let mut manifest = Manifest::new();
4157        manifest.add_dependency(
4158            "local-agents".to_string(),
4159            ResourceDependency::Simple(format!("{}/agents/*.md", project_dir.display())),
4160            true,
4161        );
4162
4163        // Create resolver and resolve dependencies
4164        let cache_dir = temp_dir.path().join("cache");
4165        let cache = Cache::with_dir(cache_dir).unwrap();
4166        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4167
4168        let lockfile = resolver.resolve().await.unwrap();
4169
4170        // Verify all agents were resolved
4171        assert_eq!(lockfile.agents.len(), 3);
4172        let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
4173        assert!(agent_names.contains(&"helper".to_string()));
4174        assert!(agent_names.contains(&"assistant".to_string()));
4175        assert!(agent_names.contains(&"tester".to_string()));
4176    }
4177
4178    #[tokio::test]
4179    async fn test_resolve_pattern_dependency_remote() {
4180        let temp_dir = TempDir::new().unwrap();
4181
4182        // Create a local mock git repository with pattern-matching files
4183        let source_dir = temp_dir.path().join("test-source");
4184        std::fs::create_dir_all(&source_dir).unwrap();
4185        std::process::Command::new("git")
4186            .args(["init"])
4187            .current_dir(&source_dir)
4188            .output()
4189            .expect("Failed to initialize git repository");
4190
4191        // Configure git
4192        std::process::Command::new("git")
4193            .args(["config", "user.email", "test@example.com"])
4194            .current_dir(&source_dir)
4195            .output()
4196            .unwrap();
4197        std::process::Command::new("git")
4198            .args(["config", "user.name", "Test User"])
4199            .current_dir(&source_dir)
4200            .output()
4201            .unwrap();
4202
4203        // Create multiple agent files
4204        let agents_dir = source_dir.join("agents");
4205        std::fs::create_dir_all(&agents_dir).unwrap();
4206        std::fs::write(agents_dir.join("python-linter.md"), "# Python Linter").unwrap();
4207        std::fs::write(agents_dir.join("python-formatter.md"), "# Python Formatter").unwrap();
4208        std::fs::write(agents_dir.join("rust-linter.md"), "# Rust Linter").unwrap();
4209
4210        // Add and commit
4211        std::process::Command::new("git")
4212            .args(["add", "."])
4213            .current_dir(&source_dir)
4214            .output()
4215            .unwrap();
4216        std::process::Command::new("git")
4217            .args(["commit", "-m", "Add agents"])
4218            .current_dir(&source_dir)
4219            .output()
4220            .unwrap();
4221
4222        // Create a tag
4223        std::process::Command::new("git")
4224            .args(["tag", "v1.0.0"])
4225            .current_dir(&source_dir)
4226            .output()
4227            .unwrap();
4228
4229        let mut manifest = Manifest::new();
4230        // Use file:// URL to ensure it's treated as a Git source, not a local path
4231        let source_url = format!("file://{}", source_dir.display());
4232        manifest.add_source("test".to_string(), source_url);
4233
4234        // Add pattern dependency for python agents
4235        manifest.add_dependency(
4236            "python-tools".to_string(),
4237            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4238                source: Some("test".to_string()),
4239                path: "agents/python-*.md".to_string(),
4240                version: Some("v1.0.0".to_string()),
4241                branch: None,
4242                rev: None,
4243                command: None,
4244                args: None,
4245                target: None,
4246                filename: None,
4247                dependencies: None,
4248                tool: "claude-code".to_string(),
4249            })),
4250            true, // agents
4251        );
4252
4253        let cache_dir = temp_dir.path().join("cache");
4254        let cache = Cache::with_dir(cache_dir).unwrap();
4255        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4256
4257        let lockfile = resolver.resolve().await.unwrap();
4258        // Should have resolved to 2 python agents
4259        assert_eq!(lockfile.agents.len(), 2);
4260
4261        // Check that both python agents were found
4262        let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
4263        assert!(agent_names.contains(&"python-linter".to_string()));
4264        assert!(agent_names.contains(&"python-formatter".to_string()));
4265        assert!(!agent_names.contains(&"rust-linter".to_string()));
4266    }
4267
4268    #[tokio::test]
4269    async fn test_resolve_pattern_dependency_with_custom_target() {
4270        let temp_dir = TempDir::new().unwrap();
4271        let project_dir = temp_dir.path();
4272
4273        // Create local agent files
4274        let agents_dir = project_dir.join("agents");
4275        std::fs::create_dir_all(&agents_dir).unwrap();
4276        std::fs::write(agents_dir.join("helper.md"), "# Helper Agent").unwrap();
4277        std::fs::write(agents_dir.join("assistant.md"), "# Assistant Agent").unwrap();
4278
4279        // Create manifest with local pattern dependency and custom target
4280        let mut manifest = Manifest::new();
4281        manifest.add_dependency(
4282            "custom-agents".to_string(),
4283            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4284                source: None,
4285                path: format!("{}/agents/*.md", project_dir.display()),
4286                version: None,
4287                branch: None,
4288                rev: None,
4289                command: None,
4290                args: None,
4291                target: Some("custom/agents".to_string()),
4292                filename: None,
4293                dependencies: None,
4294                tool: "claude-code".to_string(),
4295            })),
4296            true,
4297        );
4298
4299        // Create resolver and resolve dependencies
4300        let cache_dir = temp_dir.path().join("cache");
4301        let cache = Cache::with_dir(cache_dir).unwrap();
4302        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4303
4304        let lockfile = resolver.resolve().await.unwrap();
4305
4306        // Verify agents were resolved with custom target
4307        // Custom target is relative to default agents directory
4308        assert_eq!(lockfile.agents.len(), 2);
4309        for agent in &lockfile.agents {
4310            assert!(agent.installed_at.starts_with(".claude/agents/custom/agents/"));
4311        }
4312
4313        let agent_names: Vec<String> = lockfile.agents.iter().map(|a| a.name.clone()).collect();
4314        assert!(agent_names.contains(&"helper".to_string()));
4315        assert!(agent_names.contains(&"assistant".to_string()));
4316    }
4317
4318    #[tokio::test]
4319    async fn test_update_specific_dependencies() {
4320        let temp_dir = TempDir::new().unwrap();
4321
4322        // Create a local mock git repository
4323        let source_dir = temp_dir.path().join("test-source");
4324        std::fs::create_dir_all(&source_dir).unwrap();
4325        std::process::Command::new("git")
4326            .args(["init"])
4327            .current_dir(&source_dir)
4328            .output()
4329            .expect("Failed to initialize git repository");
4330
4331        // Configure git
4332        std::process::Command::new("git")
4333            .args(["config", "user.email", "test@example.com"])
4334            .current_dir(&source_dir)
4335            .output()
4336            .unwrap();
4337        std::process::Command::new("git")
4338            .args(["config", "user.name", "Test User"])
4339            .current_dir(&source_dir)
4340            .output()
4341            .unwrap();
4342
4343        // Create initial files
4344        let agents_dir = source_dir.join("agents");
4345        std::fs::create_dir_all(&agents_dir).unwrap();
4346        std::fs::write(agents_dir.join("agent1.md"), "# Agent 1 v1").unwrap();
4347        std::fs::write(agents_dir.join("agent2.md"), "# Agent 2 v1").unwrap();
4348
4349        // Initial commit
4350        std::process::Command::new("git")
4351            .args(["add", "."])
4352            .current_dir(&source_dir)
4353            .output()
4354            .unwrap();
4355        std::process::Command::new("git")
4356            .args(["commit", "-m", "Initial"])
4357            .current_dir(&source_dir)
4358            .output()
4359            .unwrap();
4360        std::process::Command::new("git")
4361            .args(["tag", "v1.0.0"])
4362            .current_dir(&source_dir)
4363            .output()
4364            .unwrap();
4365
4366        // Update agent1 and create v2.0.0
4367        std::fs::write(agents_dir.join("agent1.md"), "# Agent 1 v2").unwrap();
4368        std::process::Command::new("git")
4369            .args(["add", "."])
4370            .current_dir(&source_dir)
4371            .output()
4372            .unwrap();
4373        std::process::Command::new("git")
4374            .args(["commit", "-m", "Update agent1"])
4375            .current_dir(&source_dir)
4376            .output()
4377            .unwrap();
4378        std::process::Command::new("git")
4379            .args(["tag", "v2.0.0"])
4380            .current_dir(&source_dir)
4381            .output()
4382            .unwrap();
4383
4384        let mut manifest = Manifest::new();
4385        // Use file:// URL to ensure it's treated as a Git source, not a local path
4386        let source_url = format!("file://{}", source_dir.display());
4387        manifest.add_source("test".to_string(), source_url);
4388
4389        // Add dependencies - initially both at v1.0.0
4390        manifest.add_dependency(
4391            "agent1".to_string(),
4392            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4393                source: Some("test".to_string()),
4394                path: "agents/agent1.md".to_string(),
4395                version: Some("v1.0.0".to_string()), // Start with v1.0.0
4396                branch: None,
4397                rev: None,
4398                command: None,
4399                args: None,
4400                target: None,
4401                filename: None,
4402                dependencies: None,
4403                tool: "claude-code".to_string(),
4404            })),
4405            true,
4406        );
4407        manifest.add_dependency(
4408            "agent2".to_string(),
4409            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4410                source: Some("test".to_string()),
4411                path: "agents/agent2.md".to_string(),
4412                version: Some("v1.0.0".to_string()), // Start with v1.0.0
4413                branch: None,
4414                rev: None,
4415                command: None,
4416                args: None,
4417                target: None,
4418                filename: None,
4419                dependencies: None,
4420                tool: "claude-code".to_string(),
4421            })),
4422            true,
4423        );
4424
4425        let cache_dir = temp_dir.path().join("cache");
4426        let cache = Cache::with_dir(cache_dir.clone()).unwrap();
4427        let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
4428
4429        // First resolve with v1.0.0 for both
4430        let initial_lockfile = resolver.resolve().await.unwrap();
4431        assert_eq!(initial_lockfile.agents.len(), 2);
4432
4433        // Create a new manifest with agent1 updated to v2.0.0
4434        let mut updated_manifest = Manifest::new();
4435        // Use file:// URL to ensure it's treated as a Git source, not a local path
4436        updated_manifest.add_source("test".to_string(), format!("file://{}", source_dir.display()));
4437        updated_manifest.add_dependency(
4438            "agent1".to_string(),
4439            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4440                source: Some("test".to_string()),
4441                path: "agents/agent1.md".to_string(),
4442                version: Some("v2.0.0".to_string()),
4443                branch: None,
4444                rev: None,
4445                command: None,
4446                args: None,
4447                target: None,
4448                filename: None,
4449                dependencies: None,
4450                tool: "claude-code".to_string(),
4451            })),
4452            true,
4453        );
4454        updated_manifest.add_dependency(
4455            "agent2".to_string(),
4456            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4457                source: Some("test".to_string()),
4458                path: "agents/agent2.md".to_string(),
4459                version: Some("v1.0.0".to_string()), // Keep v1.0.0
4460                branch: None,
4461                rev: None,
4462                command: None,
4463                args: None,
4464                target: None,
4465                filename: None,
4466                dependencies: None,
4467                tool: "claude-code".to_string(),
4468            })),
4469            true,
4470        );
4471
4472        // Now update only agent1
4473        let cache2 = Cache::with_dir(cache_dir).unwrap();
4474        let mut resolver2 = DependencyResolver::with_cache(updated_manifest, cache2);
4475        let updated_lockfile =
4476            resolver2.update(&initial_lockfile, Some(vec!["agent1".to_string()])).await.unwrap();
4477
4478        // agent1 should be updated to v2.0.0
4479        let agent1 = updated_lockfile
4480            .agents
4481            .iter()
4482            .find(|a| a.name == "agent1" && a.version.as_deref() == Some("v2.0.0"))
4483            .unwrap();
4484        assert_eq!(agent1.version.as_ref().unwrap(), "v2.0.0");
4485
4486        // agent2 should remain at v1.0.0
4487        let agent2 = updated_lockfile
4488            .agents
4489            .iter()
4490            .find(|a| a.name == "agent2" && a.version.as_deref() == Some("v1.0.0"))
4491            .unwrap();
4492        assert_eq!(agent2.version.as_ref().unwrap(), "v1.0.0");
4493    }
4494
4495    #[tokio::test]
4496    async fn test_update_all_dependencies() {
4497        let mut manifest = Manifest::new();
4498        manifest.add_dependency(
4499            "local1".to_string(),
4500            ResourceDependency::Simple("../a1.md".to_string()),
4501            true,
4502        );
4503        manifest.add_dependency(
4504            "local2".to_string(),
4505            ResourceDependency::Simple("../a2.md".to_string()),
4506            true,
4507        );
4508
4509        let temp_dir = TempDir::new().unwrap();
4510        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4511        let mut resolver = DependencyResolver::with_cache(manifest.clone(), cache);
4512
4513        // Initial resolve
4514        let initial_lockfile = resolver.resolve().await.unwrap();
4515        assert_eq!(initial_lockfile.agents.len(), 2);
4516
4517        // Update all (None means update all)
4518        let cache2 = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4519        let mut resolver2 = DependencyResolver::with_cache(manifest, cache2);
4520        let updated_lockfile = resolver2.update(&initial_lockfile, None).await.unwrap();
4521
4522        // All dependencies should be present
4523        assert_eq!(updated_lockfile.agents.len(), 2);
4524    }
4525
4526    // NOTE: Comprehensive integration tests for update() with transitive dependencies
4527    // are in tests/integration_incremental_add.rs. These provide end-to-end testing
4528    // of the incremental `agpm add dep` scenario which exercises the update() method.
4529
4530    #[tokio::test]
4531    async fn test_resolve_hooks_resource_type() {
4532        let mut manifest = Manifest::new();
4533
4534        // Add hook dependencies
4535        manifest.hooks.insert(
4536            "pre-commit".to_string(),
4537            ResourceDependency::Simple("../hooks/pre-commit.json".to_string()),
4538        );
4539        manifest.hooks.insert(
4540            "post-commit".to_string(),
4541            ResourceDependency::Simple("../hooks/post-commit.json".to_string()),
4542        );
4543
4544        let temp_dir = TempDir::new().unwrap();
4545        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4546        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4547
4548        let lockfile = resolver.resolve().await.unwrap();
4549        assert_eq!(lockfile.hooks.len(), 2);
4550
4551        // Check that hooks point to the config file where they're configured
4552        for hook in &lockfile.hooks {
4553            assert_eq!(
4554                hook.installed_at, ".claude/settings.local.json",
4555                "Hooks should reference the config file where they're configured"
4556            );
4557        }
4558    }
4559
4560    #[tokio::test]
4561    async fn test_resolve_scripts_resource_type() {
4562        let mut manifest = Manifest::new();
4563
4564        // Add script dependencies
4565        manifest.scripts.insert(
4566            "build".to_string(),
4567            ResourceDependency::Simple("../scripts/build.sh".to_string()),
4568        );
4569        manifest.scripts.insert(
4570            "test".to_string(),
4571            ResourceDependency::Simple("../scripts/test.py".to_string()),
4572        );
4573
4574        let temp_dir = TempDir::new().unwrap();
4575        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4576        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4577
4578        let lockfile = resolver.resolve().await.unwrap();
4579        assert_eq!(lockfile.scripts.len(), 2);
4580
4581        // Check that scripts maintain their extensions
4582        let build_script = lockfile.scripts.iter().find(|s| s.name == "build").unwrap();
4583        assert!(build_script.installed_at.ends_with("build.sh"));
4584
4585        let test_script = lockfile.scripts.iter().find(|s| s.name == "test").unwrap();
4586        assert!(test_script.installed_at.ends_with("test.py"));
4587    }
4588
4589    #[tokio::test]
4590    async fn test_resolve_mcp_servers_resource_type() {
4591        let mut manifest = Manifest::new();
4592
4593        // Add MCP server dependencies
4594        manifest.mcp_servers.insert(
4595            "filesystem".to_string(),
4596            ResourceDependency::Simple("../mcp/filesystem.json".to_string()),
4597        );
4598        manifest.mcp_servers.insert(
4599            "database".to_string(),
4600            ResourceDependency::Simple("../mcp/database.json".to_string()),
4601        );
4602
4603        let temp_dir = TempDir::new().unwrap();
4604        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4605        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4606
4607        let lockfile = resolver.resolve().await.unwrap();
4608        assert_eq!(lockfile.mcp_servers.len(), 2);
4609
4610        // Check that MCP servers point to the config file where they're configured
4611        for server in &lockfile.mcp_servers {
4612            assert_eq!(
4613                server.installed_at, ".mcp.json",
4614                "MCP servers should reference the config file where they're configured"
4615            );
4616        }
4617    }
4618
4619    #[tokio::test]
4620    async fn test_resolve_commands_resource_type() {
4621        let mut manifest = Manifest::new();
4622
4623        // Add command dependencies
4624        manifest.commands.insert(
4625            "deploy".to_string(),
4626            ResourceDependency::Simple("../commands/deploy.md".to_string()),
4627        );
4628        manifest.commands.insert(
4629            "lint".to_string(),
4630            ResourceDependency::Simple("../commands/lint.md".to_string()),
4631        );
4632
4633        let temp_dir = TempDir::new().unwrap();
4634        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4635        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4636
4637        let lockfile = resolver.resolve().await.unwrap();
4638        assert_eq!(lockfile.commands.len(), 2);
4639
4640        // Check that commands are installed to the correct location
4641        for command in &lockfile.commands {
4642            // Normalize path separators for cross-platform testing
4643            let normalized_path = command.installed_at.replace('\\', "/");
4644            assert!(normalized_path.contains(".claude/commands/"));
4645            assert!(command.installed_at.ends_with(".md"));
4646        }
4647    }
4648
4649    #[tokio::test]
4650    async fn test_checkout_version_with_constraint() {
4651        let temp_dir = TempDir::new().unwrap();
4652
4653        // Create a git repo with multiple version tags
4654        let source_dir = temp_dir.path().join("test-source");
4655        std::fs::create_dir_all(&source_dir).unwrap();
4656        std::process::Command::new("git")
4657            .args(["init"])
4658            .current_dir(&source_dir)
4659            .output()
4660            .expect("Failed to initialize git repository");
4661
4662        // Configure git
4663        std::process::Command::new("git")
4664            .args(["config", "user.email", "test@example.com"])
4665            .current_dir(&source_dir)
4666            .output()
4667            .unwrap();
4668        std::process::Command::new("git")
4669            .args(["config", "user.name", "Test User"])
4670            .current_dir(&source_dir)
4671            .output()
4672            .unwrap();
4673
4674        // Create file and make commits with version tags
4675        let test_file = source_dir.join("test.txt");
4676
4677        // v1.0.0
4678        std::fs::write(&test_file, "v1.0.0").unwrap();
4679        std::process::Command::new("git")
4680            .args(["add", "."])
4681            .current_dir(&source_dir)
4682            .output()
4683            .unwrap();
4684        std::process::Command::new("git")
4685            .args(["commit", "-m", "v1.0.0"])
4686            .current_dir(&source_dir)
4687            .output()
4688            .unwrap();
4689        std::process::Command::new("git")
4690            .args(["tag", "v1.0.0"])
4691            .current_dir(&source_dir)
4692            .output()
4693            .unwrap();
4694
4695        // v1.1.0
4696        std::fs::write(&test_file, "v1.1.0").unwrap();
4697        std::process::Command::new("git")
4698            .args(["add", "."])
4699            .current_dir(&source_dir)
4700            .output()
4701            .unwrap();
4702        std::process::Command::new("git")
4703            .args(["commit", "-m", "v1.1.0"])
4704            .current_dir(&source_dir)
4705            .output()
4706            .unwrap();
4707        std::process::Command::new("git")
4708            .args(["tag", "v1.1.0"])
4709            .current_dir(&source_dir)
4710            .output()
4711            .unwrap();
4712
4713        // v1.2.0
4714        std::fs::write(&test_file, "v1.2.0").unwrap();
4715        std::process::Command::new("git")
4716            .args(["add", "."])
4717            .current_dir(&source_dir)
4718            .output()
4719            .unwrap();
4720        std::process::Command::new("git")
4721            .args(["commit", "-m", "v1.2.0"])
4722            .current_dir(&source_dir)
4723            .output()
4724            .unwrap();
4725        std::process::Command::new("git")
4726            .args(["tag", "v1.2.0"])
4727            .current_dir(&source_dir)
4728            .output()
4729            .unwrap();
4730
4731        // v2.0.0
4732        std::fs::write(&test_file, "v2.0.0").unwrap();
4733        std::process::Command::new("git")
4734            .args(["add", "."])
4735            .current_dir(&source_dir)
4736            .output()
4737            .unwrap();
4738        std::process::Command::new("git")
4739            .args(["commit", "-m", "v2.0.0"])
4740            .current_dir(&source_dir)
4741            .output()
4742            .unwrap();
4743        std::process::Command::new("git")
4744            .args(["tag", "v2.0.0"])
4745            .current_dir(&source_dir)
4746            .output()
4747            .unwrap();
4748
4749        let mut manifest = Manifest::new();
4750        // Use file:// URL to ensure it's treated as a Git source, not a local path
4751        let source_url = format!("file://{}", source_dir.display());
4752        manifest.add_source("test".to_string(), source_url);
4753
4754        // Test version constraint resolution (^1.0.0 should resolve to 1.2.0)
4755        manifest.add_dependency(
4756            "constrained-dep".to_string(),
4757            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4758                source: Some("test".to_string()),
4759                path: "test.txt".to_string(),
4760                version: Some("^1.0.0".to_string()), // Constraint: compatible with 1.x.x
4761                branch: None,
4762                rev: None,
4763                command: None,
4764                args: None,
4765                target: None,
4766                filename: None,
4767                dependencies: None,
4768                tool: "claude-code".to_string(),
4769            })),
4770            true,
4771        );
4772
4773        let cache_dir = temp_dir.path().join("cache");
4774        let cache = Cache::with_dir(cache_dir).unwrap();
4775        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4776
4777        let lockfile = resolver.resolve().await.unwrap();
4778        assert_eq!(lockfile.agents.len(), 1);
4779
4780        let agent = &lockfile.agents[0];
4781        // Should resolve to highest 1.x version (1.2.0), not 2.0.0
4782        assert_eq!(agent.version.as_ref().unwrap(), "v1.2.0");
4783    }
4784
4785    #[tokio::test]
4786    async fn test_verify_absolute_path_error() {
4787        let mut manifest = Manifest::new();
4788
4789        // Add dependency with non-existent absolute path
4790        // Use platform-specific absolute path
4791        let nonexistent_path = if cfg!(windows) {
4792            "C:\\nonexistent\\path\\agent.md"
4793        } else {
4794            "/nonexistent/path/agent.md"
4795        };
4796
4797        manifest.add_dependency(
4798            "missing-agent".to_string(),
4799            ResourceDependency::Simple(nonexistent_path.to_string()),
4800            true,
4801        );
4802
4803        let temp_dir = TempDir::new().unwrap();
4804        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4805        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4806
4807        let result = resolver.verify();
4808        assert!(result.is_err());
4809        assert!(result.unwrap_err().to_string().contains("not found"));
4810    }
4811
4812    #[tokio::test]
4813    async fn test_resolve_pattern_dependency_error() {
4814        let mut manifest = Manifest::new();
4815
4816        // Add pattern dependency without source (should error in resolve_pattern_dependency)
4817        manifest.add_dependency(
4818            "pattern-dep".to_string(),
4819            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4820                source: Some("nonexistent".to_string()),
4821                path: "agents/*.md".to_string(), // Pattern path
4822                version: None,
4823                branch: None,
4824                rev: None,
4825                command: None,
4826                args: None,
4827                target: None,
4828                filename: None,
4829                dependencies: None,
4830                tool: "claude-code".to_string(),
4831            })),
4832            true,
4833        );
4834
4835        let temp_dir = TempDir::new().unwrap();
4836        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
4837        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4838
4839        let result = resolver.resolve().await;
4840        assert!(result.is_err());
4841    }
4842
4843    #[tokio::test]
4844    async fn test_checkout_version_with_branch() {
4845        let temp_dir = TempDir::new().unwrap();
4846
4847        // Create a git repo with a branch
4848        let source_dir = temp_dir.path().join("test-source");
4849        std::fs::create_dir_all(&source_dir).unwrap();
4850        std::process::Command::new("git")
4851            .args(["init"])
4852            .current_dir(&source_dir)
4853            .output()
4854            .expect("Failed to initialize git repository");
4855
4856        // Configure git
4857        std::process::Command::new("git")
4858            .args(["config", "user.email", "test@example.com"])
4859            .current_dir(&source_dir)
4860            .output()
4861            .unwrap();
4862        std::process::Command::new("git")
4863            .args(["config", "user.name", "Test User"])
4864            .current_dir(&source_dir)
4865            .output()
4866            .unwrap();
4867
4868        // Create initial commit on main
4869        let test_file = source_dir.join("test.txt");
4870        std::fs::write(&test_file, "main").unwrap();
4871        std::process::Command::new("git")
4872            .args(["add", "."])
4873            .current_dir(&source_dir)
4874            .output()
4875            .unwrap();
4876        std::process::Command::new("git")
4877            .args(["commit", "-m", "Initial"])
4878            .current_dir(&source_dir)
4879            .output()
4880            .unwrap();
4881
4882        // Create and switch to develop branch
4883        std::process::Command::new("git")
4884            .args(["checkout", "-b", "develop"])
4885            .current_dir(&source_dir)
4886            .output()
4887            .unwrap();
4888
4889        // Make a commit on develop
4890        std::fs::write(&test_file, "develop").unwrap();
4891        std::process::Command::new("git")
4892            .args(["add", "."])
4893            .current_dir(&source_dir)
4894            .output()
4895            .unwrap();
4896        std::process::Command::new("git")
4897            .args(["commit", "-m", "Develop commit"])
4898            .current_dir(&source_dir)
4899            .output()
4900            .unwrap();
4901
4902        let mut manifest = Manifest::new();
4903        // Use file:// URL to ensure it's treated as a Git source, not a local path
4904        let source_url = format!("file://{}", source_dir.display());
4905        manifest.add_source("test".to_string(), source_url);
4906
4907        // Test branch checkout
4908        manifest.add_dependency(
4909            "branch-dep".to_string(),
4910            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4911                source: Some("test".to_string()),
4912                path: "test.txt".to_string(),
4913                version: Some("develop".to_string()), // Branch name
4914                branch: None,
4915                rev: None,
4916                command: None,
4917                args: None,
4918                target: None,
4919                filename: None,
4920                dependencies: None,
4921                tool: "claude-code".to_string(),
4922            })),
4923            true,
4924        );
4925
4926        let cache_dir = temp_dir.path().join("cache");
4927        let cache = Cache::with_dir(cache_dir).unwrap();
4928        let mut resolver = DependencyResolver::with_cache(manifest, cache);
4929
4930        let lockfile = resolver.resolve().await.unwrap();
4931        assert_eq!(lockfile.agents.len(), 1);
4932
4933        // Should have resolved to develop branch
4934        let agent = &lockfile.agents[0];
4935        assert!(agent.resolved_commit.is_some());
4936    }
4937
4938    #[tokio::test]
4939    async fn test_checkout_version_with_commit_hash() {
4940        let temp_dir = TempDir::new().unwrap();
4941
4942        // Create a git repo
4943        let source_dir = temp_dir.path().join("test-source");
4944        std::fs::create_dir_all(&source_dir).unwrap();
4945        std::process::Command::new("git")
4946            .args(["init"])
4947            .current_dir(&source_dir)
4948            .output()
4949            .expect("Failed to initialize git repository");
4950
4951        // Configure git
4952        std::process::Command::new("git")
4953            .args(["config", "user.email", "test@example.com"])
4954            .current_dir(&source_dir)
4955            .output()
4956            .unwrap();
4957        std::process::Command::new("git")
4958            .args(["config", "user.name", "Test User"])
4959            .current_dir(&source_dir)
4960            .output()
4961            .unwrap();
4962
4963        // Create a commit
4964        let test_file = source_dir.join("test.txt");
4965        std::fs::write(&test_file, "content").unwrap();
4966        std::process::Command::new("git")
4967            .args(["add", "."])
4968            .current_dir(&source_dir)
4969            .output()
4970            .unwrap();
4971        std::process::Command::new("git")
4972            .args(["commit", "-m", "Test commit"])
4973            .current_dir(&source_dir)
4974            .output()
4975            .unwrap();
4976
4977        // Get the commit hash
4978        let output = std::process::Command::new("git")
4979            .args(["rev-parse", "HEAD"])
4980            .current_dir(&source_dir)
4981            .output()
4982            .unwrap();
4983        let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
4984
4985        let mut manifest = Manifest::new();
4986        // Use file:// URL to ensure it's treated as a Git source, not a local path
4987        let source_url = format!("file://{}", source_dir.display());
4988        manifest.add_source("test".to_string(), source_url);
4989
4990        // Test commit hash checkout (use first 7 chars for short hash)
4991        manifest.add_dependency(
4992            "commit-dep".to_string(),
4993            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
4994                source: Some("test".to_string()),
4995                path: "test.txt".to_string(),
4996                version: Some(commit_hash[..7].to_string()), // Short commit hash
4997                branch: None,
4998                rev: None,
4999                command: None,
5000                args: None,
5001                target: None,
5002                filename: None,
5003                dependencies: None,
5004                tool: "claude-code".to_string(),
5005            })),
5006            true,
5007        );
5008
5009        let cache_dir = temp_dir.path().join("cache");
5010        let cache = Cache::with_dir(cache_dir).unwrap();
5011        let mut resolver = DependencyResolver::with_cache(manifest, cache);
5012
5013        let lockfile = resolver.resolve().await.unwrap();
5014        assert_eq!(lockfile.agents.len(), 1);
5015
5016        let agent = &lockfile.agents[0];
5017        assert!(agent.resolved_commit.is_some());
5018        // The resolved commit should start with our short hash
5019        assert!(agent.resolved_commit.as_ref().unwrap().starts_with(&commit_hash[..7]));
5020    }
5021
5022    #[test]
5023    #[ignore = "Redundancy checking removed - using automatic conflict resolution"]
5024    fn test_check_redundancies_with_details() {
5025        let mut manifest = Manifest::new();
5026        manifest.add_source("official".to_string(), "https://github.com/test/repo.git".to_string());
5027
5028        // Add redundant dependencies
5029        manifest.add_dependency(
5030            "helper-v1".to_string(),
5031            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
5032                source: Some("official".to_string()),
5033                path: "agents/helper.md".to_string(),
5034                version: Some("v1.0.0".to_string()),
5035                branch: None,
5036                rev: None,
5037                command: None,
5038                args: None,
5039                target: None,
5040                filename: None,
5041                dependencies: None,
5042                tool: "claude-code".to_string(),
5043            })),
5044            true,
5045        );
5046
5047        manifest.add_dependency(
5048            "helper-v2".to_string(),
5049            ResourceDependency::Detailed(Box::new(crate::manifest::DetailedDependency {
5050                source: Some("official".to_string()),
5051                path: "agents/helper.md".to_string(),
5052                version: Some("v2.0.0".to_string()),
5053                branch: None,
5054                rev: None,
5055                command: None,
5056                args: None,
5057                target: None,
5058                filename: None,
5059                dependencies: None,
5060                tool: "claude-code".to_string(),
5061            })),
5062            true,
5063        );
5064
5065        let temp_dir = TempDir::new().unwrap();
5066        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5067        let _resolver = DependencyResolver::with_cache(manifest, cache);
5068    }
5069
5070    #[tokio::test]
5071    async fn test_mixed_resource_types() {
5072        let mut manifest = Manifest::new();
5073
5074        // Add various resource types
5075        manifest.add_dependency(
5076            "agent1".to_string(),
5077            ResourceDependency::Simple("../agents/a1.md".to_string()),
5078            true,
5079        );
5080
5081        manifest.scripts.insert(
5082            "build".to_string(),
5083            ResourceDependency::Simple("../scripts/build.sh".to_string()),
5084        );
5085
5086        manifest.hooks.insert(
5087            "pre-commit".to_string(),
5088            ResourceDependency::Simple("../hooks/pre-commit.json".to_string()),
5089        );
5090
5091        manifest.commands.insert(
5092            "deploy".to_string(),
5093            ResourceDependency::Simple("../commands/deploy.md".to_string()),
5094        );
5095
5096        manifest.mcp_servers.insert(
5097            "filesystem".to_string(),
5098            ResourceDependency::Simple("../mcp/filesystem.json".to_string()),
5099        );
5100
5101        let temp_dir = TempDir::new().unwrap();
5102        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5103        let mut resolver = DependencyResolver::with_cache(manifest, cache);
5104
5105        let lockfile = resolver.resolve().await.unwrap();
5106
5107        // Check all resource types are resolved
5108        assert_eq!(lockfile.agents.len(), 1);
5109        assert_eq!(lockfile.scripts.len(), 1);
5110        assert_eq!(lockfile.hooks.len(), 1);
5111        assert_eq!(lockfile.commands.len(), 1);
5112        assert_eq!(lockfile.mcp_servers.len(), 1);
5113    }
5114
5115    #[test]
5116    fn test_resolve_version_conflict_rejects_semver_ranges() {
5117        let manifest = Manifest::new();
5118        let temp_dir = TempDir::new().unwrap();
5119        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5120        let resolver = DependencyResolver::with_cache(manifest, cache);
5121
5122        // Test that caret ranges are rejected
5123        let existing = ResourceDependency::Detailed(Box::new(DetailedDependency {
5124            source: Some("test".to_string()),
5125            path: "agents/test.md".to_string(),
5126            version: Some("^1.0.0".to_string()),
5127            branch: None,
5128            rev: None,
5129            command: None,
5130            args: None,
5131            target: None,
5132            filename: None,
5133            dependencies: None,
5134            tool: "claude-code".to_string(),
5135        }));
5136
5137        let new_dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
5138            source: Some("test".to_string()),
5139            path: "agents/test.md".to_string(),
5140            version: Some("^1.5.0".to_string()),
5141            branch: None,
5142            rev: None,
5143            command: None,
5144            args: None,
5145            target: None,
5146            filename: None,
5147            dependencies: None,
5148            tool: "claude-code".to_string(),
5149        }));
5150
5151        let result = resolver.resolve_version_conflict("test-agent", &existing, &new_dep, "app1");
5152        assert!(result.is_err(), "Should reject caret range");
5153        let err_msg = result.unwrap_err().to_string();
5154        assert!(
5155            err_msg.contains("cannot resolve semver ranges"),
5156            "Error should mention semver ranges: {}",
5157            err_msg
5158        );
5159
5160        // Test that tilde ranges are rejected
5161        let existing_tilde = ResourceDependency::Detailed(Box::new(DetailedDependency {
5162            source: Some("test".to_string()),
5163            path: "agents/test.md".to_string(),
5164            version: Some("~1.2.0".to_string()),
5165            branch: None,
5166            rev: None,
5167            command: None,
5168            args: None,
5169            target: None,
5170            filename: None,
5171            dependencies: None,
5172            tool: "claude-code".to_string(),
5173        }));
5174
5175        let result2 =
5176            resolver.resolve_version_conflict("test-agent", &existing_tilde, &new_dep, "app2");
5177        assert!(result2.is_err(), "Should reject tilde range");
5178
5179        // Test that comparison operators are rejected
5180        let existing_gte = ResourceDependency::Detailed(Box::new(DetailedDependency {
5181            source: Some("test".to_string()),
5182            path: "agents/test.md".to_string(),
5183            version: Some(">=1.0.0".to_string()),
5184            branch: None,
5185            rev: None,
5186            command: None,
5187            args: None,
5188            target: None,
5189            filename: None,
5190            dependencies: None,
5191            tool: "claude-code".to_string(),
5192        }));
5193
5194        let result3 =
5195            resolver.resolve_version_conflict("test-agent", &existing_gte, &new_dep, "app3");
5196        assert!(result3.is_err(), "Should reject >= operator");
5197    }
5198
5199    #[test]
5200    fn test_resolve_version_conflict_semver_preference() {
5201        let manifest = Manifest::new();
5202        let temp_dir = TempDir::new().unwrap();
5203        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5204        let resolver = DependencyResolver::with_cache(manifest, cache);
5205
5206        // Test: semver version preferred over git branch
5207        let existing_semver = ResourceDependency::Detailed(Box::new(DetailedDependency {
5208            source: Some("test".to_string()),
5209            path: "agents/test.md".to_string(),
5210            version: Some("v1.0.0".to_string()),
5211            branch: None,
5212            rev: None,
5213            command: None,
5214            args: None,
5215            target: None,
5216            filename: None,
5217            dependencies: None,
5218            tool: "claude-code".to_string(),
5219        }));
5220
5221        let new_branch = ResourceDependency::Detailed(Box::new(DetailedDependency {
5222            source: Some("test".to_string()),
5223            path: "agents/test.md".to_string(),
5224            version: None,
5225            branch: Some("main".to_string()),
5226            rev: None,
5227            command: None,
5228            args: None,
5229            target: None,
5230            filename: None,
5231            dependencies: None,
5232            tool: "claude-code".to_string(),
5233        }));
5234
5235        let result =
5236            resolver.resolve_version_conflict("test-agent", &existing_semver, &new_branch, "app1");
5237        assert!(result.is_ok(), "Should succeed with semver preference");
5238        let resolved = result.unwrap();
5239        assert_eq!(
5240            resolved.get_version(),
5241            Some("v1.0.0"),
5242            "Should prefer semver version over branch"
5243        );
5244
5245        // Test reverse: git branch vs semver version
5246        let result2 =
5247            resolver.resolve_version_conflict("test-agent", &new_branch, &existing_semver, "app2");
5248        assert!(result2.is_ok(), "Should succeed with semver preference");
5249        let resolved2 = result2.unwrap();
5250        assert_eq!(
5251            resolved2.get_version(),
5252            Some("v1.0.0"),
5253            "Should prefer semver version over branch (reversed order)"
5254        );
5255    }
5256
5257    #[test]
5258    fn test_resolve_version_conflict_semver_comparison() {
5259        let manifest = Manifest::new();
5260        let temp_dir = TempDir::new().unwrap();
5261        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5262        let resolver = DependencyResolver::with_cache(manifest, cache);
5263
5264        // Test: higher semver version wins
5265        let existing_v1 = ResourceDependency::Detailed(Box::new(DetailedDependency {
5266            source: Some("test".to_string()),
5267            path: "agents/test.md".to_string(),
5268            version: Some("v1.5.0".to_string()),
5269            branch: None,
5270            rev: None,
5271            command: None,
5272            args: None,
5273            target: None,
5274            filename: None,
5275            dependencies: None,
5276            tool: "claude-code".to_string(),
5277        }));
5278
5279        let new_v2 = ResourceDependency::Detailed(Box::new(DetailedDependency {
5280            source: Some("test".to_string()),
5281            path: "agents/test.md".to_string(),
5282            version: Some("v2.0.0".to_string()),
5283            branch: None,
5284            rev: None,
5285            command: None,
5286            args: None,
5287            target: None,
5288            filename: None,
5289            dependencies: None,
5290            tool: "claude-code".to_string(),
5291        }));
5292
5293        let result = resolver.resolve_version_conflict("test-agent", &existing_v1, &new_v2, "app1");
5294        assert!(result.is_ok(), "Should succeed with higher version");
5295        let resolved = result.unwrap();
5296        assert_eq!(resolved.get_version(), Some("v2.0.0"), "Should use higher semver version");
5297
5298        // Test reverse order
5299        let result2 =
5300            resolver.resolve_version_conflict("test-agent", &new_v2, &existing_v1, "app2");
5301        assert!(result2.is_ok(), "Should succeed with higher version");
5302        let resolved2 = result2.unwrap();
5303        assert_eq!(
5304            resolved2.get_version(),
5305            Some("v2.0.0"),
5306            "Should use higher semver version (reversed order)"
5307        );
5308    }
5309
5310    #[test]
5311    fn test_resolve_version_conflict_git_refs() {
5312        let manifest = Manifest::new();
5313        let temp_dir = TempDir::new().unwrap();
5314        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5315        let resolver = DependencyResolver::with_cache(manifest, cache);
5316
5317        // Test: git refs use alphabetical ordering
5318        let existing_main = ResourceDependency::Detailed(Box::new(DetailedDependency {
5319            source: Some("test".to_string()),
5320            path: "agents/test.md".to_string(),
5321            version: None,
5322            branch: Some("main".to_string()),
5323            rev: None,
5324            command: None,
5325            args: None,
5326            target: None,
5327            filename: None,
5328            dependencies: None,
5329            tool: "claude-code".to_string(),
5330        }));
5331
5332        let new_develop = ResourceDependency::Detailed(Box::new(DetailedDependency {
5333            source: Some("test".to_string()),
5334            path: "agents/test.md".to_string(),
5335            version: None,
5336            branch: Some("develop".to_string()),
5337            rev: None,
5338            command: None,
5339            args: None,
5340            target: None,
5341            filename: None,
5342            dependencies: None,
5343            tool: "claude-code".to_string(),
5344        }));
5345
5346        let result =
5347            resolver.resolve_version_conflict("test-agent", &existing_main, &new_develop, "app1");
5348        assert!(result.is_ok(), "Should succeed with alphabetical ordering");
5349        let resolved = result.unwrap();
5350        assert_eq!(
5351            resolved.get_version(),
5352            Some("develop"),
5353            "Should use alphabetically first git ref"
5354        );
5355    }
5356
5357    #[test]
5358    fn test_resolve_version_conflict_head_vs_specific() {
5359        let manifest = Manifest::new();
5360        let temp_dir = TempDir::new().unwrap();
5361        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
5362        let resolver = DependencyResolver::with_cache(manifest, cache);
5363
5364        // Test: specific version preferred over HEAD (None)
5365        let existing_head = ResourceDependency::Simple("agents/test.md".to_string());
5366
5367        let new_specific = ResourceDependency::Detailed(Box::new(DetailedDependency {
5368            source: Some("test".to_string()),
5369            path: "agents/test.md".to_string(),
5370            version: Some("v1.0.0".to_string()),
5371            branch: None,
5372            rev: None,
5373            command: None,
5374            args: None,
5375            target: None,
5376            filename: None,
5377            dependencies: None,
5378            tool: "claude-code".to_string(),
5379        }));
5380
5381        let result =
5382            resolver.resolve_version_conflict("test-agent", &existing_head, &new_specific, "app1");
5383        assert!(result.is_ok(), "Should succeed with specific version");
5384        let resolved = result.unwrap();
5385        assert_eq!(
5386            resolved.get_version(),
5387            Some("v1.0.0"),
5388            "Should prefer specific version over HEAD"
5389        );
5390    }
5391}