1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use serde_json::Value;
7
8use callisto_manifests::{detect_npm_workspace_kind, Manifest, OpenContext, WorkspaceCargoResolver};
9use callisto_model::{
10 CommandRunner, DepEdge, Diagnostic, DiagnosticCode, DiagnosticSeverity, Ecosystem, ManifestDecl, ManifestFormat,
11 ManifestRole, Package, PackageId, PublishTarget, ReleaseTrigger,
12};
13
14use crate::config::resolve::resolve_package_config;
15use crate::config::ResolvedConfig;
16use crate::crosscheck::crosscheck_declared_edges;
17use crate::error::GraphError;
18use crate::identity::IdentityIndex;
19use crate::locate::ProjectLocator;
20use crate::manifest_cache::open_cached;
21#[allow(unused_imports)]
22use crate::resolver::{DependencyResolver, ManifestWalkResolver};
23
24fn compute_claiming_ecosystems_and_native_keys(
28 list: &[(Ecosystem, PackageId)],
29 primary_id: &PackageId,
30) -> (BTreeSet<Ecosystem>, Vec<(Ecosystem, String)>) {
31 let claiming = list
32 .iter()
33 .filter(|(_, id)| id.name() == primary_id.name())
34 .map(|(eco, _)| *eco)
35 .collect();
36 let native_keys = list.iter().map(|(eco, id)| (*eco, id.name().to_string())).collect();
37 (claiming, native_keys)
38}
39
40fn claiming_sets_disjoint(a: &BTreeSet<Ecosystem>, b: &BTreeSet<Ecosystem>) -> bool {
45 a.is_disjoint(b)
46}
47
48#[cfg(test)]
54fn is_promoted_bare_name(paths: &[PathBuf], claiming_ecosystems: &BTreeMap<PathBuf, BTreeSet<Ecosystem>>) -> bool {
55 if paths.len() <= 1 {
56 return false;
57 }
58 let mut seen = BTreeSet::new();
59 for path in paths {
60 if let Some(ecos) = claiming_ecosystems.get(path) {
61 for eco in ecos {
62 if !seen.insert(*eco) {
63 return false;
64 }
65 }
66 }
67 }
68 true
69}
70
71impl ManifestWalkResolver {
72 pub fn build<L: ProjectLocator, R: CommandRunner>(
73 root: &Path,
74 locator: &L,
75 _runner: &R,
76 cfg: &ResolvedConfig,
77 manifest_cache: &RefCell<BTreeMap<PathBuf, Arc<dyn Manifest>>>,
78 ) -> Result<Self, GraphError> {
79 let projects = locator.projects()?;
80
81 let cargo_workspace = if root.join("Cargo.toml").exists() {
82 if let Ok(resolver) = WorkspaceCargoResolver::load(&root.join("Cargo.toml")) {
83 resolver.inheritance().ok().map(Arc::new)
84 } else {
85 None
86 }
87 } else {
88 None
89 };
90
91 let npm_workspace_kind = detect_npm_workspace_kind(root).ok().flatten();
92
93 let ctx = OpenContext {
94 workspace_root: root,
95 cargo_workspace,
96 npm_workspace_kind,
97 };
98
99 let mut package_manifest_decls: BTreeMap<PackageId, (PathBuf, Vec<ManifestDecl>)> = BTreeMap::new();
100 let mut index = IdentityIndex::default();
101 let mut diagnostics = Vec::new();
102 let mut claiming_ecosystems: BTreeMap<PathBuf, BTreeSet<Ecosystem>> = BTreeMap::new();
103 let mut path_native_keys: BTreeMap<PathBuf, Vec<(Ecosystem, String)>> = BTreeMap::new();
104 let mut path_platform_keys: BTreeMap<PathBuf, Vec<String>> = BTreeMap::new();
105 let mut primary_ecosystems: BTreeMap<PathBuf, Ecosystem> = BTreeMap::new();
106 let mut promoted_siblings: BTreeMap<String, Vec<(PackageId, BTreeSet<Ecosystem>)>> = BTreeMap::new();
107
108 let mut by_path: BTreeMap<PathBuf, Vec<(Ecosystem, PackageId)>> = BTreeMap::new();
115 for proj in &projects {
116 by_path
117 .entry(proj.path.clone())
118 .or_default()
119 .push((proj.ecosystem, proj.id.clone()));
120 }
121
122 for (rel_path, mut list) in by_path {
123 list.sort_by_key(|a| ecosystem_primary_priority(a.0));
128 let mut primary_id = list[0].1.clone();
129 let (this_claiming, this_native_keys) = compute_claiming_ecosystems_and_native_keys(&list, &primary_id);
130 claiming_ecosystems.insert(rel_path.clone(), this_claiming.clone());
131 path_native_keys.insert(rel_path.clone(), this_native_keys);
132 primary_ecosystems.insert(rel_path.clone(), list[0].0);
133
134 let mut branch_ii_promoted = false;
135 if let Some(existing_members) = promoted_siblings.get(primary_id.name()) {
136 let this_set = claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default();
137 let conflict = existing_members
138 .iter()
139 .find(|(_, member_set)| !claiming_sets_disjoint(&this_set, member_set));
140 if let Some((conflicting_id, _)) = conflict {
141 let offending_path = package_manifest_decls
142 .iter()
143 .find(|(id, _)| *id == conflicting_id)
144 .map(|(_, (p, _))| p.clone())
145 .unwrap_or_default();
146 return Err(GraphError::DuplicatePackage {
147 id: primary_id,
148 paths: vec![offending_path, rel_path],
149 });
150 }
151 let promoted_id = PackageId::Prefixed {
152 ecosystem: primary_ecosystems[&rel_path],
153 name: primary_id.name().to_string(),
154 };
155 promoted_siblings
156 .entry(primary_id.name().to_string())
157 .or_default()
158 .push((promoted_id.clone(), this_set));
159 primary_id = promoted_id;
160 branch_ii_promoted = true;
161 }
162
163 if !branch_ii_promoted {
164 index.bare.insert(primary_id.name().to_string(), primary_id.clone());
165 }
166
167 let mut decls = Vec::new();
168 for (eco, id) in &list {
169 let (fmt, filename) = match eco {
170 Ecosystem::Cargo => (ManifestFormat::CargoToml, "Cargo.toml"),
171 Ecosystem::Npm => (ManifestFormat::PackageJson, "package.json"),
172 Ecosystem::Pypi => (ManifestFormat::PyprojectToml, "pyproject.toml"),
173 _ => (ManifestFormat::PackageJson, "package.json"),
174 };
175 let manifest_rel = rel_path.join(filename);
176 if let Ok(decl) = ManifestDecl::new(manifest_rel.clone(), ManifestRole::Canonical, fmt) {
177 decls.push(decl);
178 }
179 if *eco == Ecosystem::Npm {
183 let role = detect_npm_role(&root.join(&manifest_rel));
184 if let ManifestRole::Platform { .. } = role {
185 if let Ok(platform_decl) = ManifestDecl::new(manifest_rel.clone(), role.clone(), fmt) {
186 decls.push(platform_decl);
187 }
188 index
195 .platform
196 .insert(id.name().to_string(), (primary_id.clone(), manifest_rel, role));
197 path_platform_keys
198 .entry(rel_path.clone())
199 .or_default()
200 .push(id.name().to_string());
201 }
202 }
203 index.native.insert((*eco, id.name().to_string()), primary_id.clone());
204 if id.name() == primary_id.name() {
205 index.prefixed.insert((*eco, id.name().to_string()), primary_id.clone());
206 }
207 }
208
209 let current_decls = decls.clone();
210 if let Some((existing_path, existing_decls)) =
211 package_manifest_decls.insert(primary_id.clone(), (rel_path.clone(), decls))
212 {
213 let name = primary_id.name().to_string();
214 let existing_set = claiming_ecosystems.get(&existing_path).cloned().unwrap_or_default();
215 let current_set = claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default();
216 if !claiming_sets_disjoint(&existing_set, ¤t_set) {
217 return Err(GraphError::DuplicatePackage {
218 id: primary_id,
219 paths: vec![existing_path, rel_path],
220 });
221 }
222 let existing_id = PackageId::Prefixed {
230 ecosystem: primary_ecosystems[&existing_path],
231 name: name.clone(),
232 };
233 let current_id = PackageId::Prefixed {
234 ecosystem: primary_ecosystems[&rel_path],
235 name: name.clone(),
236 };
237 package_manifest_decls.insert(existing_id.clone(), (existing_path.clone(), existing_decls));
238 package_manifest_decls.insert(current_id.clone(), (rel_path.clone(), current_decls));
239 package_manifest_decls.remove(&primary_id);
240 index.bare.remove(&name);
243 if let Some(keys) = path_native_keys.get(&existing_path) {
247 for key in keys {
248 index.native.insert(key.clone(), existing_id.clone());
249 }
250 }
251 if let Some(keys) = path_native_keys.get(&rel_path) {
252 for key in keys {
253 index.native.insert(key.clone(), current_id.clone());
254 }
255 }
256 for eco in claiming_ecosystems.get(&existing_path).cloned().unwrap_or_default() {
260 index.prefixed.insert((eco, name.clone()), existing_id.clone());
261 }
262 for eco in claiming_ecosystems.get(&rel_path).cloned().unwrap_or_default() {
263 index.prefixed.insert((eco, name.clone()), current_id.clone());
264 }
265 if let Some(keys) = path_platform_keys.get(&existing_path) {
271 for key in keys {
272 if let Some((_, manifest_rel, role)) = index.platform.get(key).cloned() {
273 index
274 .platform
275 .insert(key.clone(), (existing_id.clone(), manifest_rel, role));
276 }
277 }
278 }
279 if let Some(keys) = path_platform_keys.get(&rel_path) {
280 for key in keys {
281 if let Some((_, manifest_rel, role)) = index.platform.get(key).cloned() {
282 index
283 .platform
284 .insert(key.clone(), (current_id.clone(), manifest_rel, role));
285 }
286 }
287 }
288 promoted_siblings
289 .entry(name.clone())
290 .or_default()
291 .extend([(existing_id, existing_set), (current_id, current_set)]);
292 }
293 }
294
295 let cfg = cfg.with_promoted_siblings(promoted_siblings);
296
297 let mut package_set_matched = vec![false; cfg.package_sets.len()];
303
304 let mut packages = BTreeMap::new();
305 for (id, (rel_path, decls)) in package_manifest_decls {
306 let ch_path = rel_path.join("CHANGELOG.md");
307 let mut publish_to = Vec::new();
308 for decl in &decls {
309 if let Ok(editor) = open_cached(manifest_cache, decl, &ctx) {
310 for target in editor.publish_targets() {
311 if target != PublishTarget::None && !publish_to.contains(&target) {
312 publish_to.push(target);
313 }
314 }
315 }
316 }
317 if publish_to.is_empty() {
318 publish_to.push(PublishTarget::None);
319 }
320
321 let package_ecosystems: Vec<Ecosystem> = decls.iter().map(|d| d.ecosystem()).collect();
327
328 let pkg_override = resolve_package_config(&id, &cfg)?;
337
338 for (idx, (pattern, _)) in cfg.package_sets.iter().enumerate() {
343 if pattern.matches_in_ecosystems(id.name(), &package_ecosystems) {
344 package_set_matched[idx] = true;
345 }
346 }
347
348 let set_override = if pkg_override.is_none() {
352 cfg.package_sets
353 .iter()
354 .find(|(pattern, _)| pattern.matches_in_ecosystems(id.name(), &package_ecosystems))
355 .map(|(_, cfg)| cfg)
356 } else {
357 None
358 };
359
360 let active_override = pkg_override.or(set_override);
361
362 let release_trigger = active_override
363 .and_then(|o| o.release_trigger)
364 .unwrap_or(ReleaseTrigger::Changeset);
365
366 let tag_template = active_override.and_then(|o| o.tag_template.clone());
367
368 let changelog = if let Some(override_path) = active_override.and_then(|o| o.changelog.as_ref()) {
369 Some(rel_path.join(override_path))
370 } else {
371 Some(ch_path)
372 };
373
374 if let Some(override_targets) = active_override.and_then(|o| o.publish_to.as_deref()) {
386 for target in override_targets {
387 if let Some(target_ecosystem) = target.ecosystem() {
388 if !package_ecosystems.contains(&target_ecosystem) {
389 return Err(GraphError::PublishTargetEcosystemMismatch {
390 package: id.clone(),
391 target: target.config_str().to_string(),
392 target_ecosystem,
393 package_ecosystems,
394 });
395 }
396 }
397 }
398 publish_to = override_targets.to_vec();
399 }
400
401 let pkg = Package {
402 id: id.clone(),
403 manifests: decls,
404 changelog,
405 release_trigger,
406 publish_to,
407 tag_template,
408 };
409 packages.insert(id, pkg);
410 }
411
412 for (idx, (pattern, _)) in cfg.package_sets.iter().enumerate() {
423 if !package_set_matched[idx] {
424 diagnostics.push(Diagnostic {
425 code: DiagnosticCode::PackageSetMatchedNothing,
426 severity: DiagnosticSeverity::Warning,
427 message: format!("[[package-set]] `{}` matched no packages", pattern.as_str()),
428 package: None,
429 path: None,
430 escalated_by: None,
431 governed_by: None,
432 });
433 }
434 }
435
436 for (pattern, _) in &cfg.packages {
457 if pattern.ecosystem().is_some() {
458 continue; }
460 let ecosystems: BTreeSet<Ecosystem> = packages
461 .iter()
462 .filter(|(key, _)| pattern.matches(key))
463 .flat_map(|(_, pkg)| pkg.canonical_manifests().map(|d| d.ecosystem()))
466 .collect();
467 if ecosystems.len() >= 2 {
468 let eco_list: Vec<&str> = ecosystems.iter().map(|e| e.prefix()).collect();
469 diagnostics.push(Diagnostic {
470 code: DiagnosticCode::BareRuleMatchesMultipleEcosystems,
471 severity: DiagnosticSeverity::Warning,
472 message: format!(
473 "[[package]] rule `{}` matches packages in multiple ecosystems ({}); \
474 use an ecosystem-prefixed pattern like `{}/{}` to target only one",
475 pattern.name(),
476 eco_list.join(", "),
477 ecosystems.iter().next().map(|e| e.prefix()).unwrap_or("cargo"),
478 pattern.name(),
479 ),
480 package: None,
481 path: None,
482 escalated_by: None,
483 governed_by: None,
484 });
485 }
486 }
487
488 let mut edges = Vec::new();
489 let mut out_index: BTreeMap<PackageId, Vec<usize>> = BTreeMap::new();
490 let mut in_index: BTreeMap<PackageId, Vec<usize>> = BTreeMap::new();
491
492 for pkg in packages.values() {
493 for decl in &pkg.manifests {
494 if decl.role != ManifestRole::Canonical {
495 continue;
496 }
497 if let Ok(m) = open_cached(manifest_cache, decl, &ctx) {
498 for entry in m.iter_dependencies() {
499 let (spec, declaring_path) = if entry.inherited {
500 if let Some(ref inh) = ctx.cargo_workspace {
501 if let Some(inherited_dep) = inh.inherited(&entry.name) {
502 (inherited_dep.spec.clone(), inherited_dep.declared_in.to_path_buf())
503 } else {
504 (entry.spec.clone(), decl.path.clone())
505 }
506 } else {
507 (entry.spec.clone(), decl.path.clone())
508 }
509 } else {
510 (entry.spec.clone(), decl.path.clone())
511 };
512
513 if let Some(to) =
514 index.resolve_native_with_fallback(decl.ecosystem(), &entry.name, &mut diagnostics)
515 {
516 let idx = edges.len();
517 let edge = DepEdge {
518 from: pkg.id.clone(),
519 to: to.clone(),
520 kind: entry.kind,
521 spec,
522 from_manifest: declaring_path,
523 inherited: entry.inherited,
524 };
525 edges.push(edge);
526
527 out_index.entry(pkg.id.clone()).or_default().push(idx);
528 in_index.entry(to.clone()).or_default().push(idx);
529 }
530 }
531 }
532 }
533 }
534
535 if let Some(declared) = locator.declared_edges() {
536 let cross_diags = crosscheck_declared_edges(&packages, &edges, &declared);
537 diagnostics.extend(cross_diags);
538 }
539
540 Ok(ManifestWalkResolver {
541 packages,
542 edges,
543 out_index,
544 in_index,
545 index,
546 diagnostics,
547 })
548 }
549}
550
551fn detect_npm_role(abs_path: &Path) -> ManifestRole {
562 let Ok(bytes) = std::fs::read(abs_path) else {
563 return ManifestRole::Canonical;
564 };
565 let Ok(Value::Object(map)) = serde_json::from_slice(&bytes) else {
566 return ManifestRole::Canonical;
567 };
568
569 let has_os = map.get("os").and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty());
570 let has_cpu = map.get("cpu").and_then(|v| v.as_array()).is_some_and(|a| !a.is_empty());
571
572 if !has_os || !has_cpu {
573 return ManifestRole::Canonical;
574 }
575
576 let Some(platform) = map
577 .get("os")
578 .and_then(|v| v.as_array())
579 .and_then(|a| a.first())
580 .and_then(|v| v.as_str())
581 .map(str::to_string)
582 else {
583 return ManifestRole::Canonical;
584 };
585
586 let Some(arch) = map
587 .get("cpu")
588 .and_then(|v| v.as_array())
589 .and_then(|a| a.first())
590 .and_then(|v| v.as_str())
591 .map(str::to_string)
592 else {
593 return ManifestRole::Canonical;
594 };
595
596 let abi = if platform == "linux" {
605 map.get("name")
606 .and_then(|v| v.as_str())
607 .and_then(napi_linux_abi_from_package_name)
608 } else {
609 None
610 };
611
612 ManifestRole::Platform { platform, arch, abi }
613}
614
615fn napi_linux_abi_from_package_name(name: &str) -> Option<String> {
620 for abi in ["gnueabihf", "gnu", "musl"] {
621 if name.ends_with(&format!("-{abi}")) {
622 return Some(abi.to_string());
623 }
624 }
625 None
626}
627
628fn ecosystem_primary_priority(e: Ecosystem) -> u8 {
637 match e {
638 Ecosystem::Cargo => 0,
639 Ecosystem::Npm => 1,
640 Ecosystem::Pypi => 2,
641 _ => u8::MAX,
642 }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 fn write_pkg(root: &std::path::Path, rel: &str, eco: Ecosystem, name: &str) {
650 std::fs::create_dir_all(root.join(rel)).unwrap();
651 match eco {
652 Ecosystem::Cargo => std::fs::write(
653 root.join(rel).join("Cargo.toml"),
654 format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
655 )
656 .unwrap(),
657 Ecosystem::Npm => std::fs::write(
658 root.join(rel).join("package.json"),
659 format!(r#"{{"name":"{name}","version":"0.1.0"}}"#),
660 )
661 .unwrap(),
662 Ecosystem::Pypi => std::fs::write(
663 root.join(rel).join("pyproject.toml"),
664 format!("[project]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
665 )
666 .unwrap(),
667 _ => unreachable!(),
668 }
669 }
670
671 #[test]
672 fn ac16a_fixed_group_member_resolves_via_prefixed_unpromoted_cargo_package() {
673 let dir = tempfile::tempdir().expect("tempdir");
674 let root = dir.path();
675 write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
676 std::fs::write(
677 root.join("callisto.toml"),
678 "[[fixed-group]]\nname = \"g\"\nmembers = [\"cargo:foo\"]\n",
679 )
680 .unwrap();
681 let locator = crate::locate::IgnoreWalkLocator::new(root);
682 let runner = NoopRunner;
683 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
684 .expect("Workspace::load must succeed for an unpromoted Cargo package referenced via cargo:foo");
685 let native = ws
686 .graph
687 .identity()
688 .native
689 .get(&(Ecosystem::Cargo, "foo".to_string()))
690 .expect("native entry must exist");
691 let prefixed = ws
692 .graph
693 .identity()
694 .prefixed
695 .get(&(Ecosystem::Cargo, "foo".to_string()))
696 .expect("prefixed entry must exist for AC-02");
697 assert_eq!(
698 prefixed, native,
699 "prefixed and native must resolve to the identical PackageId for an unpromoted single-ecosystem package"
700 );
701 let group = ws
702 .config
703 .groups
704 .fixed
705 .get(&callisto_model::GroupName("g".to_string()))
706 .expect("group must exist");
707 assert_eq!(group.members.len(), 1);
708 }
709
710 #[test]
711 fn ac03_fixed_group_member_naming_absent_ecosystem_is_missing_group_member() {
712 let dir = tempfile::tempdir().expect("tempdir");
713 let root = dir.path();
714 write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
715 std::fs::write(
716 root.join("callisto.toml"),
717 "[[fixed-group]]\nname = \"g\"\nmembers = [\"npm:foo\"]\n",
718 )
719 .unwrap();
720 let locator = crate::locate::IgnoreWalkLocator::new(root);
721 let runner = NoopRunner;
722 let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
723 Err(e) => e,
724 Ok(_) => panic!("expected MissingGroupMember error, got Ok"),
725 };
726 match err {
727 GraphError::MissingGroupMember { member, .. } => {
728 assert_eq!(member, "npm:foo");
729 }
730 other => panic!("expected MissingGroupMember, got {other:?}"),
731 }
732 }
733
734 struct NoopRunner;
735 impl CommandRunner for NoopRunner {
736 fn run(
737 &self,
738 _program: &str,
739 _args: &[&str],
740 _cwd: &Path,
741 ) -> Result<callisto_model::CommandOutput, callisto_model::CommandError> {
742 panic!("this test's workspace build never needs to shell out");
743 }
744 }
745
746 #[test]
756 fn real_platform_manifest_resolves_via_fixed_group_and_feeds_napi_drift() {
757 let dir = tempfile::tempdir().expect("tempdir");
758 let root = dir.path();
759
760 std::fs::write(
761 root.join("Cargo.toml"),
762 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
763 )
764 .unwrap();
765 std::fs::write(
766 root.join("package.json"),
767 r#"{"name":"@myorg/my-crate-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
768 )
769 .unwrap();
770 std::fs::write(
771 root.join("callisto.toml"),
772 "[[fixed-group]]\nname = \"my-group\"\nmembers = [\"my-crate\", \"@myorg/my-crate-linux-x64-gnu\"]\n",
773 )
774 .unwrap();
775
776 let locator = crate::locate::IgnoreWalkLocator::new(root);
777 let runner = NoopRunner;
778 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
779 .expect("group resolution must succeed now that the real platform manifest resolves");
780
781 let group = ws
782 .config
783 .groups
784 .fixed
785 .get(&callisto_model::GroupName("my-group".to_string()))
786 .expect("fixed group must exist");
787
788 let platform_member = group
789 .members
790 .iter()
791 .find(|m| matches!(m, crate::config::groups::GroupMember::PlatformManifest { .. }))
792 .expect("platform member must resolve, not be silently dropped or errored");
793
794 let crate::config::groups::GroupMember::PlatformManifest { role, name, .. } = platform_member else {
795 unreachable!()
796 };
797 assert_eq!(name, "@myorg/my-crate-linux-x64-gnu");
798 assert_eq!(
799 crate::napi::role_to_triple(role).as_deref(),
800 Some("x86_64-unknown-linux-gnu"),
801 "role must be the real, disk-derived role, not the old hardcoded \
802 platform=\"unknown\" stub -- got: {role:?}"
803 );
804
805 let declared = vec!["x86_64-unknown-linux-gnu".to_string()];
808 let diagnostics = crate::napi::napi_drift(group, &declared, root);
809 assert!(
810 diagnostics.is_empty(),
811 "declared napi.targets matches the real group member; expected no drift \
812 diagnostics, got: {diagnostics:?}"
813 );
814 }
815
816 #[test]
827 fn optional_dependency_on_case_d_platform_package_resolves_via_native_index() {
828 let dir = tempfile::tempdir().expect("tempdir");
829 let root = dir.path();
830
831 std::fs::write(
832 root.join("Cargo.toml"),
833 "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
834 )
835 .unwrap();
836 std::fs::write(
837 root.join("package.json"),
838 r#"{"name":"@myorg/my-crate-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
839 )
840 .unwrap();
841
842 std::fs::create_dir_all(root.join("consumer")).unwrap();
843 std::fs::write(
844 root.join("consumer/package.json"),
845 r#"{"name":"@myorg/consumer","version":"0.1.0","optionalDependencies":{"@myorg/my-crate-linux-x64-gnu":"0.1.0"}}"#,
846 )
847 .unwrap();
848
849 let locator = crate::locate::IgnoreWalkLocator::new(root);
850 let runner = NoopRunner;
851 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("workspace load must succeed");
852
853 let owning_crate = callisto_model::PackageId::Bare("my-crate".to_string());
854 let consumer = callisto_model::PackageId::Bare("@myorg/consumer".to_string());
855
856 let edge = ws.graph.edges().iter().find(|e| e.from == consumer).expect(
857 "consumer's optionalDependencies edge onto the Case D platform package must \
858 resolve, not be silently dropped",
859 );
860 assert_eq!(
861 edge.to, owning_crate,
862 "the platform package belongs to the owning crate (Case D); the edge must resolve \
863 to the owning crate's identity, not fail to resolve at all"
864 );
865 }
866
867 #[test]
877 fn detect_npm_role_infers_linux_gnu_abi_from_package_name_suffix() {
878 let dir = tempfile::tempdir().unwrap();
879 let path = dir.path().join("package.json");
880 std::fs::write(
881 &path,
882 r#"{"name":"@scope/my-lib-linux-x64-gnu","version":"1.0.0","os":["linux"],"cpu":["x64"]}"#,
883 )
884 .unwrap();
885
886 let role = detect_npm_role(&path);
887
888 assert_eq!(
889 crate::napi::role_to_triple(&role).as_deref(),
890 Some("x86_64-unknown-linux-gnu"),
891 "expected a resolvable triple, got role: {role:?}"
892 );
893 }
894
895 #[test]
896 fn detect_npm_role_infers_linux_musl_abi_from_package_name_suffix() {
897 let dir = tempfile::tempdir().unwrap();
898 let path = dir.path().join("package.json");
899 std::fs::write(
900 &path,
901 r#"{"name":"@scope/my-lib-linux-arm64-musl","version":"1.0.0","os":["linux"],"cpu":["arm64"]}"#,
902 )
903 .unwrap();
904
905 let role = detect_npm_role(&path);
906
907 assert_eq!(
908 crate::napi::role_to_triple(&role).as_deref(),
909 Some("aarch64-unknown-linux-musl"),
910 "expected a resolvable triple, got role: {role:?}"
911 );
912 }
913
914 #[test]
915 fn detect_npm_role_infers_linux_gnueabihf_abi_from_package_name_suffix() {
916 let dir = tempfile::tempdir().unwrap();
917 let path = dir.path().join("package.json");
918 std::fs::write(
919 &path,
920 r#"{"name":"@scope/my-lib-linux-arm-gnueabihf","version":"1.0.0","os":["linux"],"cpu":["arm"]}"#,
921 )
922 .unwrap();
923
924 let role = detect_npm_role(&path);
925
926 assert_eq!(
927 crate::napi::role_to_triple(&role).as_deref(),
928 Some("armv7-unknown-linux-gnueabihf"),
929 "expected a resolvable triple, got role: {role:?}"
930 );
931 }
932
933 #[test]
937 fn detect_npm_role_does_not_infer_abi_for_non_linux_platforms() {
938 let dir = tempfile::tempdir().unwrap();
939 let path = dir.path().join("package.json");
940 std::fs::write(
941 &path,
942 r#"{"name":"@scope/my-lib-darwin-arm64-gnu","version":"1.0.0","os":["darwin"],"cpu":["arm64"]}"#,
943 )
944 .unwrap();
945
946 let role = detect_npm_role(&path);
947
948 assert_eq!(
949 role,
950 ManifestRole::Platform {
951 platform: "darwin".to_string(),
952 arch: "arm64".to_string(),
953 abi: None,
954 }
955 );
956 }
957
958 #[test]
962 fn detect_npm_role_leaves_abi_none_when_linux_name_has_no_recognized_suffix() {
963 let dir = tempfile::tempdir().unwrap();
964 let path = dir.path().join("package.json");
965 std::fs::write(
966 &path,
967 r#"{"name":"@scope/my-lib-linux-x64","version":"1.0.0","os":["linux"],"cpu":["x64"]}"#,
968 )
969 .unwrap();
970
971 let role = detect_npm_role(&path);
972
973 assert_eq!(
974 role,
975 ManifestRole::Platform {
976 platform: "linux".to_string(),
977 arch: "x64".to_string(),
978 abi: None,
979 }
980 );
981 }
982
983 #[test]
984 fn claiming_ecosystems_is_name_scoped_not_full_path_ecosystem_set() {
985 let list = vec![
986 (Ecosystem::Cargo, PackageId::Bare("native-core".to_string())),
987 (
988 Ecosystem::Npm,
989 PackageId::Bare("@myorg/native-core-linux-x64-gnu".to_string()),
990 ),
991 ];
992 let primary_id = PackageId::Bare("native-core".to_string());
993 let (claiming, native_keys) = compute_claiming_ecosystems_and_native_keys(&list, &primary_id);
994 let mut expected = std::collections::BTreeSet::new();
995 expected.insert(Ecosystem::Cargo);
996 assert_eq!(
997 claiming, expected,
998 "npm Platform entry must NOT count toward the name-scoped claiming set"
999 );
1000 assert_eq!(
1001 native_keys,
1002 vec![
1003 (Ecosystem::Cargo, "native-core".to_string()),
1004 (Ecosystem::Npm, "@myorg/native-core-linux-x64-gnu".to_string()),
1005 ],
1006 "path_native_keys must retain the COMPLETE unfiltered key set, unlike claiming_ecosystems"
1007 );
1008 }
1009
1010 #[test]
1011 fn primary_ecosystems_records_the_actual_primary_not_an_arbitrary_set_member() {
1012 let dir = tempfile::tempdir().expect("tempdir");
1013 let root = dir.path();
1014 write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1015 std::fs::write(
1016 root.join("crates/foo/package.json"),
1017 r#"{"name":"@myorg/foo","version":"0.1.0"}"#,
1018 )
1019 .unwrap();
1020 let locator = crate::locate::IgnoreWalkLocator::new(root);
1021 let runner = NoopRunner;
1022 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1023 assert!(ws.graph.get(&PackageId::Bare("foo".to_string())).is_some());
1024 }
1025
1026 #[test]
1027 fn is_promoted_when_multiple_paths_claim_same_bare_name_with_disjoint_ecosystems() {
1028 let p1 = PathBuf::from("crates/native-core");
1029 let p2 = PathBuf::from("packages/native-core");
1030 let mut claiming = std::collections::BTreeMap::new();
1031 let mut eco1 = std::collections::BTreeSet::new();
1032 eco1.insert(Ecosystem::Cargo);
1033 let mut eco2 = std::collections::BTreeSet::new();
1034 eco2.insert(Ecosystem::Npm);
1035 claiming.insert(p1.clone(), eco1);
1036 claiming.insert(p2.clone(), eco2);
1037 let paths = vec![p1, p2];
1038 assert!(is_promoted_bare_name(&paths, &claiming));
1039 }
1040
1041 #[test]
1042 fn is_not_promoted_when_single_path_claims_bare_name() {
1043 let p1 = PathBuf::from("crates/native-core");
1044 let mut claiming = std::collections::BTreeMap::new();
1045 let mut eco1 = std::collections::BTreeSet::new();
1046 eco1.insert(Ecosystem::Cargo);
1047 claiming.insert(p1.clone(), eco1);
1048 let paths = vec![p1];
1049 assert!(!is_promoted_bare_name(&paths, &claiming));
1050 }
1051
1052 #[test]
1053 fn is_not_promoted_when_multiple_paths_have_overlapping_ecosystems() {
1054 let p1 = PathBuf::from("crates/native-core");
1055 let p2 = PathBuf::from("crates/other-core");
1056 let mut claiming = std::collections::BTreeMap::new();
1057 let mut eco1 = std::collections::BTreeSet::new();
1058 eco1.insert(Ecosystem::Cargo);
1059 let mut eco2 = std::collections::BTreeSet::new();
1060 eco2.insert(Ecosystem::Cargo);
1061 claiming.insert(p1.clone(), eco1);
1062 claiming.insert(p2.clone(), eco2);
1063 let paths = vec![p1, p2];
1064 assert!(
1065 !is_promoted_bare_name(&paths, &claiming),
1066 "overlapping ecosystem sets must NOT trigger promotion; duplicate check handles it"
1067 );
1068 }
1069
1070 #[test]
1071 fn promotion_predicate_is_disjointness_not_inequality() {
1072 let mut cargo_npm = BTreeSet::new();
1073 cargo_npm.insert(Ecosystem::Cargo);
1074 cargo_npm.insert(Ecosystem::Npm);
1075 let mut npm_only = BTreeSet::new();
1076 npm_only.insert(Ecosystem::Npm);
1077 let mut pypi_only = BTreeSet::new();
1078 pypi_only.insert(Ecosystem::Pypi);
1079
1080 assert!(!claiming_sets_disjoint(&cargo_npm, &npm_only));
1081 assert!(claiming_sets_disjoint(&cargo_npm, &pypi_only));
1082 assert!(claiming_sets_disjoint(&npm_only, &pypi_only));
1083 }
1084
1085 #[test]
1086 fn same_ecosystem_collision_still_errors_unchanged() {
1087 let dir = tempfile::tempdir().expect("tempdir");
1088 let root = dir.path();
1089 write_pkg(root, "crates/a", Ecosystem::Cargo, "dup");
1090 write_pkg(root, "crates/b", Ecosystem::Cargo, "dup");
1091 let locator = crate::locate::IgnoreWalkLocator::new(root);
1092 let runner = NoopRunner;
1093 let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1094 Err(e) => e,
1095 Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1096 };
1097 match err {
1098 GraphError::DuplicatePackage { id, paths } => {
1099 assert_eq!(id, PackageId::Bare("dup".to_string()));
1100 assert_eq!(paths.len(), 2);
1101 }
1102 other => panic!("expected DuplicatePackage, got {other:?}"),
1103 }
1104 }
1105
1106 #[test]
1107 fn case_d_colliding_with_third_disjoint_ecosystem_still_errors() {
1108 let dir = tempfile::tempdir().expect("tempdir");
1109 let root = dir.path();
1110 write_pkg(root, "crates/case-d", Ecosystem::Cargo, "hybrid");
1111 write_pkg(root, "crates/case-d", Ecosystem::Npm, "hybrid");
1112 write_pkg(root, "packages/npm-hybrid", Ecosystem::Npm, "hybrid");
1113 let locator = crate::locate::IgnoreWalkLocator::new(root);
1114 let runner = NoopRunner;
1115 let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1116 Err(e) => e,
1117 Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1118 };
1119 match err {
1120 GraphError::DuplicatePackage { id, paths } => {
1121 assert_eq!(id, PackageId::Bare("hybrid".to_string()));
1122 assert_eq!(paths.len(), 2);
1123 }
1124 other => panic!("expected DuplicatePackage, got {other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn disjoint_cross_ecosystem_collision_promotes_instead_of_duplicate_package() {
1130 let dir = tempfile::tempdir().expect("tempdir");
1131 let root = dir.path();
1132 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1133 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1134
1135 let locator = crate::locate::IgnoreWalkLocator::new(root);
1136 let runner = NoopRunner;
1137 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1138 .expect("disjoint cross-ecosystem collision must promote, not DuplicatePackage");
1139
1140 let cargo_id = PackageId::Prefixed {
1141 ecosystem: Ecosystem::Cargo,
1142 name: "native-core".to_string(),
1143 };
1144 let npm_id = PackageId::Prefixed {
1145 ecosystem: Ecosystem::Npm,
1146 name: "native-core".to_string(),
1147 };
1148 assert_eq!(ws.graph.packages().count(), 2);
1149 let cargo_pkg = ws.graph.get(&cargo_id).expect("Cargo-prefixed entry must exist");
1150 let npm_pkg = ws.graph.get(&npm_id).expect("Npm-prefixed entry must exist");
1151 assert_eq!(cargo_pkg.manifests.len(), 1);
1152 assert_eq!(
1153 cargo_pkg.manifests[0].path,
1154 PathBuf::from("crates/native-core/Cargo.toml")
1155 );
1156 assert_eq!(npm_pkg.manifests.len(), 1);
1157 assert_eq!(
1158 npm_pkg.manifests[0].path,
1159 PathBuf::from("packages/native-core/package.json")
1160 );
1161 }
1162
1163 #[test]
1164 fn promoted_name_removed_from_index_bare() {
1165 let dir = tempfile::tempdir().expect("tempdir");
1166 let root = dir.path();
1167 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1168 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1169 let locator = crate::locate::IgnoreWalkLocator::new(root);
1170 let runner = NoopRunner;
1171 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1172 assert!(
1173 !ws.graph.identity().bare.contains_key("native-core"),
1174 "index.bare must not retain the stale pre-promotion key once both occurrences promote"
1175 );
1176 let unprefixed_lookup = ws.graph.identity().resolve_human("native-core", &[]);
1177 assert!(matches!(unprefixed_lookup, Err(GraphError::AmbiguousName { .. })));
1178 }
1179
1180 #[test]
1181 fn promoted_native_values_point_to_prefixed_ids() {
1182 let dir = tempfile::tempdir().expect("tempdir");
1183 let root = dir.path();
1184 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1185 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1186 let locator = crate::locate::IgnoreWalkLocator::new(root);
1187 let runner = NoopRunner;
1188 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1189
1190 let cargo_native = ws
1191 .graph
1192 .identity()
1193 .native
1194 .get(&(Ecosystem::Cargo, "native-core".to_string()))
1195 .expect("Cargo native entry must exist");
1196 let npm_native = ws
1197 .graph
1198 .identity()
1199 .native
1200 .get(&(Ecosystem::Npm, "native-core".to_string()))
1201 .expect("Npm native entry must exist");
1202
1203 assert_eq!(
1204 cargo_native,
1205 &PackageId::Prefixed {
1206 ecosystem: Ecosystem::Cargo,
1207 name: "native-core".to_string(),
1208 },
1209 "index.native value must point to the promoted Cargo-prefixed ID"
1210 );
1211 assert_eq!(
1212 npm_native,
1213 &PackageId::Prefixed {
1214 ecosystem: Ecosystem::Npm,
1215 name: "native-core".to_string(),
1216 },
1217 "index.native value must point to the promoted Npm-prefixed ID"
1218 );
1219 }
1220
1221 #[test]
1222 fn promoted_prefixed_values_point_to_prefixed_ids() {
1223 let dir = tempfile::tempdir().expect("tempdir");
1224 let root = dir.path();
1225 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1226 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1227 let locator = crate::locate::IgnoreWalkLocator::new(root);
1228 let runner = NoopRunner;
1229 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1230
1231 let cargo_prefixed = ws
1232 .graph
1233 .identity()
1234 .prefixed
1235 .get(&(Ecosystem::Cargo, "native-core".to_string()))
1236 .expect("Cargo prefixed entry must exist");
1237 let npm_prefixed = ws
1238 .graph
1239 .identity()
1240 .prefixed
1241 .get(&(Ecosystem::Npm, "native-core".to_string()))
1242 .expect("Npm prefixed entry must exist");
1243
1244 assert_eq!(
1245 cargo_prefixed,
1246 &PackageId::Prefixed {
1247 ecosystem: Ecosystem::Cargo,
1248 name: "native-core".to_string(),
1249 },
1250 "index.prefixed value must point to the promoted Cargo-prefixed ID"
1251 );
1252 assert_eq!(
1253 npm_prefixed,
1254 &PackageId::Prefixed {
1255 ecosystem: Ecosystem::Npm,
1256 name: "native-core".to_string(),
1257 },
1258 "index.prefixed value must point to the promoted Npm-prefixed ID"
1259 );
1260 }
1261
1262 #[test]
1269 fn promoted_platform_index_value_points_to_prefixed_owner_id() {
1270 let dir = tempfile::tempdir().expect("tempdir");
1271 let root = dir.path();
1272 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1273 std::fs::write(
1274 root.join("crates/native-core/package.json"),
1275 r#"{"name":"@myorg/native-core-linux-x64-gnu","version":"0.1.0","os":["linux"],"cpu":["x64"]}"#,
1276 )
1277 .unwrap();
1278 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1279
1280 let locator = crate::locate::IgnoreWalkLocator::new(root);
1281 let runner = NoopRunner;
1282 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1283
1284 let platform_entry = ws
1285 .graph
1286 .identity()
1287 .platform
1288 .get("@myorg/native-core-linux-x64-gnu")
1289 .expect("platform entry must exist");
1290
1291 assert_eq!(
1292 platform_entry.0,
1293 PackageId::Prefixed {
1294 ecosystem: Ecosystem::Cargo,
1295 name: "native-core".to_string(),
1296 },
1297 "index.platform's owner id must point to the promoted Cargo-prefixed id, \
1298 not the stale pre-promotion bare id"
1299 );
1300 }
1301
1302 #[test]
1303 fn unpromoted_standalone_cargo_package_retains_bare_id() {
1304 let dir = tempfile::tempdir().expect("tempdir");
1305 let root = dir.path();
1306 write_pkg(root, "crates/single", Ecosystem::Cargo, "single");
1307 let locator = crate::locate::IgnoreWalkLocator::new(root);
1308 let runner = NoopRunner;
1309 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1310 .expect("standalone package load must succeed");
1311 let bare_id = PackageId::Bare("single".to_string());
1312 assert!(
1313 ws.graph.get(&bare_id).is_some(),
1314 "unpromoted single-ecosystem package must register under Bare ID"
1315 );
1316 let prefixed_id = PackageId::Prefixed {
1317 ecosystem: Ecosystem::Cargo,
1318 name: "single".to_string(),
1319 };
1320 assert!(
1321 ws.graph.get(&prefixed_id).is_none(),
1322 "unpromoted package must NOT register under Prefixed ID directly"
1323 );
1324 assert_eq!(
1325 ws.graph.identity().bare.get("single"),
1326 Some(&bare_id),
1327 "index.bare must point to the Bare ID"
1328 );
1329 let resolved = ws
1330 .graph
1331 .identity()
1332 .resolve_human("cargo:single", &[])
1333 .expect("cargo:single must resolve");
1334 assert_eq!(
1335 resolved, bare_id,
1336 "cargo:single human lookup must resolve to the Bare ID"
1337 );
1338 }
1339
1340 #[test]
1341 fn case_d_single_path_multi_ecosystem_retains_bare_id_and_preserves_platform() {
1342 let dir = tempfile::tempdir().expect("tempdir");
1343 let root = dir.path();
1344 write_pkg(root, "crates/hybrid", Ecosystem::Cargo, "hybrid");
1345 std::fs::write(
1346 root.join("crates/hybrid/package.json"),
1347 r#"{"name":"@myorg/hybrid-darwin-arm64","version":"0.1.0","os":["darwin"],"cpu":["arm64"]}"#,
1348 )
1349 .unwrap();
1350
1351 let locator = crate::locate::IgnoreWalkLocator::new(root);
1352 let runner = NoopRunner;
1353 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1354
1355 let bare_id = PackageId::Bare("hybrid".to_string());
1356 assert!(
1357 ws.graph.get(&bare_id).is_some(),
1358 "Case D primary package must register under Bare ID"
1359 );
1360 let platform_entry = ws
1361 .graph
1362 .identity()
1363 .platform
1364 .get("@myorg/hybrid-darwin-arm64")
1365 .expect("platform entry must exist in IdentityIndex.platform");
1366 assert_eq!(platform_entry.0, bare_id, "platform entry owner must be the Bare ID");
1367 assert_eq!(platform_entry.1, PathBuf::from("crates/hybrid/package.json"));
1368 }
1369
1370 #[test]
1371 fn promoted_index_prefixed_holds_two_distinct_ids_not_stale_bare() {
1372 let dir = tempfile::tempdir().expect("tempdir");
1373 let root = dir.path();
1374 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1375 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1376 let locator = crate::locate::IgnoreWalkLocator::new(root);
1377 let runner = NoopRunner;
1378 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("promotion must succeed");
1379 let cargo_id = ws
1380 .graph
1381 .identity()
1382 .prefixed
1383 .get(&(Ecosystem::Cargo, "native-core".to_string()))
1384 .expect("prefixed Cargo entry must exist");
1385 let npm_id = ws
1386 .graph
1387 .identity()
1388 .prefixed
1389 .get(&(Ecosystem::Npm, "native-core".to_string()))
1390 .expect("prefixed Npm entry must exist");
1391 assert_eq!(
1392 *cargo_id,
1393 PackageId::Prefixed {
1394 ecosystem: Ecosystem::Cargo,
1395 name: "native-core".to_string()
1396 }
1397 );
1398 assert_eq!(
1399 *npm_id,
1400 PackageId::Prefixed {
1401 ecosystem: Ecosystem::Npm,
1402 name: "native-core".to_string()
1403 }
1404 );
1405 assert_ne!(
1406 cargo_id, npm_id,
1407 "a distinct-id count of 2 is required for ResolvedConfig.promoted_siblings' derivation (AC-23) to retain this name"
1408 );
1409 }
1410
1411 #[test]
1412 fn tri_fixture_rejects_only_the_offending_member_and_path() {
1413 let dir = tempfile::tempdir().expect("tempdir");
1414 let root = dir.path();
1415 write_pkg(root, "aaa/tri", Ecosystem::Cargo, "tri");
1416 std::fs::write(root.join("aaa/tri/package.json"), r#"{"name":"tri","version":"0.1.0"}"#).unwrap();
1417 write_pkg(root, "bbb/tri", Ecosystem::Pypi, "tri");
1418 write_pkg(root, "ccc/tri", Ecosystem::Npm, "tri");
1419 let locator = crate::locate::IgnoreWalkLocator::new(root);
1420 let runner = NoopRunner;
1421 let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1422 Err(e) => e,
1423 Ok(_) => panic!("expected DuplicatePackage error, got Ok"),
1424 };
1425 match err {
1426 GraphError::DuplicatePackage { id, paths } => {
1427 assert_eq!(id, PackageId::Bare("tri".to_string()));
1428 assert!(paths.contains(&PathBuf::from("aaa/tri")));
1429 assert!(paths.contains(&PathBuf::from("ccc/tri")));
1430 assert!(
1431 !paths.contains(&PathBuf::from("bbb/tri")),
1432 "the non-offending group member bbb/tri must not be named"
1433 );
1434 }
1435 other => panic!("expected DuplicatePackage, got {other:?}"),
1436 }
1437 }
1438
1439 #[test]
1440 fn third_path_joins_already_promoted_group() {
1441 let dir = tempfile::tempdir().expect("tempdir");
1442 let root = dir.path();
1443 write_pkg(root, "aaa", Ecosystem::Cargo, "multi");
1444 write_pkg(root, "bbb", Ecosystem::Npm, "multi");
1445 write_pkg(root, "ccc", Ecosystem::Pypi, "multi");
1446 let locator = crate::locate::IgnoreWalkLocator::new(root);
1447 let runner = NoopRunner;
1448 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("N=3 promotion must succeed");
1449 assert_eq!(ws.graph.packages().count(), 3);
1450 for eco in [Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi] {
1451 let id = PackageId::Prefixed {
1452 ecosystem: eco,
1453 name: "multi".to_string(),
1454 };
1455 let pkg = ws.graph.get(&id).unwrap_or_else(|| panic!("{eco:?} entry must exist"));
1456 assert_eq!(
1457 pkg.manifests.len(),
1458 1,
1459 "{eco:?} entry must not bleed in another path's manifest"
1460 );
1461 }
1462 }
1463
1464 #[test]
1465 fn third_path_joins_already_promoted_group_reverse_iteration_order() {
1466 let dir = tempfile::tempdir().expect("tempdir");
1471 let root = dir.path();
1472 write_pkg(root, "path-a-pypi", Ecosystem::Pypi, "multi2");
1473 write_pkg(root, "path-b-npm", Ecosystem::Npm, "multi2");
1474 write_pkg(root, "path-c-cargo", Ecosystem::Cargo, "multi2");
1475 let locator = crate::locate::IgnoreWalkLocator::new(root);
1476 let runner = NoopRunner;
1477 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1478 .expect("N=3 promotion must succeed regardless of by-path iteration order");
1479 assert_three_promoted_singletons(&ws, "multi2");
1480 }
1481
1482 fn assert_three_promoted_singletons(ws: &crate::Workspace<NoopRunner>, name: &str) {
1483 assert_eq!(ws.graph.packages().count(), 3);
1484 for eco in [Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi] {
1485 let id = PackageId::Prefixed {
1486 ecosystem: eco,
1487 name: name.to_string(),
1488 };
1489 let pkg = ws.graph.get(&id).unwrap_or_else(|| panic!("{eco:?} entry must exist"));
1490 assert_eq!(
1491 pkg.manifests.len(),
1492 1,
1493 "{eco:?} entry must not bleed in another path's manifest"
1494 );
1495 }
1496 }
1497
1498 #[test]
1499 fn maturin_pyo3_layout_promotes_cargo_and_pypi_siblings() {
1500 let dir = tempfile::tempdir().expect("tempdir");
1501 let root = dir.path();
1502 write_pkg(root, "bindings/rust", Ecosystem::Cargo, "mylib");
1503 write_pkg(root, "bindings/python", Ecosystem::Pypi, "mylib");
1504 let locator = crate::locate::IgnoreWalkLocator::new(root);
1505 let runner = NoopRunner;
1506 let ws =
1507 crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Cargo/Pypi promotion must succeed");
1508 let cargo_pkg = ws
1509 .graph
1510 .get(&PackageId::Prefixed {
1511 ecosystem: Ecosystem::Cargo,
1512 name: "mylib".to_string(),
1513 })
1514 .expect("Cargo entry must exist");
1515 let pypi_pkg = ws
1516 .graph
1517 .get(&PackageId::Prefixed {
1518 ecosystem: Ecosystem::Pypi,
1519 name: "mylib".to_string(),
1520 })
1521 .expect("Pypi entry must exist");
1522 assert_eq!(cargo_pkg.manifests.len(), 1);
1523 assert_eq!(cargo_pkg.manifests[0].path, PathBuf::from("bindings/rust/Cargo.toml"));
1524 assert_eq!(pypi_pkg.manifests.len(), 1);
1525 assert_eq!(
1526 pypi_pkg.manifests[0].path,
1527 PathBuf::from("bindings/python/pyproject.toml")
1528 );
1529 }
1530
1531 #[test]
1532 fn case_d_package_with_no_collision_stays_bare() {
1533 let dir = tempfile::tempdir().expect("tempdir");
1534 let root = dir.path();
1535 write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1536 std::fs::write(
1537 root.join("crates/foo/package.json"),
1538 r#"{"name":"@myorg/foo","version":"0.1.0"}"#,
1539 )
1540 .unwrap();
1541 let locator = crate::locate::IgnoreWalkLocator::new(root);
1542 let runner = NoopRunner;
1543 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("Case D load must succeed");
1544 assert_eq!(ws.graph.packages().count(), 1);
1545 let pkg = ws
1546 .graph
1547 .get(&PackageId::Bare("foo".to_string()))
1548 .expect("single Bare(foo) entry must exist, unpromoted");
1549 assert_eq!(
1550 pkg.manifests.len(),
1551 2,
1552 "both the Cargo and npm manifest belong to the one Case D package"
1553 );
1554 }
1555
1556 #[test]
1557 fn ac12_ac18_npm_consumer_depending_on_cargo_only_name_gets_no_edge_and_diagnostic() {
1558 let dir = tempfile::tempdir().expect("tempdir");
1559 let root = dir.path();
1560 write_pkg(root, "crates/foo", Ecosystem::Cargo, "foo");
1561 std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1562 std::fs::write(
1563 root.join("packages/consumer/package.json"),
1564 r#"{"name":"consumer","version":"0.1.0","dependencies":{"foo":"^1.0.0"}}"#,
1565 )
1566 .unwrap();
1567 let locator = crate::locate::IgnoreWalkLocator::new(root);
1568 let runner = NoopRunner;
1569 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1570 .expect("load must succeed even with an unresolved dependency name");
1571 let cargo_foo = PackageId::Bare("foo".to_string());
1572 assert!(
1573 !ws.graph.edges().iter().any(|e| e.to == cargo_foo),
1574 "no DepEdge must be created to the Cargo foo id from an npm consumer's same-name miss"
1575 );
1576 let diag = ws
1577 .graph
1578 .diagnostics()
1579 .iter()
1580 .find(|d| {
1581 d.code == callisto_model::DiagnosticCode::UnknownPackage
1582 && d.severity == callisto_model::DiagnosticSeverity::Warning
1583 && d.message.contains("foo")
1584 })
1585 .expect("an UnknownPackage warning diagnostic naming foo must be present");
1586 assert!(diag.message.contains("ambiguous") || diag.message.contains("cargo:foo"));
1587 }
1588
1589 #[test]
1590 fn ac14_ac18_npm_consumer_depending_on_serde_with_cargo_serde_present_gets_no_edge_and_diagnostic() {
1591 let dir = tempfile::tempdir().expect("tempdir");
1592 let root = dir.path();
1593 write_pkg(root, "crates/serde", Ecosystem::Cargo, "serde");
1594 std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1595 std::fs::write(
1596 root.join("packages/consumer/package.json"),
1597 r#"{"name":"consumer","version":"0.1.0","dependencies":{"serde":"^1"}}"#,
1598 )
1599 .unwrap();
1600 let locator = crate::locate::IgnoreWalkLocator::new(root);
1601 let runner = NoopRunner;
1602 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("load must succeed");
1603 let cargo_serde = PackageId::Bare("serde".to_string());
1604 assert!(!ws.graph.edges().iter().any(|e| e.to == cargo_serde));
1605 let diag = ws
1606 .graph
1607 .diagnostics()
1608 .iter()
1609 .find(|d| {
1610 d.code == callisto_model::DiagnosticCode::UnknownPackage
1611 && d.severity == callisto_model::DiagnosticSeverity::Warning
1612 && d.message.contains("serde")
1613 })
1614 .expect("an UnknownPackage warning diagnostic naming serde must be present");
1615 assert!(diag.message.contains("ambiguous") || diag.message.contains("cargo:serde"));
1616 }
1617
1618 #[test]
1619 fn ac15_ac18_npm_consumer_depending_on_ambiguous_lib_with_cargo_and_pypi_present_gets_exactly_one_diagnostic() {
1620 let dir = tempfile::tempdir().expect("tempdir");
1621 let root = dir.path();
1622 write_pkg(root, "crates/ambiguous-lib", Ecosystem::Cargo, "ambiguous-lib");
1623 write_pkg(root, "py/ambiguous-lib", Ecosystem::Pypi, "ambiguous-lib");
1624 std::fs::create_dir_all(root.join("packages/consumer")).unwrap();
1625 std::fs::write(
1626 root.join("packages/consumer/package.json"),
1627 r#"{"name":"consumer","version":"0.1.0","dependencies":{"ambiguous-lib":"^1"}}"#,
1628 )
1629 .unwrap();
1630 let locator = crate::locate::IgnoreWalkLocator::new(root);
1631 let runner = NoopRunner;
1632 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner).expect("load must succeed");
1633 let diags: Vec<_> = ws
1634 .graph
1635 .diagnostics()
1636 .iter()
1637 .filter(|d| d.code == callisto_model::DiagnosticCode::UnknownPackage && d.message.contains("ambiguous-lib"))
1638 .collect();
1639 assert_eq!(
1640 diags.len(),
1641 1,
1642 "exactly one diagnostic, not one per candidate ecosystem"
1643 );
1644 }
1645
1646 #[test]
1647 fn unprefixed_package_rule_matching_two_promoted_siblings_is_ambiguous() {
1648 let dir = tempfile::tempdir().expect("tempdir");
1649 let root = dir.path();
1650 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1651 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1652 std::fs::write(
1653 root.join("callisto.toml"),
1654 "[[package]]\nmatch = \"native-core\"\nrelease-trigger = \"auto\"\n",
1655 )
1656 .unwrap();
1657 let locator = crate::locate::IgnoreWalkLocator::new(root);
1658 let runner = NoopRunner;
1659 let err = match crate::Workspace::load(root.to_path_buf(), &locator, &runner) {
1660 Err(e) => e,
1661 Ok(_) => panic!("expected AmbiguousName error, got Ok"),
1662 };
1663 match err {
1664 GraphError::AmbiguousName { name, candidates } => {
1665 assert_eq!(name, "native-core");
1666 assert_eq!(candidates.len(), 2);
1667 assert!(candidates.contains(&PackageId::Prefixed {
1668 ecosystem: Ecosystem::Cargo,
1669 name: "native-core".to_string()
1670 }));
1671 assert!(candidates.contains(&PackageId::Prefixed {
1672 ecosystem: Ecosystem::Npm,
1673 name: "native-core".to_string()
1674 }));
1675 }
1676 other => panic!("expected AmbiguousName, got {other:?}"),
1677 }
1678 }
1679
1680 #[test]
1681 fn prefixed_package_rules_still_apply_correctly_to_promoted_siblings() {
1682 let dir = tempfile::tempdir().expect("tempdir");
1683 let root = dir.path();
1684 write_pkg(root, "crates/native-core", Ecosystem::Cargo, "native-core");
1685 write_pkg(root, "packages/native-core", Ecosystem::Npm, "native-core");
1686 std::fs::write(
1687 root.join("callisto.toml"),
1688 "[[package]]\nmatch = \"cargo:native-core\"\nrelease-trigger = \"changeset\"\n\n[[package]]\nmatch = \"npm:native-core\"\nrelease-trigger = \"auto\"\n",
1689 )
1690 .unwrap();
1691 let locator = crate::locate::IgnoreWalkLocator::new(root);
1692 let runner = NoopRunner;
1693 let ws = crate::Workspace::load(root.to_path_buf(), &locator, &runner)
1694 .expect("prefixed rules must not trigger the ambiguity check");
1695 let cargo_pkg = ws
1696 .graph
1697 .get(&PackageId::Prefixed {
1698 ecosystem: Ecosystem::Cargo,
1699 name: "native-core".to_string(),
1700 })
1701 .unwrap();
1702 let npm_pkg = ws
1703 .graph
1704 .get(&PackageId::Prefixed {
1705 ecosystem: Ecosystem::Npm,
1706 name: "native-core".to_string(),
1707 })
1708 .unwrap();
1709 assert_eq!(cargo_pkg.release_trigger, ReleaseTrigger::Changeset);
1710 assert_eq!(npm_pkg.release_trigger, ReleaseTrigger::Auto);
1711 }
1712}