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