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
814#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
824#[serde(transparent)]
825pub struct VariantInputs {
826 json: serde_json::Value,
828 #[serde(skip)]
830 hash: String,
831}
832
833impl PartialEq for VariantInputs {
834 fn eq(&self, other: &Self) -> bool {
835 self.hash == other.hash
837 }
838}
839
840impl Eq for VariantInputs {}
841
842impl Default for VariantInputs {
843 fn default() -> Self {
844 Self::new(serde_json::Value::Object(serde_json::Map::new()))
845 }
846}
847
848impl VariantInputs {
849 pub fn new(json: serde_json::Value) -> Self {
851 let hash = crate::utils::compute_variant_inputs_hash(&json).unwrap_or_else(|_| {
853 tracing::error!("Failed to compute variant_inputs_hash, using empty hash");
855 "sha256:".to_string()
856 });
857
858 Self {
859 json,
860 hash,
861 }
862 }
863
864 pub fn json(&self) -> &serde_json::Value {
866 &self.json
867 }
868
869 pub fn hash(&self) -> &str {
871 &self.hash
872 }
873
874 pub fn recompute_hash(&mut self) {
878 self.hash = crate::utils::compute_variant_inputs_hash(&self.json).unwrap_or_else(|_| {
879 tracing::error!("Failed to recompute variant_inputs_hash");
880 "sha256:".to_string()
881 });
882 }
883}
884
885pub(super) fn detect_target_conflicts(lockfile: &LockFile) -> Result<()> {
905 let mut path_map: HashMap<(String, Option<String>), Vec<String>> = HashMap::new();
908
909 let all_resources: Vec<(&str, &LockedResource)> = lockfile
915 .agents
916 .iter()
917 .filter(|r| r.install != Some(false))
918 .map(|r| (r.name.as_str(), r))
919 .chain(
920 lockfile
921 .snippets
922 .iter()
923 .filter(|r| r.install != Some(false))
924 .map(|r| (r.name.as_str(), r)),
925 )
926 .chain(
927 lockfile
928 .commands
929 .iter()
930 .filter(|r| r.install != Some(false))
931 .map(|r| (r.name.as_str(), r)),
932 )
933 .chain(
934 lockfile
935 .scripts
936 .iter()
937 .filter(|r| r.install != Some(false))
938 .map(|r| (r.name.as_str(), r)),
939 )
940 .collect();
942
943 for (name, resource) in &all_resources {
945 let key = (resource.installed_at.clone(), resource.resolved_commit.clone());
946 path_map.entry(key).or_default().push((*name).to_string());
947 }
948
949 let mut path_only_map: HashMap<String, Vec<(&str, &LockedResource)>> = HashMap::new();
952 for (name, resource) in &all_resources {
953 path_only_map.entry(resource.installed_at.clone()).or_default().push((name, resource));
954 }
955
956 let mut conflicts: Vec<(String, Vec<String>)> = Vec::new();
961
962 tracing::debug!("DEBUG: Checking {} resources for conflicts", all_resources.len());
963 for (path, resources) in path_only_map {
964 if resources.len() > 1 {
965 tracing::debug!("DEBUG: Checking path {} with {} resources", path, resources.len());
966 let canonical_names: HashSet<_> = resources.iter().map(|(_, r)| &r.name).collect();
969 let sources: HashSet<_> = resources.iter().map(|(_, r)| &r.source).collect();
970 let manifest_aliases: HashSet<_> =
971 resources.iter().map(|(_, r)| &r.manifest_alias).collect();
972
973 tracing::debug!(
974 "DEBUG: canonical_names: {:?}, sources: {:?}, manifest_aliases: {:?}",
975 canonical_names,
976 sources,
977 manifest_aliases
978 );
979
980 if canonical_names.len() == 1 && sources.len() == 1 && manifest_aliases.len() == 1 {
983 tracing::debug!("DEBUG: Skipping - version variants");
984 continue;
985 }
986
987 let commits: HashSet<_> = resources.iter().map(|(_, r)| &r.resolved_commit).collect();
988 let all_local = commits.len() == 1 && commits.contains(&None);
989
990 let names: Vec<String> = resources.iter().map(|(n, _)| (*n).to_string()).collect();
992
993 tracing::debug!("DEBUG: commits: {:?}, all_local: {}", commits, all_local);
994
995 if commits.len() > 1 {
996 conflicts.push((path, names));
997 } else if all_local {
998 tracing::debug!("DEBUG: Adding local conflict for path: {}", path);
1002 conflicts.push((path, names));
1003 }
1004 }
1005 }
1006
1007 if !conflicts.is_empty() {
1008 let mut error_msg = String::from(
1010 "Target path conflicts detected:\n\n\
1011 Multiple dependencies resolve to the same installation path with different content.\n\
1012 This would cause files to overwrite each other.\n\n",
1013 );
1014
1015 for (path, names) in &conflicts {
1016 error_msg.push_str(&format!(" Path: {}\n Conflicts: {}\n\n", path, names.join(", ")));
1017 }
1018
1019 error_msg.push_str(
1020 "To resolve this conflict:\n\
1021 1. Use custom 'target' field to specify different installation paths:\n\
1022 Example: target = \"custom/subdir/file.md\"\n\n\
1023 2. Use custom 'filename' field to specify different filenames:\n\
1024 Example: filename = \"utils-v2.md\"\n\n\
1025 3. For transitive dependencies, add them as direct dependencies with custom target/filename\n\n\
1026 4. Ensure pattern dependencies don't overlap with single-file dependencies\n\n\
1027 Note: This often occurs when different dependencies have transitive dependencies\n\
1028 with the same name but from different sources.",
1029 );
1030
1031 return Err(anyhow::anyhow!(error_msg));
1032 }
1033
1034 Ok(())
1035}
1036
1037pub(super) fn add_version_to_all_dependencies(lockfile: &mut LockFile) {
1046 use crate::resolver::types as dependency_helpers;
1047
1048 let mut lookup_map: HashMap<(ResourceType, String, Option<String>), String> = HashMap::new();
1050
1051 for resource_type in ResourceType::all() {
1053 for entry in lockfile.get_resources(resource_type) {
1054 let normalized_path = dependency_helpers::normalize_lookup_path(&entry.path);
1055 lookup_map.insert(
1056 (*resource_type, normalized_path.clone(), entry.source.clone()),
1057 entry.name.clone(),
1058 );
1059
1060 if let Some(filename) = dependency_helpers::extract_filename_from_path(&entry.path) {
1062 lookup_map
1063 .insert((*resource_type, filename, entry.source.clone()), entry.name.clone());
1064 }
1065
1066 if let Some(stripped) =
1068 dependency_helpers::strip_resource_type_directory(&normalized_path)
1069 {
1070 lookup_map
1071 .insert((*resource_type, stripped, entry.source.clone()), entry.name.clone());
1072 }
1073 }
1074 }
1075
1076 let mut resource_info_map: HashMap<ResourceKey, ResourceInfo> = HashMap::new();
1078
1079 for resource_type in ResourceType::all() {
1080 for entry in lockfile.get_resources(resource_type) {
1081 resource_info_map.insert(
1082 (*resource_type, entry.name.clone(), entry.source.clone()),
1083 (entry.source.clone(), entry.version.clone()),
1084 );
1085 }
1086 }
1087
1088 for resource_type in ResourceType::all() {
1090 let resources = lockfile.get_resources_mut(resource_type);
1091 for entry in resources {
1092 let parent_source = entry.source.clone();
1093
1094 let updated_deps: Vec<String> = entry
1095 .dependencies
1096 .iter()
1097 .map(|dep| {
1098 rewrite_dependency_string(
1099 dep,
1100 &lookup_map,
1101 &resource_info_map,
1102 parent_source.clone(),
1103 )
1104 })
1105 .collect();
1106
1107 entry.dependencies = updated_deps;
1108 }
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::core::ResourceType;
1116 use crate::lockfile::LockedResource;
1117 use crate::manifest::ResourceDependency;
1118
1119 fn create_test_manifest() -> Manifest {
1120 let mut manifest = Manifest::default();
1121 manifest.agents.insert(
1122 "test-agent".to_string(),
1123 ResourceDependency::Simple("agents/test-agent.md".to_string()),
1124 );
1125 manifest.snippets.insert(
1126 "test-snippet".to_string(),
1127 ResourceDependency::Simple("snippets/test-snippet.md".to_string()),
1128 );
1129 manifest
1130 }
1131
1132 fn create_test_lockfile() -> LockFile {
1133 let mut lockfile = LockFile::default();
1134
1135 lockfile.agents.push(LockedResource {
1137 name: "test-agent".to_string(),
1138 source: Some("community".to_string()),
1139 url: Some("https://github.com/test/repo.git".to_string()),
1140 path: "agents/test-agent.md".to_string(),
1141 version: Some("v1.0.0".to_string()),
1142 resolved_commit: Some("abc123".to_string()),
1143 checksum: "sha256:test".to_string(),
1144 installed_at: ".claude/agents/test-agent.md".to_string(),
1145 dependencies: vec![],
1146 resource_type: ResourceType::Agent,
1147 tool: Some("claude-code".to_string()),
1148 manifest_alias: None,
1149 context_checksum: None,
1150 applied_patches: std::collections::BTreeMap::new(),
1151 install: None,
1152 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1153 is_private: false,
1154 });
1155
1156 lockfile.snippets.push(LockedResource {
1157 name: "test-snippet".to_string(),
1158 source: Some("community".to_string()),
1159 url: Some("https://github.com/test/repo.git".to_string()),
1160 path: "snippets/test-snippet.md".to_string(),
1161 version: Some("v1.0.0".to_string()),
1162 resolved_commit: Some("def456".to_string()),
1163 checksum: "sha256:test2".to_string(),
1164 installed_at: ".claude/snippets/test-snippet.md".to_string(),
1165 dependencies: vec![],
1166 resource_type: ResourceType::Snippet,
1167 tool: Some("claude-code".to_string()),
1168 manifest_alias: None,
1169 context_checksum: None,
1170 applied_patches: std::collections::BTreeMap::new(),
1171 install: None,
1172 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1173 is_private: false,
1174 });
1175
1176 lockfile
1177 }
1178
1179 #[test]
1180 fn test_add_or_update_lockfile_entry_new() {
1181 let manifest = create_test_manifest();
1182 let builder = LockfileBuilder::new(&manifest);
1183 let mut lockfile = LockFile::default();
1184
1185 let entry = LockedResource {
1186 resource_type: ResourceType::Agent,
1187 name: "new-agent".to_string(),
1188 source: Some("community".to_string()),
1189 url: Some("https://github.com/test/repo.git".to_string()),
1190 path: "agents/new-agent.md".to_string(),
1191 version: Some("v1.0.0".to_string()),
1192 tool: Some("claude-code".to_string()),
1193 manifest_alias: None,
1194 context_checksum: None,
1195 installed_at: ".claude/agents/new-agent.md".to_string(),
1196 resolved_commit: Some("xyz789".to_string()),
1197 checksum: "sha256:new".to_string(),
1198 dependencies: vec![],
1199 applied_patches: std::collections::BTreeMap::new(),
1200 install: None,
1201 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1202 is_private: false,
1203 };
1204
1205 builder.add_or_update_lockfile_entry(&mut lockfile, entry);
1206
1207 assert_eq!(lockfile.agents.len(), 1);
1208 assert_eq!(lockfile.agents[0].name, "new-agent");
1209 }
1210
1211 #[test]
1212 fn test_add_or_update_lockfile_entry_replace() {
1213 let manifest = create_test_manifest();
1214 let builder = LockfileBuilder::new(&manifest);
1215 let mut lockfile = create_test_lockfile();
1216
1217 let updated_entry = LockedResource {
1218 resource_type: ResourceType::Agent,
1219 name: "test-agent".to_string(),
1220 source: Some("community".to_string()),
1221 url: Some("https://github.com/test/repo.git".to_string()),
1222 path: "agents/test-agent.md".to_string(),
1223 version: Some("v1.0.0".to_string()),
1224 tool: Some("claude-code".to_string()),
1225 manifest_alias: Some("test-agent".to_string()), context_checksum: None,
1227 installed_at: ".claude/agents/test-agent.md".to_string(),
1228 resolved_commit: Some("updated123".to_string()), checksum: "sha256:updated".to_string(), dependencies: vec![],
1231 applied_patches: std::collections::BTreeMap::new(),
1232 install: None,
1233 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1234 is_private: false,
1235 };
1236
1237 builder.add_or_update_lockfile_entry(&mut lockfile, updated_entry);
1238
1239 assert_eq!(lockfile.agents.len(), 1);
1240 assert_eq!(lockfile.agents[0].resolved_commit, Some("updated123".to_string()));
1241 assert_eq!(lockfile.agents[0].checksum, "sha256:updated");
1242 }
1243
1244 #[test]
1245 fn test_remove_stale_manifest_entries() {
1246 let mut manifest = create_test_manifest();
1247 manifest.agents.remove("test-agent");
1249
1250 let builder = LockfileBuilder::new(&manifest);
1251 let mut lockfile = create_test_lockfile();
1252
1253 builder.remove_stale_manifest_entries(&mut lockfile);
1254
1255 assert_eq!(lockfile.agents.len(), 0);
1257 assert_eq!(lockfile.snippets.len(), 1);
1258 assert_eq!(lockfile.snippets[0].name, "test-snippet");
1259 }
1260
1261 #[test]
1262 fn test_remove_manifest_entries_for_update() {
1263 let manifest = create_test_manifest();
1264 let builder = LockfileBuilder::new(&manifest);
1265 let mut lockfile = create_test_lockfile();
1266
1267 let mut manifest_keys = HashSet::new();
1268 manifest_keys.insert("test-agent".to_string());
1269
1270 builder.remove_manifest_entries_for_update(&mut lockfile, &manifest_keys);
1271
1272 assert_eq!(lockfile.agents.len(), 0);
1274 assert_eq!(lockfile.snippets.len(), 1);
1275 assert_eq!(lockfile.snippets[0].name, "test-snippet");
1276 }
1277
1278 #[test]
1279 fn test_collect_transitive_children() {
1280 let lockfile = create_test_lockfile();
1281 let mut entries_to_remove = HashSet::new();
1282
1283 let parent = LockedResource {
1285 resource_type: ResourceType::Agent,
1286 name: "parent".to_string(),
1287 source: Some("community".to_string()),
1288 url: Some("https://github.com/test/repo.git".to_string()),
1289 path: "agents/parent.md".to_string(),
1290 version: Some("v1.0.0".to_string()),
1291 tool: Some("claude-code".to_string()),
1292 manifest_alias: None,
1293 context_checksum: None,
1294 installed_at: ".claude/agents/parent.md".to_string(),
1295 resolved_commit: Some("parent123".to_string()),
1296 checksum: "sha256:parent".to_string(),
1297 dependencies: vec!["agent:agents/test-agent".to_string()], applied_patches: std::collections::BTreeMap::new(),
1299 install: None,
1300 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1301 is_private: false,
1302 };
1303
1304 LockfileBuilder::collect_transitive_children(&lockfile, &parent, &mut entries_to_remove);
1305
1306 assert!(
1308 entries_to_remove.contains(&("test-agent".to_string(), Some("community".to_string())))
1309 );
1310 }
1311
1312 #[test]
1313 fn test_build_merged_variant_inputs_preserves_all_keys() {
1314 use crate::manifest::DetailedDependency;
1315 use serde_json::json;
1316
1317 let manifest_toml = r#"
1319[sources]
1320test-repo = "https://example.com/repo.git"
1321 "#;
1322
1323 let manifest: Manifest = toml::from_str(manifest_toml).unwrap();
1324
1325 let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
1327 source: Some("test-repo".to_string()),
1328 path: "agents/test.md".to_string(),
1329 version: Some("v1.0.0".to_string()),
1330 branch: None,
1331 rev: None,
1332 command: None,
1333 args: None,
1334 target: None,
1335 filename: None,
1336 dependencies: None,
1337 tool: None,
1338 flatten: None,
1339 install: None,
1340 template_vars: Some(json!({
1341 "project": { "name": "Production" },
1342 "config": { "model": "claude-3-opus", "temperature": 0.5 }
1343 })),
1344 }));
1345
1346 let result = build_merged_variant_inputs(&manifest, &dep);
1348
1349 println!(
1351 "Result: {}",
1352 serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
1353 );
1354
1355 assert!(result.get("project").is_some(), "project should be present in variant_inputs");
1357 assert!(result.get("config").is_some(), "config should be present in variant_inputs");
1358
1359 let config = result.get("config").unwrap();
1360 assert_eq!(config.get("model").unwrap().as_str().unwrap(), "claude-3-opus");
1361 assert_eq!(config.get("temperature").unwrap().as_f64().unwrap(), 0.5);
1362 }
1363
1364 #[test]
1365 fn test_direct_vs_transitive_with_different_template_vars_should_not_deduplicate() {
1366 use serde_json::json;
1367
1368 let direct = LockedResource {
1370 name: "agents/generic".to_string(),
1371 manifest_alias: Some("generic-rust".to_string()), source: Some("community".to_string()),
1373 url: Some("https://github.com/test/repo.git".to_string()),
1374 path: "agents/generic.md".to_string(),
1375 version: Some("v1.0.0".to_string()),
1376 resolved_commit: Some("abc123".to_string()),
1377 checksum: "sha256:direct".to_string(),
1378 installed_at: ".claude/agents/generic-rust.md".to_string(),
1379 dependencies: vec![],
1380 resource_type: ResourceType::Agent,
1381 tool: Some("claude-code".to_string()),
1382 context_checksum: None,
1383 applied_patches: std::collections::BTreeMap::new(),
1384 install: None,
1385 variant_inputs: VariantInputs::new(json!({"lang": "rust"})),
1386 is_private: false,
1387 };
1388
1389 let transitive = LockedResource {
1391 name: "agents/generic".to_string(),
1392 manifest_alias: None, source: Some("community".to_string()),
1394 url: Some("https://github.com/test/repo.git".to_string()),
1395 path: "agents/generic.md".to_string(),
1396 version: Some("v1.0.0".to_string()),
1397 resolved_commit: Some("abc123".to_string()),
1398 checksum: "sha256:transitive".to_string(),
1399 installed_at: ".claude/agents/generic.md".to_string(),
1400 dependencies: vec![],
1401 resource_type: ResourceType::Agent,
1402 tool: Some("claude-code".to_string()),
1403 context_checksum: None,
1404 applied_patches: std::collections::BTreeMap::new(),
1405 install: None,
1406 variant_inputs: VariantInputs::new(json!({"lang": "python"})),
1407 is_private: false,
1408 };
1409
1410 let is_dup = is_duplicate_entry(&direct, &transitive);
1417
1418 assert!(
1419 !is_dup,
1420 "Direct and transitive dependencies with different template_vars should NOT be duplicates. \
1421 They represent distinct resources that both need to exist in the lockfile."
1422 );
1423 }
1424}