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}