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}