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