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}