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
126pub 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 is_new_manifest
161 } else if new_install != existing_install {
162 new_install
164 } else if !is_new_manifest && !is_existing_manifest {
165 deterministic_version_comparison(existing, new_entry)
168 } else {
169 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
186fn 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 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 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 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 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
273pub struct LockfileBuilder<'a> {
275 manifest: &'a Manifest,
276}
277
278impl<'a> LockfileBuilder<'a> {
279 pub fn new(manifest: &'a Manifest) -> Self {
281 Self {
282 manifest,
283 }
284 }
285
286 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 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 } else {
350 resources.push(entry);
351 }
352 }
353
354 pub fn remove_stale_manifest_entries(&self, lockfile: &mut LockFile) {
383 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 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 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
412 let mut direct_entries: Vec<(String, Option<String>)> = Vec::new();
413
414 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 let is_stale = if let Some(ref alias) = entry.manifest_alias {
422 !manifest_keys.contains(alias)
424 } else {
425 !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 (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 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 pub fn remove_manifest_entries_for_update(
482 &self,
483 lockfile: &mut LockFile,
484 manifest_keys: &HashSet<String>,
485 ) {
486 let mut entries_to_remove: HashSet<(String, Option<String>)> = HashSet::new();
489
490 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 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 for (parent_name, parent_source) in direct_entries {
514 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 Self::collect_transitive_children(
523 lockfile,
524 parent_entry,
525 &mut entries_to_remove,
526 );
527 }
528 }
529 }
530
531 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 fn collect_transitive_children(
560 lockfile: &LockFile,
561 parent: &LockedResource,
562 entries_to_remove: &mut HashSet<(String, Option<String>)>,
563 ) {
564 for dep_ref in parent.parsed_dependencies() {
566 let dep_path = &dep_ref.path;
567 let resource_type = dep_ref.resource_type;
568
569 let dep_name = dependency_helpers::extract_filename_from_path(dep_path)
571 .unwrap_or_else(|| dep_path.to_string());
572
573 let dep_source = dep_ref.source.or_else(|| parent.source.clone());
575
576 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 if !entries_to_remove.contains(&key) {
586 entries_to_remove.insert(key);
587 Self::collect_transitive_children(lockfile, dep_entry, entries_to_remove);
589 }
590 }
591 }
592 }
593}
594
595pub 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 if should_replace_duplicate(existing, &entry) {
625 *existing = entry;
626 }
627 } else {
628 resources.push(entry);
629 }
630 }
631}
632
633fn 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 if let Ok(existing_dep) = LockfileDependencyRef::from_str(dep) {
657 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 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 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 existing_dep.to_string()
684 } else {
685 dep.to_string()
687 }
688}
689
690pub(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 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
735pub(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 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 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 let mut merged_map = serde_json::Map::new();
779
780 if let Some(vars) = dep_vars {
782 if let Some(obj) = vars.as_object() {
783 merged_map.extend(obj.clone());
784 }
785 }
786
787 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 let merged_project = deep_merge_json(global_project, &project_overrides);
794
795 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 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
814pub(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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
846#[serde(transparent)]
847pub struct VariantInputs {
848 json: serde_json::Value,
850 #[serde(skip)]
852 hash: String,
853}
854
855impl PartialEq for VariantInputs {
856 fn eq(&self, other: &Self) -> bool {
857 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 pub fn new(json: serde_json::Value) -> Self {
873 let hash = crate::utils::compute_variant_inputs_hash(&json).unwrap_or_else(|_| {
875 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 pub fn json(&self) -> &serde_json::Value {
888 &self.json
889 }
890
891 pub fn hash(&self) -> &str {
893 &self.hash
894 }
895
896 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
907pub(super) fn detect_target_conflicts(lockfile: &LockFile) -> Result<()> {
927 let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
930
931 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 .collect();
964
965 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 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 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 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 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 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 tracing::debug!("DEBUG: Adding local conflict for path: {}", path);
1024 conflicts.push((path, names));
1025 }
1026 }
1027 }
1028
1029 if !conflicts.is_empty() {
1030 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
1059pub(super) fn add_version_to_all_dependencies(lockfile: &mut LockFile) {
1068 use crate::resolver::types as dependency_helpers;
1069
1070 let mut lookup_map: HashMap<(ResourceType, String, Option<String>), String> = HashMap::new();
1072
1073 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 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 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 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 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 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()), context_checksum: None,
1252 installed_at: ".claude/agents/test-agent.md".to_string(),
1253 resolved_commit: Some("updated123".to_string()), checksum: "sha256:updated".to_string(), 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 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 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 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 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()], 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 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 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 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 let result = build_merged_variant_inputs(&manifest, &dep);
1375
1376 println!(
1378 "Result: {}",
1379 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
1380 );
1381
1382 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 let direct = LockedResource {
1397 name: "agents/generic".to_string(),
1398 manifest_alias: Some("generic-rust".to_string()), 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 let transitive = LockedResource {
1419 name: "agents/generic".to_string(),
1420 manifest_alias: None, 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 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}