1use 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
14type ResourceKey = (ResourceType, String, Option<String>);
16type ResourceInfo = (Option<String>, Option<String>);
17
18pub 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 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; }
68
69 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 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 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
126fn 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 is_new_manifest
161 } else if new_install != existing_install {
162 new_install
164 } else {
165 is_new_manifest
168 };
169
170 if new_install != existing_install {
171 tracing::debug!(
172 "Merge decision for {}: existing.install={:?}, new.install={:?}, should_replace={}",
173 new_entry.name,
174 existing.install,
175 new_entry.install,
176 should_replace
177 );
178 }
179
180 should_replace
181}
182
183pub struct LockfileBuilder<'a> {
185 manifest: &'a Manifest,
186}
187
188impl<'a> LockfileBuilder<'a> {
189 pub fn new(manifest: &'a Manifest) -> Self {
191 Self {
192 manifest,
193 }
194 }
195
196 pub fn add_or_update_lockfile_entry(
241 &self,
242 lockfile: &mut LockFile,
243 _name: &str,
244 entry: LockedResource,
245 ) {
246 let resources = lockfile.get_resources_mut(&entry.resource_type);
247
248 if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
249 let should_replace = should_replace_duplicate(existing, &entry);
251
252 tracing::trace!(
253 "Duplicate entry for {}: existing.install={:?}, new.install={:?}, should_replace={}",
254 entry.name,
255 existing.install,
256 entry.install,
257 should_replace
258 );
259
260 if should_replace {
261 *existing = entry;
262 }
263 } else {
265 resources.push(entry);
266 }
267 }
268
269 pub fn remove_stale_manifest_entries(&self, lockfile: &mut LockFile) {
298 let manifest_agents: HashSet<String> =
300 self.manifest.agents.keys().map(|k| k.to_string()).collect();
301 let manifest_snippets: HashSet<String> =
302 self.manifest.snippets.keys().map(|k| k.to_string()).collect();
303 let manifest_commands: HashSet<String> =
304 self.manifest.commands.keys().map(|k| k.to_string()).collect();
305 let manifest_scripts: HashSet<String> =
306 self.manifest.scripts.keys().map(|k| k.to_string()).collect();
307 let manifest_hooks: HashSet<String> =
308 self.manifest.hooks.keys().map(|k| k.to_string()).collect();
309 let manifest_mcp_servers: HashSet<String> =
310 self.manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
311
312 let get_manifest_keys = |resource_type: ResourceType| match resource_type {
314 ResourceType::Agent => &manifest_agents,
315 ResourceType::Snippet => &manifest_snippets,
316 ResourceType::Command => &manifest_commands,
317 ResourceType::Script => &manifest_scripts,
318 ResourceType::Hook => &manifest_hooks,
319 ResourceType::McpServer => &manifest_mcp_servers,
320 };
321
322 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
324 let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
325
326 for resource_type in ResourceType::all() {
328 let manifest_keys = get_manifest_keys(*resource_type);
329 let resources = lockfile.get_resources(resource_type);
330
331 for entry in resources {
332 let is_stale = if let Some(ref alias) = entry.manifest_alias {
334 !manifest_keys.contains(alias)
336 } else {
337 !manifest_keys.contains(&entry.name)
339 };
340
341 if is_stale {
342 let key = (entry.name.clone(), entry.source.clone());
343 entries_to_remove.insert(key.clone());
344 direct_entries.push(key);
345 }
346 }
347 }
348
349 for (parent_name, parent_source) in direct_entries {
351 for resource_type in ResourceType::all() {
352 if let Some(parent_entry) = lockfile
353 .get_resources(resource_type)
354 .iter()
355 .find(|e| e.name == parent_name && e.source == parent_source)
356 {
357 Self::collect_transitive_children(
358 lockfile,
359 parent_entry,
360 &mut entries_to_remove,
361 );
362 }
363 }
364 }
365
366 let should_remove = |entry: &LockedResource| {
368 entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
369 };
370
371 lockfile.agents.retain(|entry| !should_remove(entry));
372 lockfile.snippets.retain(|entry| !should_remove(entry));
373 lockfile.commands.retain(|entry| !should_remove(entry));
374 lockfile.scripts.retain(|entry| !should_remove(entry));
375 lockfile.hooks.retain(|entry| !should_remove(entry));
376 lockfile.mcp_servers.retain(|entry| !should_remove(entry));
377 }
378
379 pub fn remove_manifest_entries_for_update(
394 &self,
395 lockfile: &mut LockFile,
396 manifest_keys: &HashSet<String>,
397 ) {
398 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
401
402 let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
404
405 for resource_type in ResourceType::all() {
406 let resources = lockfile.get_resources(resource_type);
407 for entry in resources {
408 if manifest_keys.contains(&entry.name)
410 || entry
411 .manifest_alias
412 .as_ref()
413 .is_some_and(|alias| manifest_keys.contains(alias))
414 {
415 let key = (entry.name.clone(), entry.source.clone());
416 entries_to_remove.insert(key.clone());
417 direct_entries.push(key);
418 }
419 }
420 }
421
422 for (parent_name, parent_source) in direct_entries {
426 for resource_type in ResourceType::all() {
428 if let Some(parent_entry) = lockfile
429 .get_resources(resource_type)
430 .iter()
431 .find(|e| e.name == parent_name && e.source == parent_source)
432 {
433 Self::collect_transitive_children(
435 lockfile,
436 parent_entry,
437 &mut entries_to_remove,
438 );
439 }
440 }
441 }
442
443 let should_remove = |entry: &LockedResource| {
445 entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
446 };
447
448 lockfile.agents.retain(|entry| !should_remove(entry));
449 lockfile.snippets.retain(|entry| !should_remove(entry));
450 lockfile.commands.retain(|entry| !should_remove(entry));
451 lockfile.scripts.retain(|entry| !should_remove(entry));
452 lockfile.hooks.retain(|entry| !should_remove(entry));
453 lockfile.mcp_servers.retain(|entry| !should_remove(entry));
454 }
455
456 fn collect_transitive_children(
472 lockfile: &LockFile,
473 parent: &LockedResource,
474 entries_to_remove: &mut HashSet<(String, Option<String>)>,
475 ) {
476 for dep_ref in parent.parsed_dependencies() {
478 let dep_path = &dep_ref.path;
479 let resource_type = dep_ref.resource_type;
480
481 let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
483 .unwrap_or_else(|| dep_path.to_string());
484
485 let dep_source = dep_ref.source.or_else(|| parent.source.clone());
487
488 if let Some(dep_entry) = lockfile
490 .get_resources(&resource_type)
491 .iter()
492 .find(|e| e.name == dep_name && e.source == dep_source)
493 {
494 let key = (dep_entry.name.clone(), dep_entry.source.clone());
495
496 if !entries_to_remove.contains(&key) {
498 entries_to_remove.insert(key);
499 Self::collect_transitive_children(lockfile, dep_entry, entries_to_remove);
501 }
502 }
503 }
504 }
505}
506
507pub fn add_pattern_entries(
527 lockfile: &mut LockFile,
528 entries: Vec<LockedResource>,
529 resource_type: ResourceType,
530) {
531 let resources = lockfile.get_resources_mut(&resource_type);
532
533 for entry in entries {
534 if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
535 if should_replace_duplicate(existing, &entry) {
537 *existing = entry;
538 }
539 } else {
540 resources.push(entry);
541 }
542 }
543}
544
545fn rewrite_dependency_string(
562 dep: &str,
563 lookup_map: &HashMap<(ResourceType, String, Option<String>), String>,
564 resource_info_map: &HashMap<ResourceKey, ResourceInfo>,
565 parent_source: Option<String>,
566) -> String {
567 if let Ok(existing_dep) = LockfileDependencyRef::from_str(dep) {
569 let dep_source = existing_dep.source.clone().or_else(|| parent_source.clone());
571 let dep_resource_type = existing_dep.resource_type;
572 let dep_path = existing_dep.path.clone();
573
574 if let Some(dep_name) = lookup_map.get(&(
576 dep_resource_type,
577 dependency_helpers::normalize_lookup_path(&dep_path),
578 dep_source.clone(),
579 )) {
580 if let Some((_source, Some(ver))) =
581 resource_info_map.get(&(dep_resource_type, dep_name.clone(), dep_source.clone()))
582 {
583 return LockfileDependencyRef::git(
585 dep_source.clone().unwrap_or_default(),
586 dep_resource_type,
587 dep_path,
588 Some(ver.clone()),
589 )
590 .to_string();
591 }
592 }
593
594 existing_dep.to_string()
596 } else {
597 dep.to_string()
599 }
600}
601
602#[allow(dead_code)] pub(super) fn group_key(source: &str, version: &str) -> String {
609 format!("{source}::{version}")
610}
611
612pub(super) fn get_patches_for_resource(
632 manifest: &Manifest,
633 resource_type: ResourceType,
634 name: &str,
635 manifest_alias: Option<&str>,
636) -> BTreeMap<String, toml::Value> {
637 let lookup_name = manifest_alias.unwrap_or(name);
639
640 let patches = match resource_type {
641 ResourceType::Agent => &manifest.patches.agents,
642 ResourceType::Snippet => &manifest.patches.snippets,
643 ResourceType::Command => &manifest.patches.commands,
644 ResourceType::Script => &manifest.patches.scripts,
645 ResourceType::Hook => &manifest.patches.hooks,
646 ResourceType::McpServer => &manifest.patches.mcp_servers,
647 };
648
649 patches.get(lookup_name).cloned().unwrap_or_default()
650}
651
652pub(super) fn build_merged_variant_inputs(
670 manifest: &Manifest,
671 dep: &ResourceDependency,
672) -> serde_json::Value {
673 use crate::templating::deep_merge_json;
674
675 let dep_vars = dep.get_template_vars();
677
678 tracing::debug!(
679 "[DEBUG] build_merged_variant_inputs: dep_path='{}', has_dep_vars={}, dep_vars={:?}",
680 dep.get_path(),
681 dep_vars.is_some(),
682 dep_vars
683 );
684
685 let global_project = manifest
687 .project
688 .as_ref()
689 .map(|p| p.to_json_value())
690 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
691
692 tracing::debug!("[DEBUG] build_merged_variant_inputs: global_project={:?}", global_project);
693
694 let mut merged_map = serde_json::Map::new();
696
697 if let Some(vars) = dep_vars {
699 if let Some(obj) = vars.as_object() {
700 merged_map.extend(obj.clone());
701 }
702 }
703
704 let project_overrides = dep_vars
706 .and_then(|v| v.get("project").cloned())
707 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
708
709 let merged_project = deep_merge_json(global_project, &project_overrides);
711
712 if let Some(project_obj) = merged_project.as_object() {
714 if !project_obj.is_empty() {
715 merged_map.insert("project".to_string(), merged_project);
716 }
717 }
718
719 let result = serde_json::Value::Object(merged_map);
721
722 tracing::debug!(
723 "[DEBUG] build_merged_variant_inputs: dep_path='{}', result={:?}",
724 dep.get_path(),
725 result
726 );
727
728 result
729}
730
731#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
741#[serde(transparent)]
742pub struct VariantInputs {
743 json: serde_json::Value,
745 #[serde(skip)]
747 hash: String,
748}
749
750impl PartialEq for VariantInputs {
751 fn eq(&self, other: &Self) -> bool {
752 self.hash == other.hash
754 }
755}
756
757impl Eq for VariantInputs {}
758
759impl Default for VariantInputs {
760 fn default() -> Self {
761 Self::new(serde_json::Value::Object(serde_json::Map::new()))
762 }
763}
764
765impl VariantInputs {
766 pub fn new(json: serde_json::Value) -> Self {
768 let hash = crate::utils::compute_variant_inputs_hash(&json).unwrap_or_else(|_| {
770 tracing::error!("Failed to compute variant_inputs_hash, using empty hash");
772 "sha256:".to_string()
773 });
774
775 Self {
776 json,
777 hash,
778 }
779 }
780
781 pub fn json(&self) -> &serde_json::Value {
783 &self.json
784 }
785
786 pub fn hash(&self) -> &str {
788 &self.hash
789 }
790
791 pub fn recompute_hash(&mut self) {
795 self.hash = crate::utils::compute_variant_inputs_hash(&self.json).unwrap_or_else(|_| {
796 tracing::error!("Failed to recompute variant_inputs_hash");
797 "sha256:".to_string()
798 });
799 }
800}
801
802#[allow(dead_code)] pub(super) fn add_or_update_lockfile_entry(lockfile: &mut LockFile, entry: LockedResource) {
820 let resources = lockfile.get_resources_mut(&entry.resource_type);
821
822 if let Some(existing) = resources.iter_mut().find(|e| is_duplicate_entry(e, &entry)) {
823 if should_replace_duplicate(existing, &entry) {
825 *existing = entry;
826 }
827 } else {
828 resources.push(entry);
829 }
830}
831
832#[allow(dead_code)] pub(super) fn remove_stale_manifest_entries(manifest: &Manifest, lockfile: &mut LockFile) {
852 let manifest_agents: HashSet<String> = manifest.agents.keys().map(|k| k.to_string()).collect();
854 let manifest_snippets: HashSet<String> =
855 manifest.snippets.keys().map(|k| k.to_string()).collect();
856 let manifest_commands: HashSet<String> =
857 manifest.commands.keys().map(|k| k.to_string()).collect();
858 let manifest_scripts: HashSet<String> =
859 manifest.scripts.keys().map(|k| k.to_string()).collect();
860 let manifest_hooks: HashSet<String> = manifest.hooks.keys().map(|k| k.to_string()).collect();
861 let manifest_mcp_servers: HashSet<String> =
862 manifest.mcp_servers.keys().map(|k| k.to_string()).collect();
863
864 let get_manifest_keys = |resource_type: ResourceType| match resource_type {
866 ResourceType::Agent => &manifest_agents,
867 ResourceType::Snippet => &manifest_snippets,
868 ResourceType::Command => &manifest_commands,
869 ResourceType::Script => &manifest_scripts,
870 ResourceType::Hook => &manifest_hooks,
871 ResourceType::McpServer => &manifest_mcp_servers,
872 };
873
874 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
876 let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
877
878 for resource_type in ResourceType::all() {
880 let manifest_keys = get_manifest_keys(*resource_type);
881 let resources = lockfile.get_resources(resource_type);
882
883 for entry in resources {
884 let is_stale = if let Some(ref alias) = entry.manifest_alias {
886 !manifest_keys.contains(alias)
888 } else {
889 !manifest_keys.contains(&entry.name)
891 };
892
893 if is_stale {
894 let key = (entry.name.clone(), entry.source.clone());
895 entries_to_remove.insert(key.clone());
896 direct_entries.push(key);
897 }
898 }
899 }
900
901 for (parent_name, parent_source) in direct_entries {
903 for resource_type in ResourceType::all() {
904 if let Some(parent_entry) = lockfile
905 .get_resources(resource_type)
906 .iter()
907 .find(|e| e.name == parent_name && e.source == parent_source)
908 {
909 collect_transitive_children(lockfile, parent_entry, &mut entries_to_remove);
910 }
911 }
912 }
913
914 let should_remove = |entry: &LockedResource| {
916 entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
917 };
918
919 lockfile.agents.retain(|entry| !should_remove(entry));
920 lockfile.snippets.retain(|entry| !should_remove(entry));
921 lockfile.commands.retain(|entry| !should_remove(entry));
922 lockfile.scripts.retain(|entry| !should_remove(entry));
923 lockfile.hooks.retain(|entry| !should_remove(entry));
924 lockfile.mcp_servers.retain(|entry| !should_remove(entry));
925}
926
927#[allow(dead_code)] pub(super) fn remove_manifest_entries_for_update(
943 lockfile: &mut LockFile,
944 manifest_keys: &HashSet<String>,
945) {
946 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
949
950 let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
952
953 for resource_type in ResourceType::all() {
954 let resources = lockfile.get_resources(resource_type);
955 for entry in resources {
956 if manifest_keys.contains(&entry.name)
958 || entry.manifest_alias.as_ref().is_some_and(|alias| manifest_keys.contains(alias))
959 {
960 let key = (entry.name.clone(), entry.source.clone());
961 entries_to_remove.insert(key.clone());
962 direct_entries.push(key);
963 }
964 }
965 }
966
967 for (parent_name, parent_source) in direct_entries {
971 for resource_type in ResourceType::all() {
973 if let Some(parent_entry) = lockfile
974 .get_resources(resource_type)
975 .iter()
976 .find(|e| e.name == parent_name && e.source == parent_source)
977 {
978 collect_transitive_children(lockfile, parent_entry, &mut entries_to_remove);
980 }
981 }
982 }
983
984 let should_remove = |entry: &LockedResource| {
986 entries_to_remove.contains(&(entry.name.clone(), entry.source.clone()))
987 };
988
989 lockfile.agents.retain(|entry| !should_remove(entry));
990 lockfile.snippets.retain(|entry| !should_remove(entry));
991 lockfile.commands.retain(|entry| !should_remove(entry));
992 lockfile.scripts.retain(|entry| !should_remove(entry));
993 lockfile.hooks.retain(|entry| !should_remove(entry));
994 lockfile.mcp_servers.retain(|entry| !should_remove(entry));
995}
996
997#[allow(dead_code)] pub(super) fn collect_transitive_children(
1014 lockfile: &LockFile,
1015 parent: &LockedResource,
1016 entries_to_remove: &mut HashSet<(String, Option<String>)>,
1017) {
1018 for dep_ref in parent.parsed_dependencies() {
1020 let dep_path = &dep_ref.path;
1021 let resource_type = dep_ref.resource_type;
1022
1023 let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
1025 .unwrap_or_else(|| dep_path.to_string());
1026
1027 let dep_source = dep_ref.source.or_else(|| parent.source.clone());
1029
1030 if let Some(dep_entry) = lockfile
1032 .get_resources(&resource_type)
1033 .iter()
1034 .find(|e| e.name == dep_name && e.source == dep_source)
1035 {
1036 let key = (dep_entry.name.clone(), dep_entry.source.clone());
1037
1038 if !entries_to_remove.contains(&key) {
1040 entries_to_remove.insert(key);
1041 collect_transitive_children(lockfile, dep_entry, entries_to_remove);
1043 }
1044 }
1045 }
1046}
1047
1048pub(super) fn detect_target_conflicts(lockfile: &LockFile) -> Result<()> {
1068 let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
1071
1072 let all_resources: Vec<(&str, &LockedResource)> = lockfile
1078 .agents
1079 .iter()
1080 .filter(|r| r.install != Some(false))
1081 .map(|r| (r.name.as_str(), r))
1082 .chain(
1083 lockfile
1084 .snippets
1085 .iter()
1086 .filter(|r| r.install != Some(false))
1087 .map(|r| (r.name.as_str(), r)),
1088 )
1089 .chain(
1090 lockfile
1091 .commands
1092 .iter()
1093 .filter(|r| r.install != Some(false))
1094 .map(|r| (r.name.as_str(), r)),
1095 )
1096 .chain(
1097 lockfile
1098 .scripts
1099 .iter()
1100 .filter(|r| r.install != Some(false))
1101 .map(|r| (r.name.as_str(), r)),
1102 )
1103 .collect();
1105
1106 for (name, resource) in &all_resources {
1108 let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
1109 path_map.entry(key).or_default().push((*name).to_string());
1110 }
1111
1112 let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
1115 for (name, resource) in &all_resources {
1116 path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
1117 }
1118
1119 let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
1121 for (path, resources) in path_only_map {
1122 if resources.len() > 1 {
1123 let commits: HashSet<_> = resources.iter().map(|(_, r)| &r.resolved_commit).collect();
1125
1126 let all_local = commits.len() == 1 && commits.contains(&None);
1130
1131 if commits.len() > 1 || all_local {
1132 let names: Vec<String> = resources.iter().map(|(n, _)| (*n).to_string()).collect();
1133 conflicts.push((path, names));
1134 }
1135 }
1136 }
1137
1138 if !conflicts.is_empty() {
1139 let mut error_msg = String::from(
1141 "Target path conflicts detected:\n\n\
1142 Multiple dependencies resolve to the same installation path with different content.\n\
1143 This would cause files to overwrite each other.\n\n",
1144 );
1145
1146 for (path, names) in &conflicts {
1147 error_msg.push_str(&format!(" Path: {}\n Conflicts: {}\n\n", path, names.join(", ")));
1148 }
1149
1150 error_msg.push_str(
1151 "To resolve this conflict:\n\
1152 1. Use custom 'target' field to specify different installation paths:\n\
1153 Example: target = \"custom/subdir/file.md\"\n\n\
1154 2. Use custom 'filename' field to specify different filenames:\n\
1155 Example: filename = \"utils-v2.md\"\n\n\
1156 3. For transitive dependencies, add them as direct dependencies with custom target/filename\n\n\
1157 4. Ensure pattern dependencies don't overlap with single-file dependencies\n\n\
1158 Note: This often occurs when different dependencies have transitive dependencies\n\
1159 with the same name but from different sources.",
1160 );
1161
1162 return Err(anyhow::anyhow!(error_msg));
1163 }
1164
1165 Ok(())
1166}
1167
1168pub(super) fn add_version_to_all_dependencies(lockfile: &mut LockFile) {
1177 use crate::resolver::types as dependency_helpers;
1178
1179 let mut lookup_map: HashMap<(ResourceType, String, Option<String>), String> = HashMap::new();
1181
1182 for resource_type in ResourceType::all() {
1184 for entry in lockfile.get_resources(resource_type) {
1185 let normalized_path = dependency_helpers::normalize_lookup_path(&entry.path);
1186 lookup_map.insert(
1187 (*resource_type, normalized_path.clone(), entry.source.clone()),
1188 entry.name.clone(),
1189 );
1190
1191 if let Some(filename) = dependency_helpers::extract_filename_from_path(&entry.path) {
1193 lookup_map
1194 .insert((*resource_type, filename, entry.source.clone()), entry.name.clone());
1195 }
1196
1197 if let Some(stripped) =
1199 dependency_helpers::strip_resource_type_directory(&normalized_path)
1200 {
1201 lookup_map
1202 .insert((*resource_type, stripped, entry.source.clone()), entry.name.clone());
1203 }
1204 }
1205 }
1206
1207 let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
1209
1210 for resource_type in ResourceType::all() {
1211 for entry in lockfile.get_resources(resource_type) {
1212 resource_info_map.insert(
1213 (*resource_type, entry.name.clone(), entry.source.clone()),
1214 (entry.source.clone(), entry.version.clone()),
1215 );
1216 }
1217 }
1218
1219 for resource_type in ResourceType::all() {
1221 let resources = lockfile.get_resources_mut(resource_type);
1222 for entry in resources {
1223 let parent_source = entry.source.clone();
1224
1225 let updated_deps: Vec<String> = entry
1226 .dependencies
1227 .iter()
1228 .map(|dep| {
1229 rewrite_dependency_string(
1230 dep,
1231 &lookup_map,
1232 &resource_info_map,
1233 parent_source.clone(),
1234 )
1235 })
1236 .collect();
1237
1238 entry.dependencies = updated_deps;
1239 }
1240 }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use super::*;
1246 use crate::core::ResourceType;
1247 use crate::lockfile::LockedResource;
1248 use crate::manifest::ResourceDependency;
1249
1250 fn create_test_manifest() -> Manifest {
1251 let mut manifest = Manifest::default();
1252 manifest.agents.insert(
1253 "test-agent".to_string(),
1254 ResourceDependency::Simple("agents/test-agent.md".to_string()),
1255 );
1256 manifest.snippets.insert(
1257 "test-snippet".to_string(),
1258 ResourceDependency::Simple("snippets/test-snippet.md".to_string()),
1259 );
1260 manifest
1261 }
1262
1263 fn create_test_lockfile() -> LockFile {
1264 let mut lockfile = LockFile::default();
1265
1266 lockfile.agents.push(LockedResource {
1268 name: "test-agent".to_string(),
1269 source: Some("community".to_string()),
1270 url: Some("https://github.com/test/repo.git".to_string()),
1271 path: "agents/test-agent.md".to_string(),
1272 version: Some("v1.0.0".to_string()),
1273 resolved_commit: Some("abc123".to_string()),
1274 checksum: "sha256:test".to_string(),
1275 installed_at: ".claude/agents/test-agent.md".to_string(),
1276 dependencies: vec![],
1277 resource_type: ResourceType::Agent,
1278 tool: Some("claude-code".to_string()),
1279 manifest_alias: None,
1280 context_checksum: None,
1281 applied_patches: std::collections::BTreeMap::new(),
1282 install: None,
1283 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1284 });
1285
1286 lockfile.snippets.push(LockedResource {
1287 name: "test-snippet".to_string(),
1288 source: Some("community".to_string()),
1289 url: Some("https://github.com/test/repo.git".to_string()),
1290 path: "snippets/test-snippet.md".to_string(),
1291 version: Some("v1.0.0".to_string()),
1292 resolved_commit: Some("def456".to_string()),
1293 checksum: "sha256:test2".to_string(),
1294 installed_at: ".claude/snippets/test-snippet.md".to_string(),
1295 dependencies: vec![],
1296 resource_type: ResourceType::Snippet,
1297 tool: Some("claude-code".to_string()),
1298 manifest_alias: None,
1299 context_checksum: None,
1300 applied_patches: std::collections::BTreeMap::new(),
1301 install: None,
1302 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1303 });
1304
1305 lockfile
1306 }
1307
1308 #[test]
1309 fn test_add_or_update_lockfile_entry_new() {
1310 let manifest = create_test_manifest();
1311 let builder = LockfileBuilder::new(&manifest);
1312 let mut lockfile = LockFile::default();
1313
1314 let entry = LockedResource {
1315 resource_type: ResourceType::Agent,
1316 name: "new-agent".to_string(),
1317 source: Some("community".to_string()),
1318 url: Some("https://github.com/test/repo.git".to_string()),
1319 path: "agents/new-agent.md".to_string(),
1320 version: Some("v1.0.0".to_string()),
1321 tool: Some("claude-code".to_string()),
1322 manifest_alias: None,
1323 context_checksum: None,
1324 installed_at: ".claude/agents/new-agent.md".to_string(),
1325 resolved_commit: Some("xyz789".to_string()),
1326 checksum: "sha256:new".to_string(),
1327 dependencies: vec![],
1328 applied_patches: std::collections::BTreeMap::new(),
1329 install: None,
1330 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1331 };
1332
1333 builder.add_or_update_lockfile_entry(&mut lockfile, "new-agent", entry);
1334
1335 assert_eq!(lockfile.agents.len(), 1);
1336 assert_eq!(lockfile.agents[0].name, "new-agent");
1337 }
1338
1339 #[test]
1340 fn test_add_or_update_lockfile_entry_replace() {
1341 let manifest = create_test_manifest();
1342 let builder = LockfileBuilder::new(&manifest);
1343 let mut lockfile = create_test_lockfile();
1344
1345 let updated_entry = LockedResource {
1346 resource_type: ResourceType::Agent,
1347 name: "test-agent".to_string(),
1348 source: Some("community".to_string()),
1349 url: Some("https://github.com/test/repo.git".to_string()),
1350 path: "agents/test-agent.md".to_string(),
1351 version: Some("v1.0.0".to_string()),
1352 tool: Some("claude-code".to_string()),
1353 manifest_alias: Some("test-agent".to_string()), context_checksum: None,
1355 installed_at: ".claude/agents/test-agent.md".to_string(),
1356 resolved_commit: Some("updated123".to_string()), checksum: "sha256:updated".to_string(), dependencies: vec![],
1359 applied_patches: std::collections::BTreeMap::new(),
1360 install: None,
1361 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1362 };
1363
1364 builder.add_or_update_lockfile_entry(&mut lockfile, "test-agent", updated_entry);
1365
1366 assert_eq!(lockfile.agents.len(), 1);
1367 assert_eq!(lockfile.agents[0].resolved_commit, Some("updated123".to_string()));
1368 assert_eq!(lockfile.agents[0].checksum, "sha256:updated");
1369 }
1370
1371 #[test]
1372 fn test_remove_stale_manifest_entries() {
1373 let mut manifest = create_test_manifest();
1374 manifest.agents.remove("test-agent");
1376
1377 let builder = LockfileBuilder::new(&manifest);
1378 let mut lockfile = create_test_lockfile();
1379
1380 builder.remove_stale_manifest_entries(&mut lockfile);
1381
1382 assert_eq!(lockfile.agents.len(), 0);
1384 assert_eq!(lockfile.snippets.len(), 1);
1385 assert_eq!(lockfile.snippets[0].name, "test-snippet");
1386 }
1387
1388 #[test]
1389 fn test_remove_manifest_entries_for_update() {
1390 let manifest = create_test_manifest();
1391 let builder = LockfileBuilder::new(&manifest);
1392 let mut lockfile = create_test_lockfile();
1393
1394 let mut manifest_keys = HashSet::new();
1395 manifest_keys.insert("test-agent".to_string());
1396
1397 builder.remove_manifest_entries_for_update(&mut lockfile, &manifest_keys);
1398
1399 assert_eq!(lockfile.agents.len(), 0);
1401 assert_eq!(lockfile.snippets.len(), 1);
1402 assert_eq!(lockfile.snippets[0].name, "test-snippet");
1403 }
1404
1405 #[test]
1406 fn test_collect_transitive_children() {
1407 let lockfile = create_test_lockfile();
1408 let mut entries_to_remove = HashSet::new();
1409
1410 let parent = LockedResource {
1412 resource_type: ResourceType::Agent,
1413 name: "parent".to_string(),
1414 source: Some("community".to_string()),
1415 url: Some("https://github.com/test/repo.git".to_string()),
1416 path: "agents/parent.md".to_string(),
1417 version: Some("v1.0.0".to_string()),
1418 tool: Some("claude-code".to_string()),
1419 manifest_alias: None,
1420 context_checksum: None,
1421 installed_at: ".claude/agents/parent.md".to_string(),
1422 resolved_commit: Some("parent123".to_string()),
1423 checksum: "sha256:parent".to_string(),
1424 dependencies: vec!["agent:agents/test-agent".to_string()], applied_patches: std::collections::BTreeMap::new(),
1426 install: None,
1427 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1428 };
1429
1430 LockfileBuilder::collect_transitive_children(&lockfile, &parent, &mut entries_to_remove);
1431
1432 assert!(
1434 entries_to_remove.contains(&("test-agent".to_string(), Some("community".to_string())))
1435 );
1436 }
1437
1438 #[test]
1439 fn test_build_merged_variant_inputs_preserves_all_keys() {
1440 use crate::manifest::DetailedDependency;
1441 use serde_json::json;
1442
1443 let manifest_toml = r#"
1445[sources]
1446test-repo = "https://example.com/repo.git"
1447 "#;
1448
1449 let manifest: Manifest = toml::from_str(manifest_toml).unwrap();
1450
1451 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
1453 source: Some("test-repo".to_string()),
1454 path: "agents/test.md".to_string(),
1455 version: Some("v1.0.0".to_string()),
1456 branch: None,
1457 rev: None,
1458 command: None,
1459 args: None,
1460 target: None,
1461 filename: None,
1462 dependencies: None,
1463 tool: None,
1464 flatten: None,
1465 install: None,
1466 template_vars: Some(json!({
1467 "project": { "name": "Production" },
1468 "config": { "model": "claude-3-opus", "temperature": 0.5 }
1469 })),
1470 }));
1471
1472 let result = build_merged_variant_inputs(&manifest, &dep);
1474
1475 println!(
1477 "Result: {}",
1478 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
1479 );
1480
1481 assert!(result.get("project").is_some(), "project should be present in variant_inputs");
1483 assert!(result.get("config").is_some(), "config should be present in variant_inputs");
1484
1485 let config = result.get("config").unwrap();
1486 assert_eq!(config.get("model").unwrap().as_str().unwrap(), "claude-3-opus");
1487 assert_eq!(config.get("temperature").unwrap().as_f64().unwrap(), 0.5);
1488 }
1489
1490 #[test]
1491 fn test_direct_vs_transitive_with_different_template_vars_should_not_deduplicate() {
1492 use serde_json::json;
1493
1494 let direct = LockedResource {
1496 name: "agents/generic".to_string(),
1497 manifest_alias: Some("generic-rust".to_string()), source: Some("community".to_string()),
1499 url: Some("https://github.com/test/repo.git".to_string()),
1500 path: "agents/generic.md".to_string(),
1501 version: Some("v1.0.0".to_string()),
1502 resolved_commit: Some("abc123".to_string()),
1503 checksum: "sha256:direct".to_string(),
1504 installed_at: ".claude/agents/generic-rust.md".to_string(),
1505 dependencies: vec![],
1506 resource_type: ResourceType::Agent,
1507 tool: Some("claude-code".to_string()),
1508 context_checksum: None,
1509 applied_patches: std::collections::BTreeMap::new(),
1510 install: None,
1511 variant_inputs: VariantInputs::new(json!({"lang": "rust"})),
1512 };
1513
1514 let transitive = LockedResource {
1516 name: "agents/generic".to_string(),
1517 manifest_alias: None, source: Some("community".to_string()),
1519 url: Some("https://github.com/test/repo.git".to_string()),
1520 path: "agents/generic.md".to_string(),
1521 version: Some("v1.0.0".to_string()),
1522 resolved_commit: Some("abc123".to_string()),
1523 checksum: "sha256:transitive".to_string(),
1524 installed_at: ".claude/agents/generic.md".to_string(),
1525 dependencies: vec![],
1526 resource_type: ResourceType::Agent,
1527 tool: Some("claude-code".to_string()),
1528 context_checksum: None,
1529 applied_patches: std::collections::BTreeMap::new(),
1530 install: None,
1531 variant_inputs: VariantInputs::new(json!({"lang": "python"})),
1532 };
1533
1534 let is_dup = is_duplicate_entry(&direct, &transitive);
1541
1542 assert!(
1543 !is_dup,
1544 "Direct and transitive dependencies with different template_vars should NOT be duplicates. \
1545 They represent distinct resources that both need to exist in the lockfile."
1546 );
1547 }
1548}