agpm_cli/resolver/types.rs
1//! Core types and utilities for dependency resolution.
2//!
3//! This module provides shared types, context structures, and helper functions
4//! used throughout the resolver. It consolidates:
5//! - Resolution context and core shared state
6//! - Context structures for different resolution phases
7//! - Pure helper functions for dependency manipulation
8
9use std::collections::HashMap;
10use std::sync::Arc;
11
12use dashmap::DashMap;
13
14use crate::cache::Cache;
15use crate::core::ResourceType;
16use crate::core::operation_context::OperationContext;
17use crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef;
18use crate::manifest::{Manifest, ResourceDependency};
19use crate::source::SourceManager;
20use crate::version::conflict::ConflictDetector;
21
22// ============================================================================
23// Resolution Mode Types
24// ============================================================================
25
26/// Determines which resolution strategy to use for a dependency
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ResolutionMode {
29 /// Semantic versioning with constraints and tags
30 Version,
31 /// Direct git reference (branch or rev)
32 GitRef,
33}
34
35impl ResolutionMode {
36 /// Determine resolution mode from a dependency specification
37 pub fn from_dependency(dep: &crate::manifest::ResourceDependency) -> Self {
38 use crate::manifest::ResourceDependency;
39
40 match dep {
41 ResourceDependency::Simple(_) => Self::Version, // Default to version
42 ResourceDependency::Detailed(d) => {
43 if d.branch.is_some() || d.rev.is_some() {
44 Self::GitRef
45 } else {
46 Self::Version
47 }
48 }
49 }
50 }
51}
52
53// ============================================================================
54// Core Resolution Context
55// ============================================================================
56
57/// Core shared context for dependency resolution.
58///
59/// This struct holds immutable state that is shared across all
60/// resolution services. It does not change during resolution.
61pub struct ResolutionCore {
62 /// The project manifest with dependencies and configuration
63 pub manifest: Manifest,
64
65 /// The cache for worktrees and Git operations
66 pub cache: Cache,
67
68 /// The source manager for resolving source URLs
69 pub source_manager: SourceManager,
70
71 /// Optional operation context for warnings and progress tracking
72 pub operation_context: Option<Arc<OperationContext>>,
73}
74
75impl ResolutionCore {
76 /// Create a new resolution core.
77 pub fn new(
78 manifest: Manifest,
79 cache: Cache,
80 source_manager: SourceManager,
81 operation_context: Option<Arc<OperationContext>>,
82 ) -> Self {
83 Self {
84 manifest,
85 cache,
86 source_manager,
87 operation_context,
88 }
89 }
90
91 /// Get a reference to the manifest.
92 pub fn manifest(&self) -> &Manifest {
93 &self.manifest
94 }
95
96 /// Get a reference to the cache.
97 pub fn cache(&self) -> &Cache {
98 &self.cache
99 }
100
101 /// Get a reference to the source manager.
102 pub fn source_manager(&self) -> &SourceManager {
103 &self.source_manager
104 }
105
106 /// Get a reference to the operation context if present.
107 pub fn operation_context(&self) -> Option<&Arc<OperationContext>> {
108 self.operation_context.as_ref()
109 }
110}
111
112// ============================================================================
113// Resolution Context Types
114// ============================================================================
115
116/// Type alias for dependency keys used in resolution maps.
117///
118/// Format: (ResourceType, dependency_name, source, tool, variant_inputs_hash)
119///
120/// The variant_inputs_hash ensures that dependencies with different template variables
121/// are treated as distinct entries, preventing incorrect deduplication when multiple
122/// parent resources need the same dependency with different variant inputs.
123pub type DependencyKey = (ResourceType, String, Option<String>, Option<String>, String);
124
125/// Base resolution context with immutable shared state.
126///
127/// This context is passed to most resolution operations and provides access
128/// to the manifest, cache, source manager, and operation context.
129///
130/// Implements `Copy` because all fields are references (`&'a T`), making
131/// it cheap to pass by value and enabling ergonomic usage in closures
132/// and parallel processing contexts.
133#[derive(Copy, Clone)]
134pub struct ResolutionContext<'a> {
135 /// The project manifest with dependencies and configuration
136 pub manifest: &'a Manifest,
137
138 /// The cache for worktrees and Git operations
139 pub cache: &'a Cache,
140
141 /// The source manager for resolving source URLs
142 pub source_manager: &'a SourceManager,
143
144 /// Optional operation context for warnings and progress tracking
145 pub operation_context: Option<&'a Arc<OperationContext>>,
146}
147
148/// Context for transitive dependency resolution.
149///
150/// Extends the base resolution context with concurrent state needed for
151/// parallel transitive dependency traversal and conflict detection.
152pub struct TransitiveContext<'a> {
153 /// Base immutable context
154 pub base: ResolutionContext<'a>,
155
156 /// Map tracking which dependencies are required by which resources (concurrent)
157 pub dependency_map: &'a Arc<DashMap<DependencyKey, Vec<String>>>,
158
159 /// Map tracking custom names for transitive dependencies (concurrent, for template variables)
160 pub transitive_custom_names: &'a Arc<DashMap<DependencyKey, String>>,
161
162 /// Conflict detector for version resolution
163 pub conflict_detector: &'a mut ConflictDetector,
164
165 /// Index of manifest overrides for deduplication with transitive deps
166 pub manifest_overrides: &'a ManifestOverrideIndex,
167}
168
169/// Context for pattern expansion operations.
170///
171/// Extends the base resolution context with pattern alias tracking.
172pub struct PatternContext<'a> {
173 /// Base immutable context
174 pub base: ResolutionContext<'a>,
175
176 /// Map tracking pattern alias relationships (concrete_name -> pattern_name) (concurrent)
177 pub pattern_alias_map: &'a Arc<DashMap<(ResourceType, String), String>>,
178}
179
180// ============================================================================
181// Manifest Override Types
182// ============================================================================
183
184/// Stores override information from manifest dependencies.
185///
186/// When a resource appears both as a direct dependency in the manifest and as
187/// a transitive dependency of another resource, this structure stores the
188/// customizations from the manifest version to ensure they take precedence.
189#[derive(Debug, Clone)]
190pub struct ManifestOverride {
191 /// Custom filename specified in manifest
192 pub filename: Option<String>,
193
194 /// Custom target path specified in manifest
195 pub target: Option<String>,
196
197 /// Install flag override
198 pub install: Option<bool>,
199
200 /// Manifest alias (for reference)
201 pub manifest_alias: Option<String>,
202
203 /// Original template variables from manifest
204 pub template_vars: Option<serde_json::Value>,
205}
206
207/// Key for override index lookup.
208///
209/// This key uniquely identifies a resource variant for the purpose of
210/// detecting when a transitive dependency should be overridden by a
211/// direct manifest dependency.
212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
213pub struct OverrideKey {
214 /// The type of resource (Agent, Snippet, etc.)
215 pub resource_type: ResourceType,
216
217 /// Normalized path (without leading ./ and without extension)
218 pub normalized_path: String,
219
220 /// Source repository name (None for local dependencies)
221 pub source: Option<String>,
222
223 /// Target tool name
224 pub tool: String,
225
226 /// Variant inputs hash (computed from template_vars)
227 pub variant_hash: String,
228}
229
230/// Override index mapping resource identities to their manifest customizations.
231///
232/// This index is built once during resolution from the manifest dependencies
233/// and used to apply overrides to transitive dependencies that match.
234pub type ManifestOverrideIndex = HashMap<OverrideKey, ManifestOverride>;
235
236// ============================================================================
237// Conflict Detection Types
238// ============================================================================
239
240/// Value type for resolved dependencies tracked for conflict detection.
241///
242/// Contains the resolution metadata needed to detect and resolve version conflicts
243/// between different dependencies that resolve to the same resource.
244#[derive(Debug, Clone)]
245pub struct ResolvedDependencyInfo {
246 /// The version constraint that was specified (e.g., "^1.0.0", "main", "abc123")
247 pub version_constraint: String,
248
249 /// The resolved commit SHA for this dependency
250 pub resolved_sha: String,
251
252 /// The version constraint of the parent dependency (if any)
253 pub parent_version: Option<String>,
254
255 /// The resolved SHA of the parent dependency (if any)
256 pub parent_sha: Option<String>,
257
258 /// The resolution mode used (Version or GitRef)
259 pub resolution_mode: ResolutionMode,
260}
261
262/// Key type for resolved dependencies tracked for conflict detection.
263///
264/// Uniquely identifies a resolved dependency instance for conflict tracking.
265pub type ConflictDetectionKey = (crate::lockfile::ResourceId, String, String);
266
267/// Concurrent map type for tracking resolved dependencies during conflict detection.
268///
269/// This type is used throughout the resolver to track dependency resolution
270/// metadata for detecting version conflicts between different dependency requirements.
271pub type ResolvedDependenciesMap = Arc<DashMap<ConflictDetectionKey, ResolvedDependencyInfo>>;
272
273// ============================================================================
274// Manifest Override Helper Functions
275
276/// Apply manifest overrides to a resource dependency.
277///
278/// This helper function centralizes the logic for applying manifest customizations
279/// to transitive dependencies, ensuring consistent behavior across the codebase.
280///
281/// # Arguments
282///
283/// * `dep` - The resource dependency to modify (will be updated in-place)
284/// * `override_info` - The override information from the manifest
285/// * `normalized_path` - The normalized path for logging
286///
287/// # Effects
288///
289/// Modifies the dependency in-place with the following overrides:
290/// - `filename` - Custom filename
291/// - `target` - Custom target path
292/// - `install` - Install flag override
293/// - `template_vars` - Template variables (replaces transitive version)
294///
295/// # Logging
296///
297/// Logs debug information about applied overrides and warnings for non-detailed dependencies.
298pub fn apply_manifest_override(
299 dep: &mut ResourceDependency,
300 override_info: &ManifestOverride,
301 normalized_path: &str,
302) {
303 tracing::debug!(
304 "Applying manifest override to transitive dependency: {} (normalized: {})",
305 dep.get_path(),
306 normalized_path
307 );
308
309 // Apply overrides to make transitive dep match manifest version
310 if let ResourceDependency::Detailed(detailed) = dep {
311 // Get the path before we start modifying the dependency
312 let path = detailed.path.clone();
313
314 if let Some(filename) = &override_info.filename {
315 detailed.filename = Some(filename.clone());
316 }
317
318 if let Some(target) = &override_info.target {
319 detailed.target = Some(target.clone());
320 }
321
322 if let Some(install) = override_info.install {
323 detailed.install = Some(install);
324 }
325
326 // Replace template vars with manifest version for consistent rendering
327 if let Some(template_vars) = &override_info.template_vars {
328 detailed.template_vars = Some(template_vars.clone());
329 }
330
331 tracing::debug!(
332 "Applied manifest overrides to '{}': filename={:?}, target={:?}, install={:?}, template_vars={}",
333 path,
334 detailed.filename,
335 detailed.target,
336 detailed.install,
337 detailed.template_vars.is_some()
338 );
339 } else {
340 tracing::warn!(
341 "Cannot apply manifest override to non-detailed dependency: {}",
342 dep.get_path()
343 );
344 }
345}
346
347// ============================================================================
348// Dependency Helper Functions
349// ============================================================================
350
351/// Builds a resource identifier in the format `source:path`.
352///
353/// Resource identifiers are used for conflict detection and version resolution
354/// to uniquely identify resources across different sources.
355///
356/// # Arguments
357///
358/// * `dep` - The resource dependency specification
359///
360/// # Returns
361///
362/// A string in the format `"source:path"`, or `"unknown:path"` for dependencies
363/// without a source (e.g., local dependencies).
364pub fn build_resource_id(dep: &ResourceDependency) -> String {
365 let source = dep.get_source().unwrap_or("unknown");
366 let path = dep.get_path();
367 format!("{source}:{path}")
368}
369
370/// Normalizes a path by stripping leading `./` prefix.
371///
372/// This is a simple normalization that makes paths consistent for comparison
373/// and lookup operations.
374///
375/// # Arguments
376///
377/// * `path` - The path string to normalize
378///
379/// # Returns
380///
381/// A normalized path string without leading `./`
382///
383/// # Examples
384///
385/// ```
386/// use agpm_cli::resolver::types::normalize_lookup_path;
387///
388/// assert_eq!(normalize_lookup_path("./agents/helper.md"), "agents/helper");
389/// assert_eq!(normalize_lookup_path("agents/helper.md"), "agents/helper");
390/// assert_eq!(normalize_lookup_path("./foo"), "foo");
391/// ```
392pub fn normalize_lookup_path(path: &str) -> String {
393 use std::path::{Component, Path};
394
395 let path_obj = Path::new(path);
396
397 // Build normalized path by iterating through components
398 let mut components = Vec::new();
399 for component in path_obj.components() {
400 match component {
401 Component::CurDir => continue, // Skip "."
402 Component::Normal(os_str) => {
403 components.push(os_str.to_string_lossy().to_string());
404 }
405 _ => {}
406 }
407 }
408
409 // If we have components, strip extension from last one
410 if let Some(last) = components.last_mut() {
411 // Strip .md extension if present
412 if let Some(stem) = Path::new(last.as_str()).file_stem() {
413 *last = stem.to_string_lossy().to_string();
414 }
415 }
416
417 if components.is_empty() {
418 path.to_string()
419 } else {
420 components.join("/")
421 }
422}
423
424/// Extracts the filename from a path.
425///
426/// Returns the last component of a slash-separated path.
427///
428/// # Arguments
429///
430/// * `path` - The path string (may contain forward slashes)
431///
432/// # Returns
433///
434/// The filename if the path contains at least one component, `None` otherwise.
435///
436/// # Examples
437///
438/// ```
439/// use agpm_cli::resolver::types::extract_filename_from_path;
440///
441/// assert_eq!(extract_filename_from_path("agents/helper.md"), Some("helper.md".to_string()));
442/// assert_eq!(extract_filename_from_path("foo/bar/baz.txt"), Some("baz.txt".to_string()));
443/// assert_eq!(extract_filename_from_path("single.md"), Some("single.md".to_string()));
444/// assert_eq!(extract_filename_from_path(""), None);
445/// ```
446pub fn extract_filename_from_path(path: &str) -> Option<String> {
447 std::path::Path::new(path).file_name().and_then(|n| n.to_str()).map(String::from)
448}
449
450/// Strips resource type directory prefix from a path.
451///
452/// This mimics the logic in `generate_dependency_name` to allow dependency
453/// lookups to work with dependency names from the dependency map.
454///
455/// For paths like `agents/helpers/foo.md`, this returns `helpers/foo.md`.
456/// For paths without a recognized resource type directory, returns `None`.
457///
458/// # Arguments
459///
460/// * `path` - The path string with forward slashes
461///
462/// # Returns
463///
464/// The path with the resource type directory prefix stripped, or `None` if
465/// no resource type directory is found.
466///
467/// # Recognized Resource Type Directories
468///
469/// - agents
470/// - snippets
471/// - commands
472/// - scripts
473/// - hooks
474/// - mcp-servers
475///
476/// # Examples
477///
478/// ```
479/// use agpm_cli::resolver::types::strip_resource_type_directory;
480///
481/// assert_eq!(
482/// strip_resource_type_directory("agents/helpers/foo.md"),
483/// Some("helpers/foo.md".to_string())
484/// );
485/// assert_eq!(
486/// strip_resource_type_directory("snippets/rust/best-practices.md"),
487/// Some("rust/best-practices.md".to_string())
488/// );
489/// assert_eq!(
490/// strip_resource_type_directory("commands/deploy.md"),
491/// Some("deploy.md".to_string())
492/// );
493/// assert_eq!(
494/// strip_resource_type_directory("foo/bar.md"),
495/// None
496/// );
497/// assert_eq!(
498/// strip_resource_type_directory("agents"),
499/// None // No components after the resource type dir
500/// );
501/// ```
502pub fn strip_resource_type_directory(path: &str) -> Option<String> {
503 use std::path::{Component, Path};
504
505 let resource_type_dirs = ["agents", "snippets", "commands", "scripts", "hooks", "mcp-servers"];
506
507 // Use Path::components() to handle both forward and back slashes
508 let components: Vec<_> = Path::new(path)
509 .components()
510 .filter_map(|c| match c {
511 Component::Normal(s) => s.to_str(),
512 _ => None,
513 })
514 .collect();
515
516 if components.len() > 1 {
517 // Find the index of the first resource type directory
518 if let Some(idx) = components.iter().position(|c| resource_type_dirs.contains(c)) {
519 // Skip everything up to and including the resource type directory
520 if idx + 1 < components.len() {
521 // Always return with forward slashes for storage format
522 return Some(components[idx + 1..].join("/"));
523 }
524 }
525 }
526 None
527}
528
529/// Formats a dependency reference with version suffix.
530///
531/// Creates a string in the format `"resource_type/name@version"` for use in
532/// lockfile dependency lists.
533///
534/// # Arguments
535///
536/// * `resource_type` - The type of resource (Agent, Snippet, etc.)
537/// * `name` - The resource name
538/// * `version` - The version string (can be a semver tag, commit SHA, or "HEAD")
539///
540/// # Returns
541///
542/// A formatted dependency string with version.
543///
544/// # Examples
545///
546/// ```
547/// use agpm_cli::core::ResourceType;
548/// use agpm_cli::resolver::types::format_dependency_with_version;
549///
550/// let formatted = format_dependency_with_version(
551/// ResourceType::Agent,
552/// "helper",
553/// "v1.0.0"
554/// );
555/// assert_eq!(formatted, "agent:helper@v1.0.0");
556///
557/// let formatted = format_dependency_with_version(
558/// ResourceType::Snippet,
559/// "utils",
560/// "abc123"
561/// );
562/// assert_eq!(formatted, "snippet:utils@abc123");
563/// ```
564pub fn format_dependency_with_version(
565 resource_type: ResourceType,
566 name: &str,
567 version: &str,
568) -> String {
569 LockfileDependencyRef::local(resource_type, name.to_string(), Some(version.to_string()))
570 .to_string()
571}
572
573/// Formats a dependency reference without version suffix.
574///
575/// Creates a string in the format `"resource_type/name"` for use in
576/// dependency tracking before version resolution.
577///
578/// # Arguments
579///
580/// * `resource_type` - The type of resource (Agent, Snippet, etc.)
581/// * `name` - The resource name
582///
583/// # Returns
584///
585/// A formatted dependency string without version.
586///
587/// # Examples
588///
589/// ```
590/// use agpm_cli::core::ResourceType;
591/// use agpm_cli::resolver::types::format_dependency_without_version;
592///
593/// let formatted = format_dependency_without_version(ResourceType::Agent, "helper");
594/// assert_eq!(formatted, "agent:helper");
595///
596/// let formatted = format_dependency_without_version(ResourceType::Command, "deploy");
597/// assert_eq!(formatted, "command:deploy");
598/// ```
599pub fn format_dependency_without_version(resource_type: ResourceType, name: &str) -> String {
600 LockfileDependencyRef::local(resource_type, name.to_string(), None).to_string()
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::manifest::DetailedDependency;
607
608 #[test]
609 fn test_normalize_lookup_path() {
610 // Extensions are stripped for consistent lookup
611 assert_eq!(normalize_lookup_path("./agents/helper.md"), "agents/helper");
612 assert_eq!(normalize_lookup_path("agents/helper.md"), "agents/helper");
613 assert_eq!(normalize_lookup_path("snippets/helpers/foo.md"), "snippets/helpers/foo");
614 assert_eq!(normalize_lookup_path("./foo.md"), "foo");
615 assert_eq!(normalize_lookup_path("./foo"), "foo");
616 assert_eq!(normalize_lookup_path("foo"), "foo");
617 }
618
619 #[test]
620 fn test_extract_filename_from_path() {
621 assert_eq!(extract_filename_from_path("agents/helper.md"), Some("helper.md".to_string()));
622 assert_eq!(extract_filename_from_path("foo/bar/baz.txt"), Some("baz.txt".to_string()));
623 assert_eq!(extract_filename_from_path("single.md"), Some("single.md".to_string()));
624 assert_eq!(extract_filename_from_path(""), None);
625 // Path::file_name() normalizes trailing slashes
626 assert_eq!(extract_filename_from_path("trailing/"), Some("trailing".to_string()));
627 }
628
629 #[test]
630 fn test_strip_resource_type_directory() {
631 assert_eq!(
632 strip_resource_type_directory("agents/helpers/foo.md"),
633 Some("helpers/foo.md".to_string())
634 );
635 assert_eq!(
636 strip_resource_type_directory("snippets/rust/best-practices.md"),
637 Some("rust/best-practices.md".to_string())
638 );
639 assert_eq!(
640 strip_resource_type_directory("commands/deploy.md"),
641 Some("deploy.md".to_string())
642 );
643 assert_eq!(strip_resource_type_directory("foo/bar.md"), None);
644 assert_eq!(strip_resource_type_directory("agents"), None);
645 assert_eq!(
646 strip_resource_type_directory("mcp-servers/filesystem.json"),
647 Some("filesystem.json".to_string())
648 );
649 }
650
651 #[test]
652 fn test_format_dependency_with_version() {
653 assert_eq!(
654 format_dependency_with_version(ResourceType::Agent, "helper", "v1.0.0"),
655 "agent:helper@v1.0.0"
656 );
657 assert_eq!(
658 format_dependency_with_version(ResourceType::Snippet, "utils", "abc123"),
659 "snippet:utils@abc123"
660 );
661 }
662
663 #[test]
664 fn test_format_dependency_without_version() {
665 assert_eq!(
666 format_dependency_without_version(ResourceType::Agent, "helper"),
667 "agent:helper"
668 );
669 assert_eq!(
670 format_dependency_without_version(ResourceType::Command, "deploy"),
671 "command:deploy"
672 );
673 }
674
675 #[test]
676 fn test_build_resource_id() {
677 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
678 source: Some("test-source".to_string()),
679 path: "agents/helper.md".to_string(),
680 version: Some("v1.0.0".to_string()),
681 branch: None,
682 rev: None,
683 command: None,
684 args: None,
685 target: None,
686 filename: None,
687 dependencies: None,
688 tool: None,
689 flatten: None,
690 install: None,
691 template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
692 }));
693 let resource_id = build_resource_id(&dep);
694 assert!(resource_id.contains("agents/helper.md"));
695 }
696}