use crate::core::ResourceType;
use crate::lockfile::{LockFile, LockedResource, lockfile_dependency_ref::LockfileDependencyRef};
use crate::manifest::{Manifest, ResourceDependency};
use crate::resolver::types as dependency_helpers;
use anyhow::Result;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::str::FromStr;
type ResourceKey = (ResourceType, String, Option<String>);
type ResourceInfo = (Option<String>, Option<String>);
pub fn is_duplicate_entry(existing: &LockedResource, new_entry: &LockedResource) -> bool {
tracing::info!(
"is_duplicate_entry: existing.name='{}', new.name='{}', existing.manifest_alias={:?}, new.manifest_alias={:?}, existing.path='{}', new.path='{}'",
existing.name,
new_entry.name,
existing.manifest_alias,
new_entry.manifest_alias,
existing.path,
new_entry.path
);
if existing.manifest_alias.is_some()
&& new_entry.manifest_alias.is_some()
&& existing.manifest_alias != new_entry.manifest_alias
{
tracing::debug!(
"NOT duplicates - both are direct/pattern deps with different manifest_alias: existing={:?} vs new={:?} (path={})",
existing.manifest_alias,
new_entry.manifest_alias,
existing.path
);
return false; }
let existing_is_direct = existing.manifest_alias.is_some();
let new_is_direct = new_entry.manifest_alias.is_some();
let one_direct_one_transitive = existing_is_direct != new_is_direct;
let basic_match = existing.name == new_entry.name
&& existing.source == new_entry.source
&& existing.tool == new_entry.tool;
let is_duplicate = basic_match && existing.variant_inputs == new_entry.variant_inputs;
if is_duplicate {
tracing::debug!(
"Deduplicating entries: name={}, source={:?}, tool={:?}, manifest_alias existing={:?} new={:?}, one_direct_one_transitive={}",
existing.name,
existing.source,
existing.tool,
existing.manifest_alias,
new_entry.manifest_alias,
one_direct_one_transitive
);
return true;
}
if existing.source.is_none() && new_entry.source.is_none() {
let path_tool_match = existing.path == new_entry.path && existing.tool == new_entry.tool;
let is_local_duplicate =
path_tool_match && existing.variant_inputs == new_entry.variant_inputs;
if is_local_duplicate {
tracing::debug!(
"Deduplicating local deps: path={}, tool={:?}, one_direct_one_transitive={}",
existing.path,
existing.tool,
one_direct_one_transitive
);
return true;
}
}
tracing::debug!(
"NOT duplicates: name existing={} new={}, source existing={:?} new={:?}, variant_inputs match={}",
existing.name,
new_entry.name,
existing.source,
new_entry.source,
existing.variant_inputs == new_entry.variant_inputs
);
false
}
pub fn should_replace_duplicate(existing: &LockedResource, new_entry: &LockedResource) -> bool {
let is_new_manifest = new_entry.manifest_alias.is_some();
let is_existing_manifest = existing.manifest_alias.is_some();
let new_install = new_entry.install.unwrap_or(true);
let existing_install = existing.install.unwrap_or(true);
let should_replace = if is_new_manifest != is_existing_manifest {
is_new_manifest
} else if new_install != existing_install {
new_install
} else if !is_new_manifest && !is_existing_manifest {
deterministic_version_comparison(existing, new_entry)
} else {
false
};
if new_install != existing_install {
tracing::debug!(
"Merge decision for {}: existing.install={:?}, new.install={:?}, should_replace={}",
new_entry.name,
existing.install,
new_entry.install,
should_replace
);
}
should_replace
}
fn deterministic_version_comparison(existing: &LockedResource, new_entry: &LockedResource) -> bool {
use crate::version::constraints::VersionConstraint;
let existing_version = existing.version.as_deref().unwrap_or("");
let new_version = new_entry.version.as_deref().unwrap_or("");
let existing_is_semver = matches!(
VersionConstraint::parse(existing_version),
Ok(VersionConstraint::Exact { .. }) | Ok(VersionConstraint::Requirement { .. })
);
let new_is_semver = matches!(
VersionConstraint::parse(new_version),
Ok(VersionConstraint::Exact { .. }) | Ok(VersionConstraint::Requirement { .. })
);
if existing_is_semver != new_is_semver {
let replace = new_is_semver;
tracing::debug!(
"Deterministic merge for {}: preferring semver {} over {}, replace={}",
new_entry.name,
if new_is_semver {
new_version
} else {
existing_version
},
if new_is_semver {
existing_version
} else {
new_version
},
replace
);
return replace;
}
match new_version.cmp(existing_version) {
std::cmp::Ordering::Greater => {
tracing::debug!(
"Deterministic merge for {}: new version '{}' > existing '{}', replacing",
new_entry.name,
new_version,
existing_version
);
true
}
std::cmp::Ordering::Less => {
tracing::debug!(
"Deterministic merge for {}: existing version '{}' > new '{}', keeping",
new_entry.name,
existing_version,
new_version
);
false
}
std::cmp::Ordering::Equal => {
let existing_sha = existing.resolved_commit.as_deref().unwrap_or("");
let new_sha = new_entry.resolved_commit.as_deref().unwrap_or("");
let replace = new_sha > existing_sha;
tracing::debug!(
"Deterministic merge for {}: versions equal, comparing SHAs: new {} {} existing {}, replace={}",
new_entry.name,
&new_sha.get(..8).unwrap_or(new_sha),
if replace {
">"
} else {
"<="
},
&existing_sha.get(..8).unwrap_or(existing_sha),
replace
);
replace
}
}
}
pub struct LockfileBuilder<'a> {
manifest: &'a Manifest,
}
impl<'a> LockfileBuilder<'a> {
pub fn new(manifest: &'a Manifest) -> Self {
Self {
manifest,
}
}
pub fn add_or_update_lockfile_entry(&self, lockfile: &mut LockFile, entry: LockedResource) {
let resources = lockfile.get_resources_mut(&entry.resource_type);
if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
let should_replace = should_replace_duplicate(existing, &entry);
tracing::trace!(
"Duplicate entry for {}: existing.install={:?}, new.install={:?}, should_replace={}",
entry.name,
existing.install,
entry.install,
should_replace
);
if should_replace {
*existing = entry;
}
} else {
resources.push(entry);
}
}
pub fn remove_stale_manifest_entries(&self, lockfile: &mut LockFile) {
let manifest_agents: HashSet<String> =
self.manifest.agents.keys().map(|k| k.to_string()).collect();
let manifest_snippets: HashSet<String> =
self.manifest.snippets.keys().map(|k| k.to_string()).collect();
let manifest_commands: HashSet<String> =
self.manifest.commands.keys().map(|k| k.to_string()).collect();
let manifest_scripts: HashSet<String> =
self.manifest.scripts.keys().map(|k| k.to_string()).collect();
let manifest_hooks: HashSet<String> =
self.manifest.hooks.keys().map(|k| k.to_string()).collect();
let manifest_mcp_servers: HashSet<String> =
self.manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
let manifest_skills: HashSet<String> =
self.manifest.skills.keys().map(|k| k.to_string()).collect();
let get_manifest_keys = |resource_type: ResourceType| match resource_type {
ResourceType::Agent => &manifest_agents,
ResourceType::Snippet => &manifest_snippets,
ResourceType::Command => &manifest_commands,
ResourceType::Script => &manifest_scripts,
ResourceType::Hook => &manifest_hooks,
ResourceType::McpServer => &manifest_mcp_servers,
ResourceType::Skill => &manifest_skills,
};
let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
for resource_type in ResourceType::all() {
let manifest_keys = get_manifest_keys(*resource_type);
let resources = lockfile.get_resources(resource_type);
for entry in resources {
let is_stale = if let Some(ref alias) = entry.manifest_alias {
!manifest_keys.contains(alias)
} else {
!manifest_keys.contains(&entry.name)
};
if is_stale {
let key = (entry.name.clone(), entry.source.clone());
entries_to_remove.insert(key.clone());
direct_entries.push(key);
}
}
}
for (parent_name, parent_source) in direct_entries {
for resource_type in ResourceType::all() {
if let Some(parent_entry) = lockfile
.get_resources(resource_type)
.iter()
.find(|e| e.name == parent_name && e.source == parent_source)
{
Self::collect_transitive_children(
lockfile,
parent_entry,
&mut entries_to_remove,
);
}
}
}
let should_remove = |entry: &LockedResource| {
entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
};
lockfile.agents.retain(|entry| !should_remove(entry));
lockfile.snippets.retain(|entry| !should_remove(entry));
lockfile.commands.retain(|entry| !should_remove(entry));
lockfile.scripts.retain(|entry| !should_remove(entry));
lockfile.hooks.retain(|entry| !should_remove(entry));
lockfile.mcp_servers.retain(|entry| !should_remove(entry));
}
pub fn remove_manifest_entries_for_update(
&self,
lockfile: &mut LockFile,
manifest_keys: &HashSet<String>,
) {
let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
for resource_type in ResourceType::all() {
let resources = lockfile.get_resources(resource_type);
for entry in resources {
if manifest_keys.contains(&entry.name)
|| entry
.manifest_alias
.as_ref()
.is_some_and(|alias| manifest_keys.contains(alias))
{
let key = (entry.name.clone(), entry.source.clone());
entries_to_remove.insert(key.clone());
direct_entries.push(key);
}
}
}
for (parent_name, parent_source) in direct_entries {
for resource_type in ResourceType::all() {
if let Some(parent_entry) = lockfile
.get_resources(resource_type)
.iter()
.find(|e| e.name == parent_name && e.source == parent_source)
{
Self::collect_transitive_children(
lockfile,
parent_entry,
&mut entries_to_remove,
);
}
}
}
let should_remove = |entry: &LockedResource| {
entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
};
lockfile.agents.retain(|entry| !should_remove(entry));
lockfile.snippets.retain(|entry| !should_remove(entry));
lockfile.commands.retain(|entry| !should_remove(entry));
lockfile.scripts.retain(|entry| !should_remove(entry));
lockfile.hooks.retain(|entry| !should_remove(entry));
lockfile.mcp_servers.retain(|entry| !should_remove(entry));
}
fn collect_transitive_children(
lockfile: &LockFile,
parent: &LockedResource,
entries_to_remove: &mut HashSet<(String, Option<String>)>,
) {
for dep_ref in parent.parsed_dependencies() {
let dep_path = &dep_ref.path;
let resource_type = dep_ref.resource_type;
let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
.unwrap_or_else(|| dep_path.to_string());
let dep_source = dep_ref.source.or_else(|| parent.source.clone());
if let Some(dep_entry) = lockfile
.get_resources(&resource_type)
.iter()
.find(|e| e.name == dep_name && e.source == dep_source)
{
let key = (dep_entry.name.clone(), dep_entry.source.clone());
if !entries_to_remove.contains(&key) {
entries_to_remove.insert(key);
Self::collect_transitive_children(lockfile, dep_entry, entries_to_remove);
}
}
}
}
}
pub fn add_pattern_entries(
lockfile: &mut LockFile,
entries: Vec<LockedResource>,
resource_type: ResourceType,
) {
let resources = lockfile.get_resources_mut(&resource_type);
for entry in entries {
if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
if should_replace_duplicate(existing, &entry) {
*existing = entry;
}
} else {
resources.push(entry);
}
}
}
fn rewrite_dependency_string(
dep: &str,
lookup_map: &HashMap<(ResourceType, String, Option<String>), String>,
resource_info_map: &HashMap<ResourceKey, ResourceInfo>,
parent_source: Option<String>,
) -> String {
if let Ok(existing_dep) = LockfileDependencyRef::from_str(dep) {
let dep_source = existing_dep.source.clone().or_else(|| parent_source.clone());
let dep_resource_type = existing_dep.resource_type;
let dep_path = existing_dep.path.clone();
if let Some(dep_name) = lookup_map.get(&(
dep_resource_type,
dependency_helpers::normalize_lookup_path(&dep_path),
dep_source.clone(),
)) {
if let Some((_source, Some(ver))) =
resource_info_map.get(&(dep_resource_type, dep_name.clone(), dep_source.clone()))
{
return LockfileDependencyRef::git(
dep_source.clone().unwrap_or_default(),
dep_resource_type,
dep_path,
Some(ver.clone()),
)
.to_string();
}
}
existing_dep.to_string()
} else {
dep.to_string()
}
}
pub(super) fn get_patches_for_resource(
manifest: &Manifest,
resource_type: ResourceType,
name: &str,
manifest_alias: Option<&str>,
) -> BTreeMap<String, toml::Value> {
let lookup_name = manifest_alias.unwrap_or(name);
let patches = match resource_type {
ResourceType::Agent => &manifest.patches.agents,
ResourceType::Snippet => &manifest.patches.snippets,
ResourceType::Command => &manifest.patches.commands,
ResourceType::Script => &manifest.patches.scripts,
ResourceType::Hook => &manifest.patches.hooks,
ResourceType::McpServer => &manifest.patches.mcp_servers,
ResourceType::Skill => &manifest.patches.skills,
};
patches.get(lookup_name).cloned().unwrap_or_default()
}
pub(super) fn build_merged_variant_inputs(
manifest: &Manifest,
dep: &ResourceDependency,
) -> serde_json::Value {
use crate::templating::deep_merge_json;
let dep_vars = dep.get_template_vars();
tracing::debug!(
"[DEBUG] build_merged_variant_inputs: dep_path='{}', has_dep_vars={}, dep_vars={:?}",
dep.get_path(),
dep_vars.is_some(),
dep_vars
);
let global_project = manifest
.project
.as_ref()
.map(|p| p.to_json_value())
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
tracing::debug!("[DEBUG] build_merged_variant_inputs: global_project={:?}", global_project);
let mut merged_map = serde_json::Map::new();
if let Some(vars) = dep_vars {
if let Some(obj) = vars.as_object() {
merged_map.extend(obj.clone());
}
}
let project_overrides = dep_vars
.and_then(|v| v.get("project").cloned())
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
let merged_project = deep_merge_json(global_project, &project_overrides);
if let Some(project_obj) = merged_project.as_object() {
if !project_obj.is_empty() {
merged_map.insert("project".to_string(), merged_project);
}
}
let result = serde_json::Value::Object(merged_map);
tracing::debug!(
"[DEBUG] build_merged_variant_inputs: dep_path='{}', result={:?}",
dep.get_path(),
result
);
result
}
pub(super) fn compute_merged_variant_hash(manifest: &Manifest, dep: &ResourceDependency) -> String {
let merged_variant_inputs = build_merged_variant_inputs(manifest, dep);
crate::utils::compute_variant_inputs_hash(&merged_variant_inputs)
.unwrap_or_else(|_| crate::utils::EMPTY_VARIANT_INPUTS_HASH.to_string())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct VariantInputs {
json: serde_json::Value,
#[serde(skip)]
hash: String,
}
impl PartialEq for VariantInputs {
fn eq(&self, other: &Self) -> bool {
self.hash == other.hash
}
}
impl Eq for VariantInputs {}
impl Default for VariantInputs {
fn default() -> Self {
Self::new(serde_json::Value::Object(serde_json::Map::new()))
}
}
impl VariantInputs {
pub fn new(json: serde_json::Value) -> Self {
let hash = crate::utils::compute_variant_inputs_hash(&json).unwrap_or_else(|_| {
tracing::error!("Failed to compute variant_inputs_hash, using empty hash");
"sha256:".to_string()
});
Self {
json,
hash,
}
}
pub fn json(&self) -> &serde_json::Value {
&self.json
}
pub fn hash(&self) -> &str {
&self.hash
}
pub fn recompute_hash(&mut self) {
self.hash = crate::utils::compute_variant_inputs_hash(&self.json).unwrap_or_else(|_| {
tracing::error!("Failed to recompute variant_inputs_hash");
"sha256:".to_string()
});
}
}
pub(super) fn detect_target_conflicts(lockfile: &LockFile) -> Result<()> {
let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
let all_resources: Vec<(&str, &LockedResource)> = lockfile
.agents
.iter()
.filter(|r| r.install != Some(false))
.map(|r| (r.name.as_str(), r))
.chain(
lockfile
.snippets
.iter()
.filter(|r| r.install != Some(false))
.map(|r| (r.name.as_str(), r)),
)
.chain(
lockfile
.commands
.iter()
.filter(|r| r.install != Some(false))
.map(|r| (r.name.as_str(), r)),
)
.chain(
lockfile
.scripts
.iter()
.filter(|r| r.install != Some(false))
.map(|r| (r.name.as_str(), r)),
)
.collect();
for (name, resource) in &all_resources {
let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
path_map.entry(key).or_default().push((*name).to_string());
}
let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
for (name, resource) in &all_resources {
path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
}
let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
tracing::debug!("DEBUG: Checking {} resources for conflicts", all_resources.len());
for (path, resources) in path_only_map {
if resources.len() > 1 {
tracing::debug!("DEBUG: Checking path {} with {} resources", path, resources.len());
let canonical_names: HashSet<_> = resources.iter().map(|(_, r)| &r.name).collect();
let sources: HashSet<_> = resources.iter().map(|(_, r)| &r.source).collect();
let manifest_aliases: HashSet<_> =
resources.iter().map(|(_, r)| &r.manifest_alias).collect();
tracing::debug!(
"DEBUG: canonical_names: {:?}, sources: {:?}, manifest_aliases: {:?}",
canonical_names,
sources,
manifest_aliases
);
if canonical_names.len() == 1 && sources.len() == 1 && manifest_aliases.len() == 1 {
tracing::debug!("DEBUG: Skipping - version variants");
continue;
}
let commits: HashSet<_> = resources.iter().map(|(_, r)| &r.resolved_commit).collect();
let all_local = commits.len() == 1 && commits.contains(&None);
let names: Vec<String> = resources.iter().map(|(n, _)| (*n).to_string()).collect();
tracing::debug!("DEBUG: commits: {:?}, all_local: {}", commits, all_local);
if commits.len() > 1 {
conflicts.push((path, names));
} else if all_local {
tracing::debug!("DEBUG: Adding local conflict for path: {}", path);
conflicts.push((path, names));
}
}
}
if !conflicts.is_empty() {
let mut error_msg = String::from(
"Target path conflicts detected:\n\n\
Multiple dependencies resolve to the same installation path with different content.\n\
This would cause files to overwrite each other.\n\n",
);
for (path, names) in &conflicts {
error_msg.push_str(&format!(" Path: {}\n Conflicts: {}\n\n", path, names.join(", ")));
}
error_msg.push_str(
"To resolve this conflict:\n\
1. Use custom 'target' field to specify different installation paths:\n\
Example: target = \"custom/subdir/file.md\"\n\n\
2. Use custom 'filename' field to specify different filenames:\n\
Example: filename = \"utils-v2.md\"\n\n\
3. For transitive dependencies, add them as direct dependencies with custom target/filename\n\n\
4. Ensure pattern dependencies don't overlap with single-file dependencies\n\n\
Note: This often occurs when different dependencies have transitive dependencies\n\
with the same name but from different sources.",
);
return Err(anyhow::anyhow!(error_msg));
}
Ok(())
}
pub(super) fn add_version_to_all_dependencies(lockfile: &mut LockFile) {
use crate::resolver::types as dependency_helpers;
let mut lookup_map: HashMap<(ResourceType, String, Option<String>), String> = HashMap::new();
for resource_type in ResourceType::all() {
for entry in lockfile.get_resources(resource_type) {
let normalized_path = dependency_helpers::normalize_lookup_path(&entry.path);
lookup_map.insert(
(*resource_type, normalized_path.clone(), entry.source.clone()),
entry.name.clone(),
);
if let Some(filename) = dependency_helpers::extract_filename_from_path(&entry.path) {
lookup_map
.insert((*resource_type, filename, entry.source.clone()), entry.name.clone());
}
if let Some(stripped) =
dependency_helpers::strip_resource_type_directory(&normalized_path)
{
lookup_map
.insert((*resource_type, stripped, entry.source.clone()), entry.name.clone());
}
}
}
let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
for resource_type in ResourceType::all() {
for entry in lockfile.get_resources(resource_type) {
resource_info_map.insert(
(*resource_type, entry.name.clone(), entry.source.clone()),
(entry.source.clone(), entry.version.clone()),
);
}
}
for resource_type in ResourceType::all() {
let resources = lockfile.get_resources_mut(resource_type);
for entry in resources {
let parent_source = entry.source.clone();
let updated_deps: Vec<String> = entry
.dependencies
.iter()
.map(|dep| {
rewrite_dependency_string(
dep,
&lookup_map,
&resource_info_map,
parent_source.clone(),
)
})
.collect();
entry.dependencies = updated_deps;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ResourceType;
use crate::lockfile::LockedResource;
use crate::manifest::ResourceDependency;
fn create_test_manifest() -> Manifest {
let mut manifest = Manifest::default();
manifest.agents.insert(
"test-agent".to_string(),
ResourceDependency::Simple("agents/test-agent.md".to_string()),
);
manifest.snippets.insert(
"test-snippet".to_string(),
ResourceDependency::Simple("snippets/test-snippet.md".to_string()),
);
manifest
}
fn create_test_lockfile() -> LockFile {
let mut lockfile = LockFile::default();
lockfile.agents.push(LockedResource {
name: "test-agent".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/test-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:test".to_string(),
installed_at: ".claude/agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
});
lockfile.snippets.push(LockedResource {
name: "test-snippet".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "snippets/test-snippet.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("def456".to_string()),
checksum: "sha256:test2".to_string(),
installed_at: ".claude/snippets/test-snippet.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Snippet,
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
});
lockfile
}
#[test]
fn test_add_or_update_lockfile_entry_new() {
let manifest = create_test_manifest();
let builder = LockfileBuilder::new(&manifest);
let mut lockfile = LockFile::default();
let entry = LockedResource {
resource_type: ResourceType::Agent,
name: "new-agent".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/new-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
installed_at: ".claude/agents/new-agent.md".to_string(),
resolved_commit: Some("xyz789".to_string()),
checksum: "sha256:new".to_string(),
dependencies: vec![],
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
};
builder.add_or_update_lockfile_entry(&mut lockfile, entry);
assert_eq!(lockfile.agents.len(), 1);
assert_eq!(lockfile.agents[0].name, "new-agent");
}
#[test]
fn test_add_or_update_lockfile_entry_replace() {
let manifest = create_test_manifest();
let builder = LockfileBuilder::new(&manifest);
let mut lockfile = create_test_lockfile();
let updated_entry = LockedResource {
resource_type: ResourceType::Agent,
name: "test-agent".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/test-agent.md".to_string(),
version: Some("v1.0.0".to_string()),
tool: Some("claude-code".to_string()),
manifest_alias: Some("test-agent".to_string()), context_checksum: None,
installed_at: ".claude/agents/test-agent.md".to_string(),
resolved_commit: Some("updated123".to_string()), checksum: "sha256:updated".to_string(), dependencies: vec![],
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
};
builder.add_or_update_lockfile_entry(&mut lockfile, updated_entry);
assert_eq!(lockfile.agents.len(), 1);
assert_eq!(lockfile.agents[0].resolved_commit, Some("updated123".to_string()));
assert_eq!(lockfile.agents[0].checksum, "sha256:updated");
}
#[test]
fn test_remove_stale_manifest_entries() {
let mut manifest = create_test_manifest();
manifest.agents.remove("test-agent");
let builder = LockfileBuilder::new(&manifest);
let mut lockfile = create_test_lockfile();
builder.remove_stale_manifest_entries(&mut lockfile);
assert_eq!(lockfile.agents.len(), 0);
assert_eq!(lockfile.snippets.len(), 1);
assert_eq!(lockfile.snippets[0].name, "test-snippet");
}
#[test]
fn test_remove_manifest_entries_for_update() {
let manifest = create_test_manifest();
let builder = LockfileBuilder::new(&manifest);
let mut lockfile = create_test_lockfile();
let mut manifest_keys = HashSet::new();
manifest_keys.insert("test-agent".to_string());
builder.remove_manifest_entries_for_update(&mut lockfile, &manifest_keys);
assert_eq!(lockfile.agents.len(), 0);
assert_eq!(lockfile.snippets.len(), 1);
assert_eq!(lockfile.snippets[0].name, "test-snippet");
}
#[test]
fn test_collect_transitive_children() {
let lockfile = create_test_lockfile();
let mut entries_to_remove = HashSet::new();
let parent = LockedResource {
resource_type: ResourceType::Agent,
name: "parent".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/parent.md".to_string(),
version: Some("v1.0.0".to_string()),
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
installed_at: ".claude/agents/parent.md".to_string(),
resolved_commit: Some("parent123".to_string()),
checksum: "sha256:parent".to_string(),
dependencies: vec!["agent:agents/test-agent".to_string()], applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
};
LockfileBuilder::collect_transitive_children(&lockfile, &parent, &mut entries_to_remove);
assert!(
entries_to_remove.contains(&("test-agent".to_string(), Some("community".to_string())))
);
}
#[test]
fn test_build_merged_variant_inputs_preserves_all_keys() {
use crate::manifest::DetailedDependency;
use serde_json::json;
let manifest_toml = r#"
[sources]
test-repo = "https://example.com/repo.git"
"#;
let manifest: Manifest = toml::from_str(manifest_toml).unwrap();
let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
source: Some("test-repo".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: None,
flatten: None,
install: None,
template_vars: Some(json!({
"project": { "name": "Production" },
"config": { "model": "claude-3-opus", "temperature": 0.5 }
})),
}));
let result = build_merged_variant_inputs(&manifest, &dep);
println!(
"Result: {}",
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
);
assert!(result.get("project").is_some(), "project should be present in variant_inputs");
assert!(result.get("config").is_some(), "config should be present in variant_inputs");
let config = result.get("config").unwrap();
assert_eq!(config.get("model").unwrap().as_str().unwrap(), "claude-3-opus");
assert_eq!(config.get("temperature").unwrap().as_f64().unwrap(), 0.5);
}
#[test]
fn test_direct_vs_transitive_with_different_template_vars_should_not_deduplicate() {
use serde_json::json;
let direct = LockedResource {
name: "agents/generic".to_string(),
manifest_alias: Some("generic-rust".to_string()), source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/generic.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:direct".to_string(),
installed_at: ".claude/agents/generic-rust.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::new(json!({"lang": "rust"})),
is_private: false,
approximate_token_count: None,
};
let transitive = LockedResource {
name: "agents/generic".to_string(),
manifest_alias: None, source: Some("community".to_string()),
url: Some("https://github.com/test/repo.git".to_string()),
path: "agents/generic.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:transitive".to_string(),
installed_at: ".claude/agents/generic.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: VariantInputs::new(json!({"lang": "python"})),
is_private: false,
approximate_token_count: None,
};
let is_dup = is_duplicate_entry(&direct, &transitive);
assert!(
!is_dup,
"Direct and transitive dependencies with different template_vars should NOT be duplicates. \
They represent distinct resources that both need to exist in the lockfile."
);
}
}