agpm_cli/resolver/
lockfile_builder.rs

1//! Lockfile building and management functionality.
2//!
3//! This module handles the creation, updating, and maintenance of lockfile entries,
4//! including conflict detection, stale entry removal, and transitive dependency management.
5
6use crate::core::ResourceType;
7use crate::lockfile::{LockFile, LockedResource, lockfile_dependency_ref::LockfileDependencyRef};
8use crate::manifest::{Manifest, ResourceDependency};
9use crate::resolver::types as dependency_helpers;
10use anyhow::Result;
11use std::collections::{BTreeMap, HashMap, HashSet};
12use std::str::FromStr;
13
14// Type aliases for internal lookups
15type ResourceKey = (ResourceType, String, Option<String>);
16type ResourceInfo = (Option<String>, Option<String>);
17
18/// Checks if two lockfile entries should be considered duplicates.
19///
20/// Two entries are duplicates if they have the same:
21/// 1. name, source, tool, AND template_vars (standard deduplication)
22/// 2. path and tool for local dependencies (source = None)
23///
24/// **CRITICAL**: template_vars are part of the resource identity! Resources with
25/// different template_vars are DISTINCT resources that must all exist in the lockfile.
26/// For example, `backend-engineer` with `language=typescript` and `language=javascript`
27/// are TWO DIFFERENT resources.
28///
29/// The second case handles situations where a direct dependency and a transitive
30/// dependency point to the same local file but have different names (e.g., manifest
31/// name vs path-based name). This prevents false conflicts.
32pub fn is_duplicate_entry(existing: &LockedResource, new_entry: &LockedResource) -> bool {
33    tracing::info!(
34        "is_duplicate_entry: existing.name='{}', new.name='{}', existing.manifest_alias={:?}, new.manifest_alias={:?}, existing.path='{}', new.path='{}'",
35        existing.name,
36        new_entry.name,
37        existing.manifest_alias,
38        new_entry.manifest_alias,
39        existing.path,
40        new_entry.path
41    );
42
43    // CRITICAL: manifest_alias is part of resource identity for PATTERN-EXPANDED dependencies!
44    // When BOTH entries have manifest_alias (both are direct or pattern-expanded),
45    // different aliases mean different resources, even with identical paths/variant_inputs.
46    //
47    // Example case where we should NOT deduplicate:
48    //   - backend-engineer (manifest_alias = Some("backend-engineer"))
49    //   - backend-engineer-python (manifest_alias = Some("backend-engineer-python"))
50    // Both from same path but represent distinct manifest entries.
51    //
52    // Example case where we SHOULD deduplicate (let merge strategy decide which wins):
53    //   - general-purpose (manifest_alias = Some("general-purpose"), direct from manifest)
54    //   - general-purpose (manifest_alias = None, transitive dependency)
55    // One is direct, one is transitive - merge strategy will pick the direct one.
56    if existing.manifest_alias.is_some()
57        && new_entry.manifest_alias.is_some()
58        && existing.manifest_alias != new_entry.manifest_alias
59    {
60        tracing::debug!(
61            "NOT duplicates - both are direct/pattern deps with different manifest_alias: existing={:?} vs new={:?} (path={})",
62            existing.manifest_alias,
63            new_entry.manifest_alias,
64            existing.path
65        );
66        return false; // Different direct dependencies = NOT duplicates
67    }
68
69    // Determine if one is direct and one is transitive
70    let existing_is_direct = existing.manifest_alias.is_some();
71    let new_is_direct = new_entry.manifest_alias.is_some();
72    let one_direct_one_transitive = existing_is_direct != new_is_direct;
73
74    // Standard deduplication logic:
75    // variant_inputs are ALWAYS part of resource identity - resources with different
76    // template_vars are distinct resources that must both exist in the lockfile.
77    // This applies regardless of whether dependencies are direct, transitive, or mixed.
78    let basic_match = existing.name == new_entry.name
79        && existing.source == new_entry.source
80        && existing.tool == new_entry.tool;
81
82    let is_duplicate = basic_match && existing.variant_inputs == new_entry.variant_inputs;
83
84    if is_duplicate {
85        tracing::debug!(
86            "Deduplicating entries: name={}, source={:?}, tool={:?}, manifest_alias existing={:?} new={:?}, one_direct_one_transitive={}",
87            existing.name,
88            existing.source,
89            existing.tool,
90            existing.manifest_alias,
91            new_entry.manifest_alias,
92            one_direct_one_transitive
93        );
94        return true;
95    }
96
97    // Local dependency deduplication: same path and tool
98    // Apply same logic as above: variant_inputs are ALWAYS part of resource identity
99    if existing.source.is_none() && new_entry.source.is_none() {
100        let path_tool_match = existing.path == new_entry.path && existing.tool == new_entry.tool;
101        let is_local_duplicate =
102            path_tool_match && existing.variant_inputs == new_entry.variant_inputs;
103
104        if is_local_duplicate {
105            tracing::debug!(
106                "Deduplicating local deps: path={}, tool={:?}, one_direct_one_transitive={}",
107                existing.path,
108                existing.tool,
109                one_direct_one_transitive
110            );
111            return true;
112        }
113    }
114
115    tracing::debug!(
116        "NOT duplicates: name existing={} new={}, source existing={:?} new={:?}, variant_inputs match={}",
117        existing.name,
118        new_entry.name,
119        existing.source,
120        new_entry.source,
121        existing.variant_inputs == new_entry.variant_inputs
122    );
123    false
124}
125
126/// Determines if a new lockfile entry should replace an existing duplicate entry.
127///
128/// Uses a deterministic merge strategy to ensure consistent lockfile generation
129/// regardless of processing order (e.g., HashMap iteration order).
130///
131/// # Merge Priority Rules (highest to lowest)
132///
133/// 1. **Manifest dependencies win** - Direct manifest dependencies (with `manifest_alias`)
134///    always take precedence over transitive dependencies
135/// 2. **install=true wins** - Dependencies that create files (`install=true`) are
136///    preferred over content-only dependencies (`install=false`)
137/// 3. **First wins** - If both have equal priority, keep the existing entry
138///
139/// This ensures that the lockfile is deterministic even when:
140/// - Dependencies are processed in different orders
141/// - HashMap iteration order varies between runs
142/// - Multiple parents declare the same transitive dependency with different settings
143///
144/// # Arguments
145///
146/// * `existing` - The current entry in the lockfile
147/// * `new_entry` - The new entry being added
148///
149/// # Returns
150///
151/// `true` if the new entry should replace the existing one, `false` otherwise
152fn should_replace_duplicate(existing: &LockedResource, new_entry: &LockedResource) -> bool {
153    let is_new_manifest = new_entry.manifest_alias.is_some();
154    let is_existing_manifest = existing.manifest_alias.is_some();
155    let new_install = new_entry.install.unwrap_or(true);
156    let existing_install = existing.install.unwrap_or(true);
157
158    let should_replace = if is_new_manifest != is_existing_manifest {
159        // Rule 1: Manifest dependencies always win
160        is_new_manifest
161    } else if new_install != existing_install {
162        // Rule 2: Prefer install=true (files that should be written)
163        new_install
164    } else {
165        // Rule 3: Both have same priority, but still replace if new is manifest
166        // to ensure direct dependencies override transitive ones
167        is_new_manifest
168    };
169
170    if new_install != existing_install {
171        tracing::debug!(
172            "Merge decision for {}: existing.install={:?}, new.install={:?}, should_replace={}",
173            new_entry.name,
174            existing.install,
175            new_entry.install,
176            should_replace
177        );
178    }
179
180    should_replace
181}
182
183/// Manages lockfile operations including entry creation, updates, and cleanup.
184pub struct LockfileBuilder<'a> {
185    manifest: &'a Manifest,
186}
187
188impl<'a> LockfileBuilder<'a> {
189    /// Create a new lockfile builder with the given manifest.
190    pub fn new(manifest: &'a Manifest) -> Self {
191        Self {
192            manifest,
193        }
194    }
195
196    /// Add or update a lockfile entry with deterministic merging for duplicates.
197    ///
198    /// This method handles deduplication by using (name, source, tool) tuples as the unique key.
199    /// When duplicates are found, it uses a deterministic merge strategy to ensure consistent
200    /// lockfile generation across runs, regardless of processing order.
201    ///
202    /// # Merge Strategy (deterministic, order-independent)
203    ///
204    /// When merging duplicate entries:
205    /// 1. **Prefer direct manifest dependencies** (has `manifest_alias`) over transitive dependencies
206    /// 2. **Prefer install=true** over install=false (prefer dependencies that create files)
207    /// 3. Otherwise, keep the existing entry (first-wins for same priority)
208    ///
209    /// This ensures that even with non-deterministic HashMap iteration order, the same
210    /// logical dependency structure produces the same lockfile.
211    ///
212    /// # Arguments
213    ///
214    /// * `lockfile` - The mutable lockfile to update
215    /// * `name` - The name of the dependency (for documentation purposes)
216    /// * `entry` - The locked resource entry to add or update
217    ///
218    /// # Examples
219    ///
220    /// ```rust,ignore
221    /// let mut lockfile = LockFile::new();
222    /// let entry = LockedResource {
223    ///     name: "my-agent".to_string(),
224    ///     source: Some("community".to_string()),
225    ///     tool: "claude-code".to_string(),
226    ///     // ... other fields
227    /// };
228    ///
229    /// resolver.add_or_update_lockfile_entry(&mut lockfile, "my-agent", entry);
230    ///
231    /// // Later updates use deterministic merge strategy
232    /// let updated_entry = LockedResource {
233    ///     name: "my-agent".to_string(),
234    ///     source: Some("community".to_string()),
235    ///     tool: "claude-code".to_string(),
236    ///     // ... updated fields
237    /// };
238    /// resolver.add_or_update_lockfile_entry(&mut lockfile, "my-agent", updated_entry);
239    /// ```
240    pub fn add_or_update_lockfile_entry(
241        &self,
242        lockfile: &mut LockFile,
243        _name: &str,
244        entry: LockedResource,
245    ) {
246        let resources = lockfile.get_resources_mut(&entry.resource_type);
247
248        if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
249            // Use deterministic merge strategy to ensure consistent lockfile generation
250            let should_replace = should_replace_duplicate(existing, &entry);
251
252            tracing::trace!(
253                "Duplicate entry for {}: existing.install={:?}, new.install={:?}, should_replace={}",
254                entry.name,
255                existing.install,
256                entry.install,
257                should_replace
258            );
259
260            if should_replace {
261                *existing = entry;
262            }
263            // Otherwise keep existing entry (deterministic: first-wins for same priority)
264        } else {
265            resources.push(entry);
266        }
267    }
268
269    /// Removes stale lockfile entries that are no longer in the manifest.
270    ///
271    /// This method removes lockfile entries for direct manifest dependencies that have been
272    /// commented out or removed from the manifest. This must be called BEFORE
273    /// `remove_manifest_entries_for_update()` to ensure stale entries don't cause conflicts
274    /// during resolution.
275    ///
276    /// A manifest-level entry is identified by:
277    /// - `manifest_alias.is_none()` - Direct dependency with no pattern expansion
278    /// - `manifest_alias.is_some()` - Pattern-expanded dependency (alias must be in manifest)
279    ///
280    /// For each stale entry found, this also removes its transitive children to maintain
281    /// lockfile consistency.
282    ///
283    /// # Arguments
284    ///
285    /// * `lockfile` - The mutable lockfile to clean
286    ///
287    /// # Examples
288    ///
289    /// If a user comments out an agent in agpm.toml:
290    /// ```toml
291    /// # [agents]
292    /// # example = { source = "community", path = "agents/example.md", version = "v1.0.0" }
293    /// ```
294    ///
295    /// This function will remove the "example" agent from the lockfile and all its transitive
296    /// dependencies before the update process begins.
297    pub fn remove_stale_manifest_entries(&self, lockfile: &mut LockFile) {
298        // Collect all current manifest keys for each resource type
299        let manifest_agents: HashSet<String> =
300            self.manifest.agents.keys().map(|k| k.to_string()).collect();
301        let manifest_snippets: HashSet<String> =
302            self.manifest.snippets.keys().map(|k| k.to_string()).collect();
303        let manifest_commands: HashSet<String> =
304            self.manifest.commands.keys().map(|k| k.to_string()).collect();
305        let manifest_scripts: HashSet<String> =
306            self.manifest.scripts.keys().map(|k| k.to_string()).collect();
307        let manifest_hooks: HashSet<String> =
308            self.manifest.hooks.keys().map(|k| k.to_string()).collect();
309        let manifest_mcp_servers: HashSet<String> =
310            self.manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
311
312        // Helper to get the right manifest keys for a resource type
313        let get_manifest_keys = |resource_type: ResourceType| match resource_type {
314            ResourceType::Agent => &manifest_agents,
315            ResourceType::Snippet => &manifest_snippets,
316            ResourceType::Command => &manifest_commands,
317            ResourceType::Script => &manifest_scripts,
318            ResourceType::Hook => &manifest_hooks,
319            ResourceType::McpServer => &manifest_mcp_servers,
320        };
321
322        // Collect (name, source) pairs to remove
323        let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
324        let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
325
326        // Find all manifest-level entries that are no longer in the manifest
327        for resource_type in ResourceType::all() {
328            let manifest_keys = get_manifest_keys(*resource_type);
329            let resources = lockfile.get_resources(resource_type);
330
331            for entry in resources {
332                // Determine if this is a stale manifest-level entry (no longer in manifest)
333                let is_stale = if let Some(ref alias) = entry.manifest_alias {
334                    // Pattern-expanded entry: stale if alias is NOT in manifest
335                    !manifest_keys.contains(alias)
336                } else {
337                    // Direct entry: stale if name is NOT in manifest
338                    !manifest_keys.contains(&entry.name)
339                };
340
341                if is_stale {
342                    let key = (entry.name.clone(), entry.source.clone());
343                    entries_to_remove.insert(key.clone());
344                    direct_entries.push(key);
345                }
346            }
347        }
348
349        // For each stale entry, recursively collect its transitive children
350        for (parent_name, parent_source) in direct_entries {
351            for resource_type in ResourceType::all() {
352                if let Some(parent_entry) = lockfile
353                    .get_resources(resource_type)
354                    .iter()
355                    .find(|e| e.name == parent_name && e.source == parent_source)
356                {
357                    Self::collect_transitive_children(
358                        lockfile,
359                        parent_entry,
360                        &mut entries_to_remove,
361                    );
362                }
363            }
364        }
365
366        // Remove all marked entries
367        let should_remove = |entry: &LockedResource| {
368            entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
369        };
370
371        lockfile.agents.retain(|entry| !should_remove(entry));
372        lockfile.snippets.retain(|entry| !should_remove(entry));
373        lockfile.commands.retain(|entry| !should_remove(entry));
374        lockfile.scripts.retain(|entry| !should_remove(entry));
375        lockfile.hooks.retain(|entry| !should_remove(entry));
376        lockfile.mcp_servers.retain(|entry| !should_remove(entry));
377    }
378
379    /// Removes lockfile entries for manifest dependencies that will be re-resolved.
380    ///
381    /// This method removes old entries for direct manifest dependencies before updating,
382    /// which handles the case where a dependency's source or resource type changes.
383    /// This prevents duplicate entries with the same name but different sources.
384    ///
385    /// Pattern-expanded and transitive dependencies are preserved because:
386    /// - Pattern expansions will be re-added during resolution with (name, source) matching
387    /// - Transitive dependencies aren't manifest keys and won't be removed
388    ///
389    /// # Arguments
390    ///
391    /// * `lockfile` - The mutable lockfile to clean
392    /// * `manifest_keys` - Set of manifest dependency keys being updated
393    pub fn remove_manifest_entries_for_update(
394        &self,
395        lockfile: &mut LockFile,
396        manifest_keys: &HashSet<String>,
397    ) {
398        // Collect (name, source) pairs to remove
399        // We use (name, source) tuples to distinguish same-named resources from different sources
400        let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
401
402        // Step 1: Find direct manifest entries and collect them for transitive traversal
403        let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
404
405        for resource_type in ResourceType::all() {
406            let resources = lockfile.get_resources(resource_type);
407            for entry in resources {
408                // Check if this entry originates from a manifest key being updated
409                if manifest_keys.contains(&entry.name)
410                    || entry
411                        .manifest_alias
412                        .as_ref()
413                        .is_some_and(|alias| manifest_keys.contains(alias))
414                {
415                    let key = (entry.name.clone(), entry.source.clone());
416                    entries_to_remove.insert(key.clone());
417                    direct_entries.push(key);
418                }
419            }
420        }
421
422        // Step 2: For each direct entry, recursively collect its transitive children
423        // This ensures that when "agent-A" changes from repo1 to repo2, we also remove
424        // all transitive dependencies that came from repo1 via agent-A
425        for (parent_name, parent_source) in direct_entries {
426            // Find the parent entry in the lockfile
427            for resource_type in ResourceType::all() {
428                if let Some(parent_entry) = lockfile
429                    .get_resources(resource_type)
430                    .iter()
431                    .find(|e| e.name == parent_name && e.source == parent_source)
432                {
433                    // Walk its dependency tree
434                    Self::collect_transitive_children(
435                        lockfile,
436                        parent_entry,
437                        &mut entries_to_remove,
438                    );
439                }
440            }
441        }
442
443        // Step 3: Remove all marked entries
444        let should_remove = |entry: &LockedResource| {
445            entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
446        };
447
448        lockfile.agents.retain(|entry| !should_remove(entry));
449        lockfile.snippets.retain(|entry| !should_remove(entry));
450        lockfile.commands.retain(|entry| !should_remove(entry));
451        lockfile.scripts.retain(|entry| !should_remove(entry));
452        lockfile.hooks.retain(|entry| !should_remove(entry));
453        lockfile.mcp_servers.retain(|entry| !should_remove(entry));
454    }
455
456    /// Recursively collect all transitive children of a lockfile entry.
457    ///
458    /// This walks the dependency graph starting from `parent`, following the `dependencies`
459    /// field to find all resources that transitively depend on the parent. Only dependencies
460    /// with the same source as the parent are collected (to avoid removing unrelated resources).
461    ///
462    /// The `dependencies` field contains strings in the format:
463    /// - `"resource_type/name"` for dependencies from the same source
464    /// - `"source:resource_type/name:version"` for explicit source references
465    ///
466    /// # Arguments
467    ///
468    /// * `lockfile` - The lockfile to search for dependencies
469    /// * `parent` - The parent entry whose children we want to collect
470    /// * `entries_to_remove` - Set of (name, source) pairs to populate with found children
471    fn collect_transitive_children(
472        lockfile: &LockFile,
473        parent: &LockedResource,
474        entries_to_remove: &mut HashSet<(String, Option<String>)>,
475    ) {
476        // For each dependency declared by this parent
477        for dep_ref in parent.parsed_dependencies() {
478            let dep_path = &dep_ref.path;
479            let resource_type = dep_ref.resource_type;
480
481            // Extract the resource name from the path (filename without extension)
482            let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
483                .unwrap_or_else(|| dep_path.to_string());
484
485            // Determine the source: use explicit source from dep_ref if present, otherwise inherit from parent
486            let dep_source = dep_ref.source.or_else(|| parent.source.clone());
487
488            // Find the dependency entry with matching name and source
489            if let Some(dep_entry) = lockfile
490                .get_resources(&resource_type)
491                .iter()
492                .find(|e| e.name == dep_name && e.source == dep_source)
493            {
494                let key = (dep_entry.name.clone(), dep_entry.source.clone());
495
496                // Add to removal set and recurse (if not already processed)
497                if !entries_to_remove.contains(&key) {
498                    entries_to_remove.insert(key);
499                    // Recursively collect this dependency's children
500                    Self::collect_transitive_children(lockfile, dep_entry, entries_to_remove);
501                }
502            }
503        }
504    }
505}
506
507/// Adds pattern-expanded entries to the lockfile with deterministic deduplication.
508///
509/// This function adds multiple resolved entries from a pattern dependency to the
510/// appropriate resource type collection in the lockfile. When duplicates are found,
511/// it uses the same deterministic merge strategy as `add_or_update_lockfile_entry`
512/// to ensure consistent lockfile generation.
513///
514/// # Arguments
515///
516/// * `lockfile` - The mutable lockfile to update
517/// * `entries` - Vector of resolved resources from pattern expansion
518/// * `resource_type` - The type of resource being added
519///
520/// # Deduplication
521///
522/// Uses deterministic merge strategy:
523/// 1. Prefer manifest dependencies over transitive dependencies
524/// 2. Prefer install=true over install=false
525/// 3. Otherwise keep existing entry
526pub fn add_pattern_entries(
527    lockfile: &mut LockFile,
528    entries: Vec<LockedResource>,
529    resource_type: ResourceType,
530) {
531    let resources = lockfile.get_resources_mut(&resource_type);
532
533    for entry in entries {
534        if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
535            // Use deterministic merge strategy to ensure consistent lockfile generation
536            if should_replace_duplicate(existing, &entry) {
537                *existing = entry;
538            }
539        } else {
540            resources.push(entry);
541        }
542    }
543}
544
545/// Rewrites a dependency string to include version information.
546///
547/// This helper function transforms dependency strings by looking up version information
548/// in the provided maps and updating the dependency reference accordingly.
549///
550/// # Arguments
551///
552/// * `dep` - The original dependency string (must be properly formatted)
553/// * `lookup_map` - Map of (resource_type, path, source) -> name for resolving dependencies
554/// * `resource_info_map` - Map of (resource_type, name, source) -> (source, version) for version info
555/// * `parent_source` - The source of the parent resource (for inheritance)
556///
557/// # Returns
558///
559/// The updated dependency string with version information included, or the original
560/// dependency string if it cannot be parsed or no version info is found
561fn rewrite_dependency_string(
562    dep: &str,
563    lookup_map: &HashMap<(ResourceType, String, Option<String>), String>,
564    resource_info_map: &HashMap<ResourceKey, ResourceInfo>,
565    parent_source: Option<String>,
566) -> String {
567    // Parse dependency using DependencyReference - only support properly formatted dependencies
568    if let Ok(existing_dep) = LockfileDependencyRef::from_str(dep) {
569        // If it's already a properly formatted dependency, try to add version info if missing
570        let dep_source = existing_dep.source.clone().or_else(|| parent_source.clone());
571        let dep_resource_type = existing_dep.resource_type;
572        let dep_path = existing_dep.path.clone();
573
574        // Look up resource in same source
575        if let Some(dep_name) = lookup_map.get(&(
576            dep_resource_type,
577            dependency_helpers::normalize_lookup_path(&dep_path),
578            dep_source.clone(),
579        )) {
580            if let Some((_source, Some(ver))) =
581                resource_info_map.get(&(dep_resource_type, dep_name.clone(), dep_source.clone()))
582            {
583                // Create updated dependency reference with version
584                return LockfileDependencyRef::git(
585                    dep_source.clone().unwrap_or_default(),
586                    dep_resource_type,
587                    dep_path,
588                    Some(ver.clone()),
589                )
590                .to_string();
591            }
592        }
593
594        // Return as-is if no version info found
595        existing_dep.to_string()
596    } else {
597        // Return as-is if parsing fails
598        dep.to_string()
599    }
600}
601
602// ============================================================================
603// Lockfile Helper Operations
604// ============================================================================
605
606/// Helper to generate a unique key for grouping dependencies.
607#[allow(dead_code)] // Not yet used in service-based refactoring
608pub(super) fn group_key(source: &str, version: &str) -> String {
609    format!("{source}::{version}")
610}
611
612/// Get patches for a specific resource from the manifest.
613///
614/// Looks up patches defined in `[patch.<resource_type>.<alias>]` sections
615/// and returns them as a HashMap ready for inclusion in the lockfile.
616///
617/// For pattern-expanded resources, the manifest_alias should be provided to ensure
618/// patches are looked up using the original pattern name rather than the concrete
619/// resource name.
620///
621/// # Arguments
622///
623/// * `manifest` - Reference to the project manifest containing patches
624/// * `resource_type` - Type of the resource (agent, snippet, command, etc.)
625/// * `name` - Resource name to look up patches for
626/// * `manifest_alias` - Optional manifest alias for pattern-expanded resources
627///
628/// # Returns
629///
630/// BTreeMap of patch key-value pairs, or empty BTreeMap if no patches defined
631pub(super) fn get_patches_for_resource(
632    manifest: &Manifest,
633    resource_type: ResourceType,
634    name: &str,
635    manifest_alias: Option<&str>,
636) -> BTreeMap<String, toml::Value> {
637    // Use manifest_alias for pattern-expanded resources, name for regular resources
638    let lookup_name = manifest_alias.unwrap_or(name);
639
640    let patches = match resource_type {
641        ResourceType::Agent => &manifest.patches.agents,
642        ResourceType::Snippet => &manifest.patches.snippets,
643        ResourceType::Command => &manifest.patches.commands,
644        ResourceType::Script => &manifest.patches.scripts,
645        ResourceType::Hook => &manifest.patches.hooks,
646        ResourceType::McpServer => &manifest.patches.mcp_servers,
647    };
648
649    patches.get(lookup_name).cloned().unwrap_or_default()
650}
651
652/// Build the complete merged template variable context for a dependency.
653///
654/// This creates the full variant_inputs that should be stored in the lockfile,
655/// combining both the global project configuration and any dependency-specific
656/// variant_inputs overrides.
657///
658/// This ensures lockfile entries contain the exact template context that was
659/// used during dependency resolution, enabling reproducible builds.
660///
661/// # Arguments
662///
663/// * `manifest` - Reference to the project manifest containing global project config
664/// * `dep` - The dependency to build variant_inputs for
665///
666/// # Returns
667///
668/// Complete merged variant_inputs (always returns a Value, empty if no variables)
669pub(super) fn build_merged_variant_inputs(
670    manifest: &Manifest,
671    dep: &ResourceDependency,
672) -> serde_json::Value {
673    use crate::templating::deep_merge_json;
674
675    // Start with dependency-level template_vars (if any)
676    let dep_vars = dep.get_template_vars();
677
678    tracing::debug!(
679        "[DEBUG] build_merged_variant_inputs: dep_path='{}', has_dep_vars={}, dep_vars={:?}",
680        dep.get_path(),
681        dep_vars.is_some(),
682        dep_vars
683    );
684
685    // Get global project config as JSON
686    let global_project = manifest
687        .project
688        .as_ref()
689        .map(|p| p.to_json_value())
690        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
691
692    tracing::debug!("[DEBUG] build_merged_variant_inputs: global_project={:?}", global_project);
693
694    // Build complete context
695    let mut merged_map = serde_json::Map::new();
696
697    // If dependency has template_vars, start with those
698    if let Some(vars) = dep_vars {
699        if let Some(obj) = vars.as_object() {
700            merged_map.extend(obj.clone());
701        }
702    }
703
704    // Extract project overrides from dependency template_vars (if present)
705    let project_overrides = dep_vars
706        .and_then(|v| v.get("project").cloned())
707        .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
708
709    // Deep merge global project config with dependency-specific overrides
710    let merged_project = deep_merge_json(global_project, &project_overrides);
711
712    // Add merged project config to the template_vars only if it's not empty
713    if let Some(project_obj) = merged_project.as_object() {
714        if !project_obj.is_empty() {
715            merged_map.insert("project".to_string(), merged_project);
716        }
717    }
718
719    // Always return a Value (empty object if nothing else)
720    let result = serde_json::Value::Object(merged_map);
721
722    tracing::debug!(
723        "[DEBUG] build_merged_variant_inputs: dep_path='{}', result={:?}",
724        dep.get_path(),
725        result
726    );
727
728    result
729}
730
731/// Variant inputs with JSON value and computed hash.
732///
733/// This struct holds the variant inputs as a JSON value along with its
734/// pre-computed SHA-256 hash for identity comparison. Computing the hash
735/// once ensures consistency throughout the codebase.
736///
737/// Uses `#[serde(transparent)]` so it serializes as the JSON value directly,
738/// which becomes a TOML table when serialized to TOML.
739/// The hash is transient and recomputed after deserialization.
740#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
741#[serde(transparent)]
742pub struct VariantInputs {
743    /// The JSON value
744    json: serde_json::Value,
745    /// SHA-256 hash of the serialized JSON (not serialized, computed on load)
746    #[serde(skip)]
747    hash: String,
748}
749
750impl PartialEq for VariantInputs {
751    fn eq(&self, other: &Self) -> bool {
752        // Compare by hash for performance (avoid deep JSON comparison)
753        self.hash == other.hash
754    }
755}
756
757impl Eq for VariantInputs {}
758
759impl Default for VariantInputs {
760    fn default() -> Self {
761        Self::new(serde_json::Value::Object(serde_json::Map::new()))
762    }
763}
764
765impl VariantInputs {
766    /// Create a new VariantInputs from a JSON value, computing the hash once.
767    pub fn new(json: serde_json::Value) -> Self {
768        // Compute hash using centralized function
769        let hash = crate::utils::compute_variant_inputs_hash(&json).unwrap_or_else(|_| {
770            // Fallback to empty hash if serialization fails (shouldn't happen)
771            tracing::error!("Failed to compute variant_inputs_hash, using empty hash");
772            "sha256:".to_string()
773        });
774
775        Self {
776            json,
777            hash,
778        }
779    }
780
781    /// Get the JSON value
782    pub fn json(&self) -> &serde_json::Value {
783        &self.json
784    }
785
786    /// Get the SHA-256 hash
787    pub fn hash(&self) -> &str {
788        &self.hash
789    }
790
791    /// Recompute the hash from the JSON value.
792    ///
793    /// This is called after deserialization since the hash field is skipped.
794    pub fn recompute_hash(&mut self) {
795        self.hash = crate::utils::compute_variant_inputs_hash(&self.json).unwrap_or_else(|_| {
796            tracing::error!("Failed to recompute variant_inputs_hash");
797            "sha256:".to_string()
798        });
799    }
800}
801
802/// Adds or updates a resource entry in the lockfile based on resource type.
803///
804/// This helper method eliminates code duplication between the `resolve()` and `update()`
805/// methods by centralizing lockfile entry management logic. It automatically determines
806/// the resource type from the entry name and adds or updates the entry in the appropriate
807/// collection within the lockfile.
808///
809/// The method performs upsert behavior - if an entry with matching name and source
810/// already exists in the appropriate collection, it will be updated (including version);
811/// otherwise, a new entry is added. This allows version updates (e.g., v1.0 → v2.0)
812/// to replace the existing entry rather than creating duplicates.
813///
814/// # Arguments
815///
816/// * `lockfile` - Mutable reference to the lockfile to modify
817/// * `entry` - The [`LockedResource`] entry to add or update
818#[allow(dead_code)] // Not yet used in service-based refactoring
819pub(super) fn add_or_update_lockfile_entry(lockfile: &mut LockFile, entry: LockedResource) {
820    let resources = lockfile.get_resources_mut(&entry.resource_type);
821
822    if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
823        // Use deterministic merge strategy to ensure consistent lockfile generation
824        if should_replace_duplicate(existing, &entry) {
825            *existing = entry;
826        }
827    } else {
828        resources.push(entry);
829    }
830}
831
832/// Removes stale lockfile entries that are no longer in the manifest.
833///
834/// This method removes lockfile entries for direct manifest dependencies that have been
835/// commented out or removed from the manifest. This must be called BEFORE
836/// `remove_manifest_entries_for_update()` to ensure stale entries don't cause conflicts
837/// during resolution.
838///
839/// A manifest-level entry is identified by:
840/// - `manifest_alias.is_none()` - Direct dependency with no pattern expansion
841/// - `manifest_alias.is_some()` - Pattern-expanded dependency (alias must be in manifest)
842///
843/// For each stale entry found, this also removes its transitive children to maintain
844/// lockfile consistency.
845///
846/// # Arguments
847///
848/// * `manifest` - Reference to the current project manifest
849/// * `lockfile` - The mutable lockfile to clean
850#[allow(dead_code)] // Not yet used in service-based refactoring
851pub(super) fn remove_stale_manifest_entries(manifest: &Manifest, lockfile: &mut LockFile) {
852    // Collect all current manifest keys for each resource type
853    let manifest_agents: HashSet<String> = manifest.agents.keys().map(|k| k.to_string()).collect();
854    let manifest_snippets: HashSet<String> =
855        manifest.snippets.keys().map(|k| k.to_string()).collect();
856    let manifest_commands: HashSet<String> =
857        manifest.commands.keys().map(|k| k.to_string()).collect();
858    let manifest_scripts: HashSet<String> =
859        manifest.scripts.keys().map(|k| k.to_string()).collect();
860    let manifest_hooks: HashSet<String> = manifest.hooks.keys().map(|k| k.to_string()).collect();
861    let manifest_mcp_servers: HashSet<String> =
862        manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
863
864    // Helper to get the right manifest keys for a resource type
865    let get_manifest_keys = |resource_type: ResourceType| match resource_type {
866        ResourceType::Agent => &manifest_agents,
867        ResourceType::Snippet => &manifest_snippets,
868        ResourceType::Command => &manifest_commands,
869        ResourceType::Script => &manifest_scripts,
870        ResourceType::Hook => &manifest_hooks,
871        ResourceType::McpServer => &manifest_mcp_servers,
872    };
873
874    // Collect (name, source) pairs to remove
875    let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
876    let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
877
878    // Find all manifest-level entries that are no longer in the manifest
879    for resource_type in ResourceType::all() {
880        let manifest_keys = get_manifest_keys(*resource_type);
881        let resources = lockfile.get_resources(resource_type);
882
883        for entry in resources {
884            // Determine if this is a stale manifest-level entry (no longer in manifest)
885            let is_stale = if let Some(ref alias) = entry.manifest_alias {
886                // Pattern-expanded entry: stale if alias is NOT in manifest
887                !manifest_keys.contains(alias)
888            } else {
889                // Direct entry: stale if name is NOT in manifest
890                !manifest_keys.contains(&entry.name)
891            };
892
893            if is_stale {
894                let key = (entry.name.clone(), entry.source.clone());
895                entries_to_remove.insert(key.clone());
896                direct_entries.push(key);
897            }
898        }
899    }
900
901    // For each stale entry, recursively collect its transitive children
902    for (parent_name, parent_source) in direct_entries {
903        for resource_type in ResourceType::all() {
904            if let Some(parent_entry) = lockfile
905                .get_resources(resource_type)
906                .iter()
907                .find(|e| e.name == parent_name && e.source == parent_source)
908            {
909                collect_transitive_children(lockfile, parent_entry, &mut entries_to_remove);
910            }
911        }
912    }
913
914    // Remove all marked entries
915    let should_remove = |entry: &LockedResource| {
916        entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
917    };
918
919    lockfile.agents.retain(|entry| !should_remove(entry));
920    lockfile.snippets.retain(|entry| !should_remove(entry));
921    lockfile.commands.retain(|entry| !should_remove(entry));
922    lockfile.scripts.retain(|entry| !should_remove(entry));
923    lockfile.hooks.retain(|entry| !should_remove(entry));
924    lockfile.mcp_servers.retain(|entry| !should_remove(entry));
925}
926
927/// Removes lockfile entries for manifest dependencies that will be re-resolved.
928///
929/// This method removes old entries for direct manifest dependencies before updating,
930/// which handles the case where a dependency's source or resource type changes.
931/// This prevents duplicate entries with the same name but different sources.
932///
933/// Pattern-expanded and transitive dependencies are preserved because:
934/// - Pattern expansions will be re-added during resolution with (name, source) matching
935/// - Transitive dependencies aren't manifest keys and won't be removed
936///
937/// # Arguments
938///
939/// * `lockfile` - The mutable lockfile to clean
940/// * `manifest_keys` - Set of manifest dependency keys being updated
941#[allow(dead_code)] // Not yet used in service-based refactoring
942pub(super) fn remove_manifest_entries_for_update(
943    lockfile: &mut LockFile,
944    manifest_keys: &HashSet<String>,
945) {
946    // Collect (name, source) pairs to remove
947    // We use (name, source) tuples to distinguish same-named resources from different sources
948    let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
949
950    // Step 1: Find direct manifest entries and collect them for transitive traversal
951    let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
952
953    for resource_type in ResourceType::all() {
954        let resources = lockfile.get_resources(resource_type);
955        for entry in resources {
956            // Check if this entry originates from a manifest key being updated
957            if manifest_keys.contains(&entry.name)
958                || entry.manifest_alias.as_ref().is_some_and(|alias| manifest_keys.contains(alias))
959            {
960                let key = (entry.name.clone(), entry.source.clone());
961                entries_to_remove.insert(key.clone());
962                direct_entries.push(key);
963            }
964        }
965    }
966
967    // Step 2: For each direct entry, recursively collect its transitive children
968    // This ensures that when "agent-A" changes from repo1 to repo2, we also remove
969    // all transitive dependencies that came from repo1 via agent-A
970    for (parent_name, parent_source) in direct_entries {
971        // Find the parent entry in the lockfile
972        for resource_type in ResourceType::all() {
973            if let Some(parent_entry) = lockfile
974                .get_resources(resource_type)
975                .iter()
976                .find(|e| e.name == parent_name && e.source == parent_source)
977            {
978                // Walk its dependency tree
979                collect_transitive_children(lockfile, parent_entry, &mut entries_to_remove);
980            }
981        }
982    }
983
984    // Step 3: Remove all marked entries
985    let should_remove = |entry: &LockedResource| {
986        entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
987    };
988
989    lockfile.agents.retain(|entry| !should_remove(entry));
990    lockfile.snippets.retain(|entry| !should_remove(entry));
991    lockfile.commands.retain(|entry| !should_remove(entry));
992    lockfile.scripts.retain(|entry| !should_remove(entry));
993    lockfile.hooks.retain(|entry| !should_remove(entry));
994    lockfile.mcp_servers.retain(|entry| !should_remove(entry));
995}
996
997/// Recursively collect all transitive children of a lockfile entry.
998///
999/// This walks the dependency graph starting from `parent`, following the `dependencies`
1000/// field to find all resources that transitively depend on the parent. Only dependencies
1001/// with the same source as the parent are collected (to avoid removing unrelated resources).
1002///
1003/// The `dependencies` field contains strings in the format:
1004/// - `"resource_type/name"` for dependencies from the same source
1005/// - `"source:resource_type/name:version"` for explicit source references
1006///
1007/// # Arguments
1008///
1009/// * `lockfile` - The lockfile to search for dependencies
1010/// * `parent` - The parent entry whose children we want to collect
1011/// * `entries_to_remove` - Set of (name, source) pairs to populate with found children
1012#[allow(dead_code)] // Not yet used in service-based refactoring
1013pub(super) fn collect_transitive_children(
1014    lockfile: &LockFile,
1015    parent: &LockedResource,
1016    entries_to_remove: &mut HashSet<(String, Option<String>)>,
1017) {
1018    // For each dependency declared by this parent
1019    for dep_ref in parent.parsed_dependencies() {
1020        let dep_path = &dep_ref.path;
1021        let resource_type = dep_ref.resource_type;
1022
1023        // Extract the resource name from the path (filename without extension)
1024        let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
1025            .unwrap_or_else(|| dep_path.to_string());
1026
1027        // Determine the source: use explicit source from dep_ref if present, otherwise inherit from parent
1028        let dep_source = dep_ref.source.or_else(|| parent.source.clone());
1029
1030        // Find the dependency entry with matching name and source
1031        if let Some(dep_entry) = lockfile
1032            .get_resources(&resource_type)
1033            .iter()
1034            .find(|e| e.name == dep_name && e.source == dep_source)
1035        {
1036            let key = (dep_entry.name.clone(), dep_entry.source.clone());
1037
1038            // Add to removal set and recurse (if not already processed)
1039            if !entries_to_remove.contains(&key) {
1040                entries_to_remove.insert(key);
1041                // Recursively collect this dependency's children
1042                collect_transitive_children(lockfile, dep_entry, entries_to_remove);
1043            }
1044        }
1045    }
1046}
1047
1048/// Detects conflicts where multiple dependencies resolve to the same installation path.
1049///
1050/// This method validates that no two dependencies will overwrite each other during
1051/// installation. It builds a map of all resolved `installed_at` paths and checks for
1052/// collisions across all resource types.
1053///
1054/// # Arguments
1055///
1056/// * `lockfile` - The lockfile containing all resolved dependencies
1057///
1058/// # Returns
1059///
1060/// Returns `Ok(())` if no conflicts are detected, or an error describing the conflicts.
1061///
1062/// # Errors
1063///
1064/// Returns an error if:
1065/// - Two or more dependencies resolve to the same `installed_at` path
1066/// - The error message lists all conflicting dependency names and the shared path
1067pub(super) fn detect_target_conflicts(lockfile: &LockFile) -> Result<()> {
1068    // Map of (installed_at path, resolved_commit) -> list of dependency names
1069    // Two dependencies with the same path AND same commit are NOT a conflict
1070    let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
1071
1072    // Collect all resources from lockfile
1073    // Note: Hooks and MCP servers are excluded because they're configuration-only
1074    // resources that are designed to share config files (.claude/settings.local.json
1075    // for hooks, .mcp.json for MCP servers), not individual files that would conflict.
1076    // Also skip resources with install=false since they don't create files.
1077    let all_resources: Vec<(&str, &LockedResource)> = lockfile
1078        .agents
1079        .iter()
1080        .filter(|r| r.install != Some(false))
1081        .map(|r| (r.name.as_str(), r))
1082        .chain(
1083            lockfile
1084                .snippets
1085                .iter()
1086                .filter(|r| r.install != Some(false))
1087                .map(|r| (r.name.as_str(), r)),
1088        )
1089        .chain(
1090            lockfile
1091                .commands
1092                .iter()
1093                .filter(|r| r.install != Some(false))
1094                .map(|r| (r.name.as_str(), r)),
1095        )
1096        .chain(
1097            lockfile
1098                .scripts
1099                .iter()
1100                .filter(|r| r.install != Some(false))
1101                .map(|r| (r.name.as_str(), r)),
1102        )
1103        // Hooks and MCP servers intentionally omitted - they share config files
1104        .collect();
1105
1106    // Build the path map with commit information
1107    for (name, resource) in &all_resources {
1108        let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
1109        path_map.entry(key).or_default().push((*name).to_string());
1110    }
1111
1112    // Now check for actual conflicts: same path but DIFFERENT commits
1113    // Group by path only to find potential conflicts
1114    let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
1115    for (name, resource) in &all_resources {
1116        path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
1117    }
1118
1119    // Find conflicts (same path with different commits OR local deps with same path)
1120    let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
1121    for (path, resources) in path_only_map {
1122        if resources.len() > 1 {
1123            // Check if they have different commits
1124            let commits: HashSet<_> = resources.iter().map(|(_, r)| &r.resolved_commit).collect();
1125
1126            // Conflict if:
1127            // 1. Different commits (different content from Git)
1128            // 2. All are local dependencies (resolved_commit = None) - can't overwrite same path
1129            let all_local = commits.len() == 1 && commits.contains(&None);
1130
1131            if commits.len() > 1 || all_local {
1132                let names: Vec<String> = resources.iter().map(|(n, _)| (*n).to_string()).collect();
1133                conflicts.push((path, names));
1134            }
1135        }
1136    }
1137
1138    if !conflicts.is_empty() {
1139        // Build a detailed error message
1140        let mut error_msg = String::from(
1141            "Target path conflicts detected:\n\n\
1142             Multiple dependencies resolve to the same installation path with different content.\n\
1143             This would cause files to overwrite each other.\n\n",
1144        );
1145
1146        for (path, names) in &conflicts {
1147            error_msg.push_str(&format!("  Path: {}\n  Conflicts: {}\n\n", path, names.join(", ")));
1148        }
1149
1150        error_msg.push_str(
1151            "To resolve this conflict:\n\
1152             1. Use custom 'target' field to specify different installation paths:\n\
1153                Example: target = \"custom/subdir/file.md\"\n\n\
1154             2. Use custom 'filename' field to specify different filenames:\n\
1155                Example: filename = \"utils-v2.md\"\n\n\
1156             3. For transitive dependencies, add them as direct dependencies with custom target/filename\n\n\
1157             4. Ensure pattern dependencies don't overlap with single-file dependencies\n\n\
1158             Note: This often occurs when different dependencies have transitive dependencies\n\
1159             with the same name but from different sources.",
1160        );
1161
1162        return Err(anyhow::anyhow!(error_msg));
1163    }
1164
1165    Ok(())
1166}
1167
1168/// Add version information to dependency references in all lockfile entries.
1169///
1170/// This post-processing step updates the `dependencies` field of each locked resource
1171/// to include version information (e.g., converting "agent/helper" to "agent/helper@v1.0.0").
1172///
1173/// # Arguments
1174///
1175/// * `lockfile` - The mutable lockfile to update
1176pub(super) fn add_version_to_all_dependencies(lockfile: &mut LockFile) {
1177    use crate::resolver::types as dependency_helpers;
1178
1179    // Build lookup map: (resource_type, normalized_path, source) -> name
1180    let mut lookup_map: HashMap<(ResourceType, String, Option<String>), String> = HashMap::new();
1181
1182    // Build lookup from all lockfile entries
1183    for resource_type in ResourceType::all() {
1184        for entry in lockfile.get_resources(resource_type) {
1185            let normalized_path = dependency_helpers::normalize_lookup_path(&entry.path);
1186            lookup_map.insert(
1187                (*resource_type, normalized_path.clone(), entry.source.clone()),
1188                entry.name.clone(),
1189            );
1190
1191            // Also store by filename for backward compatibility
1192            if let Some(filename) = dependency_helpers::extract_filename_from_path(&entry.path) {
1193                lookup_map
1194                    .insert((*resource_type, filename, entry.source.clone()), entry.name.clone());
1195            }
1196
1197            // Also store by type-stripped path
1198            if let Some(stripped) =
1199                dependency_helpers::strip_resource_type_directory(&normalized_path)
1200            {
1201                lookup_map
1202                    .insert((*resource_type, stripped, entry.source.clone()), entry.name.clone());
1203            }
1204        }
1205    }
1206
1207    // Build resource info map: (resource_type, name, source) -> (source, version)
1208    let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
1209
1210    for resource_type in ResourceType::all() {
1211        for entry in lockfile.get_resources(resource_type) {
1212            resource_info_map.insert(
1213                (*resource_type, entry.name.clone(), entry.source.clone()),
1214                (entry.source.clone(), entry.version.clone()),
1215            );
1216        }
1217    }
1218
1219    // Update dependencies in all resources
1220    for resource_type in ResourceType::all() {
1221        let resources = lockfile.get_resources_mut(resource_type);
1222        for entry in resources {
1223            let parent_source = entry.source.clone();
1224
1225            let updated_deps: Vec<String> = entry
1226                .dependencies
1227                .iter()
1228                .map(|dep| {
1229                    rewrite_dependency_string(
1230                        dep,
1231                        &lookup_map,
1232                        &resource_info_map,
1233                        parent_source.clone(),
1234                    )
1235                })
1236                .collect();
1237
1238            entry.dependencies = updated_deps;
1239        }
1240    }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245    use super::*;
1246    use crate::core::ResourceType;
1247    use crate::lockfile::LockedResource;
1248    use crate::manifest::ResourceDependency;
1249
1250    fn create_test_manifest() -> Manifest {
1251        let mut manifest = Manifest::default();
1252        manifest.agents.insert(
1253            "test-agent".to_string(),
1254            ResourceDependency::Simple("agents/test-agent.md".to_string()),
1255        );
1256        manifest.snippets.insert(
1257            "test-snippet".to_string(),
1258            ResourceDependency::Simple("snippets/test-snippet.md".to_string()),
1259        );
1260        manifest
1261    }
1262
1263    fn create_test_lockfile() -> LockFile {
1264        let mut lockfile = LockFile::default();
1265
1266        // Add some test entries
1267        lockfile.agents.push(LockedResource {
1268            name: "test-agent".to_string(),
1269            source: Some("community".to_string()),
1270            url: Some("https://github.com/test/repo.git".to_string()),
1271            path: "agents/test-agent.md".to_string(),
1272            version: Some("v1.0.0".to_string()),
1273            resolved_commit: Some("abc123".to_string()),
1274            checksum: "sha256:test".to_string(),
1275            installed_at: ".claude/agents/test-agent.md".to_string(),
1276            dependencies: vec![],
1277            resource_type: ResourceType::Agent,
1278            tool: Some("claude-code".to_string()),
1279            manifest_alias: None,
1280            context_checksum: None,
1281            applied_patches: std::collections::BTreeMap::new(),
1282            install: None,
1283            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1284        });
1285
1286        lockfile.snippets.push(LockedResource {
1287            name: "test-snippet".to_string(),
1288            source: Some("community".to_string()),
1289            url: Some("https://github.com/test/repo.git".to_string()),
1290            path: "snippets/test-snippet.md".to_string(),
1291            version: Some("v1.0.0".to_string()),
1292            resolved_commit: Some("def456".to_string()),
1293            checksum: "sha256:test2".to_string(),
1294            installed_at: ".claude/snippets/test-snippet.md".to_string(),
1295            dependencies: vec![],
1296            resource_type: ResourceType::Snippet,
1297            tool: Some("claude-code".to_string()),
1298            manifest_alias: None,
1299            context_checksum: None,
1300            applied_patches: std::collections::BTreeMap::new(),
1301            install: None,
1302            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1303        });
1304
1305        lockfile
1306    }
1307
1308    #[test]
1309    fn test_add_or_update_lockfile_entry_new() {
1310        let manifest = create_test_manifest();
1311        let builder = LockfileBuilder::new(&manifest);
1312        let mut lockfile = LockFile::default();
1313
1314        let entry = LockedResource {
1315            resource_type: ResourceType::Agent,
1316            name: "new-agent".to_string(),
1317            source: Some("community".to_string()),
1318            url: Some("https://github.com/test/repo.git".to_string()),
1319            path: "agents/new-agent.md".to_string(),
1320            version: Some("v1.0.0".to_string()),
1321            tool: Some("claude-code".to_string()),
1322            manifest_alias: None,
1323            context_checksum: None,
1324            installed_at: ".claude/agents/new-agent.md".to_string(),
1325            resolved_commit: Some("xyz789".to_string()),
1326            checksum: "sha256:new".to_string(),
1327            dependencies: vec![],
1328            applied_patches: std::collections::BTreeMap::new(),
1329            install: None,
1330            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1331        };
1332
1333        builder.add_or_update_lockfile_entry(&mut lockfile, "new-agent", entry);
1334
1335        assert_eq!(lockfile.agents.len(), 1);
1336        assert_eq!(lockfile.agents[0].name, "new-agent");
1337    }
1338
1339    #[test]
1340    fn test_add_or_update_lockfile_entry_replace() {
1341        let manifest = create_test_manifest();
1342        let builder = LockfileBuilder::new(&manifest);
1343        let mut lockfile = create_test_lockfile();
1344
1345        let updated_entry = LockedResource {
1346            resource_type: ResourceType::Agent,
1347            name: "test-agent".to_string(),
1348            source: Some("community".to_string()),
1349            url: Some("https://github.com/test/repo.git".to_string()),
1350            path: "agents/test-agent.md".to_string(),
1351            version: Some("v1.0.0".to_string()),
1352            tool: Some("claude-code".to_string()),
1353            manifest_alias: Some("test-agent".to_string()), // Manifest dependency being updated
1354            context_checksum: None,
1355            installed_at: ".claude/agents/test-agent.md".to_string(),
1356            resolved_commit: Some("updated123".to_string()), // Updated commit
1357            checksum: "sha256:updated".to_string(),          // Updated checksum
1358            dependencies: vec![],
1359            applied_patches: std::collections::BTreeMap::new(),
1360            install: None,
1361            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1362        };
1363
1364        builder.add_or_update_lockfile_entry(&mut lockfile, "test-agent", updated_entry);
1365
1366        assert_eq!(lockfile.agents.len(), 1);
1367        assert_eq!(lockfile.agents[0].resolved_commit, Some("updated123".to_string()));
1368        assert_eq!(lockfile.agents[0].checksum, "sha256:updated");
1369    }
1370
1371    #[test]
1372    fn test_remove_stale_manifest_entries() {
1373        let mut manifest = create_test_manifest();
1374        // Remove one agent from manifest to make it stale
1375        manifest.agents.remove("test-agent");
1376
1377        let builder = LockfileBuilder::new(&manifest);
1378        let mut lockfile = create_test_lockfile();
1379
1380        builder.remove_stale_manifest_entries(&mut lockfile);
1381
1382        // test-agent should be removed, test-snippet should remain
1383        assert_eq!(lockfile.agents.len(), 0);
1384        assert_eq!(lockfile.snippets.len(), 1);
1385        assert_eq!(lockfile.snippets[0].name, "test-snippet");
1386    }
1387
1388    #[test]
1389    fn test_remove_manifest_entries_for_update() {
1390        let manifest = create_test_manifest();
1391        let builder = LockfileBuilder::new(&manifest);
1392        let mut lockfile = create_test_lockfile();
1393
1394        let mut manifest_keys = HashSet::new();
1395        manifest_keys.insert("test-agent".to_string());
1396
1397        builder.remove_manifest_entries_for_update(&mut lockfile, &manifest_keys);
1398
1399        // test-agent should be removed, test-snippet should remain
1400        assert_eq!(lockfile.agents.len(), 0);
1401        assert_eq!(lockfile.snippets.len(), 1);
1402        assert_eq!(lockfile.snippets[0].name, "test-snippet");
1403    }
1404
1405    #[test]
1406    fn test_collect_transitive_children() {
1407        let lockfile = create_test_lockfile();
1408        let mut entries_to_remove = HashSet::new();
1409
1410        // Create a parent with dependencies
1411        let parent = LockedResource {
1412            resource_type: ResourceType::Agent,
1413            name: "parent".to_string(),
1414            source: Some("community".to_string()),
1415            url: Some("https://github.com/test/repo.git".to_string()),
1416            path: "agents/parent.md".to_string(),
1417            version: Some("v1.0.0".to_string()),
1418            tool: Some("claude-code".to_string()),
1419            manifest_alias: None,
1420            context_checksum: None,
1421            installed_at: ".claude/agents/parent.md".to_string(),
1422            resolved_commit: Some("parent123".to_string()),
1423            checksum: "sha256:parent".to_string(),
1424            dependencies: vec!["agent:agents/test-agent".to_string()], // Reference to test-agent (new format)
1425            applied_patches: std::collections::BTreeMap::new(),
1426            install: None,
1427            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1428        };
1429
1430        LockfileBuilder::collect_transitive_children(&lockfile, &parent, &mut entries_to_remove);
1431
1432        // Should find the test-agent dependency
1433        assert!(
1434            entries_to_remove.contains(&("test-agent".to_string(), Some("community".to_string())))
1435        );
1436    }
1437
1438    #[test]
1439    fn test_build_merged_variant_inputs_preserves_all_keys() {
1440        use crate::manifest::DetailedDependency;
1441        use serde_json::json;
1442
1443        // Create a manifest with no global project config
1444        let manifest_toml = r#"
1445[sources]
1446test-repo = "https://example.com/repo.git"
1447        "#;
1448
1449        let manifest: Manifest = toml::from_str(manifest_toml).unwrap();
1450
1451        // Create a dependency with template_vars containing both project and config
1452        let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
1453            source: Some("test-repo".to_string()),
1454            path: "agents/test.md".to_string(),
1455            version: Some("v1.0.0".to_string()),
1456            branch: None,
1457            rev: None,
1458            command: None,
1459            args: None,
1460            target: None,
1461            filename: None,
1462            dependencies: None,
1463            tool: None,
1464            flatten: None,
1465            install: None,
1466            template_vars: Some(json!({
1467                "project": { "name": "Production" },
1468                "config": { "model": "claude-3-opus", "temperature": 0.5 }
1469            })),
1470        }));
1471
1472        // Call build_merged_variant_inputs
1473        let result = build_merged_variant_inputs(&manifest, &dep);
1474
1475        // Print the result for debugging
1476        println!(
1477            "Result: {}",
1478            serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
1479        );
1480
1481        // Verify both project and config are present
1482        assert!(result.get("project").is_some(), "project should be present in variant_inputs");
1483        assert!(result.get("config").is_some(), "config should be present in variant_inputs");
1484
1485        let config = result.get("config").unwrap();
1486        assert_eq!(config.get("model").unwrap().as_str().unwrap(), "claude-3-opus");
1487        assert_eq!(config.get("temperature").unwrap().as_f64().unwrap(), 0.5);
1488    }
1489
1490    #[test]
1491    fn test_direct_vs_transitive_with_different_template_vars_should_not_deduplicate() {
1492        use serde_json::json;
1493
1494        // Create direct dependency with template_vars = {lang: "rust"}
1495        let direct = LockedResource {
1496            name: "agents/generic".to_string(),
1497            manifest_alias: Some("generic-rust".to_string()), // Direct from manifest
1498            source: Some("community".to_string()),
1499            url: Some("https://github.com/test/repo.git".to_string()),
1500            path: "agents/generic.md".to_string(),
1501            version: Some("v1.0.0".to_string()),
1502            resolved_commit: Some("abc123".to_string()),
1503            checksum: "sha256:direct".to_string(),
1504            installed_at: ".claude/agents/generic-rust.md".to_string(),
1505            dependencies: vec![],
1506            resource_type: ResourceType::Agent,
1507            tool: Some("claude-code".to_string()),
1508            context_checksum: None,
1509            applied_patches: std::collections::BTreeMap::new(),
1510            install: None,
1511            variant_inputs: VariantInputs::new(json!({"lang": "rust"})),
1512        };
1513
1514        // Create transitive dependency with template_vars = {lang: "python"}
1515        let transitive = LockedResource {
1516            name: "agents/generic".to_string(),
1517            manifest_alias: None, // Transitive dependency
1518            source: Some("community".to_string()),
1519            url: Some("https://github.com/test/repo.git".to_string()),
1520            path: "agents/generic.md".to_string(),
1521            version: Some("v1.0.0".to_string()),
1522            resolved_commit: Some("abc123".to_string()),
1523            checksum: "sha256:transitive".to_string(),
1524            installed_at: ".claude/agents/generic.md".to_string(),
1525            dependencies: vec![],
1526            resource_type: ResourceType::Agent,
1527            tool: Some("claude-code".to_string()),
1528            context_checksum: None,
1529            applied_patches: std::collections::BTreeMap::new(),
1530            install: None,
1531            variant_inputs: VariantInputs::new(json!({"lang": "python"})),
1532        };
1533
1534        // According to the CRITICAL note in the code:
1535        // "template_vars are part of the resource identity! Resources with
1536        // different template_vars are DISTINCT resources that must all exist in the lockfile."
1537        //
1538        // Therefore, these should NOT be considered duplicates even though
1539        // one is direct and one is transitive.
1540        let is_dup = is_duplicate_entry(&direct, &transitive);
1541
1542        assert!(
1543            !is_dup,
1544            "Direct and transitive dependencies with different template_vars should NOT be duplicates. \
1545             They represent distinct resources that both need to exist in the lockfile."
1546        );
1547    }
1548}