1use std::collections::{BTreeMap, BTreeSet, VecDeque};
21use std::path::Path;
22
23use anyhow::{Context, Result, bail};
24use cargo_metadata::{DependencyKind, Metadata, PackageId};
25use chrono::Utc;
26use sha2::{Digest, Sha256};
27use shipper_types::{PlannedPackage, ReleasePlan, ReleaseSpec};
28pub use shipper_types::{PlannedWorkspace, SkippedPackage};
29
30pub fn build_plan(spec: &ReleaseSpec) -> Result<PlannedWorkspace> {
44 let metadata = load_metadata(&spec.manifest_path)?;
45 let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
46
47 let pkg_map = metadata
48 .packages
49 .iter()
50 .map(|p| (p.id.clone(), p))
51 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
52
53 let workspace_ids: BTreeSet<PackageId> = metadata.workspace_members.iter().cloned().collect();
54
55 let mut skipped: Vec<SkippedPackage> = Vec::new();
57
58 let publishable: BTreeSet<PackageId> = workspace_ids
60 .iter()
61 .filter_map(|id| {
62 let pkg = pkg_map.get(id)?;
63 if publish_allowed(pkg, &spec.registry.name) {
64 Some(id.clone())
65 } else {
66 let reason = match &pkg.publish {
68 None => "publish not specified (default allowed)".to_string(),
69 Some(list) if list.is_empty() => "publish = false".to_string(),
70 Some(list) => format!("publish = {} (registry not in list)", list.join(", ")),
71 };
72 skipped.push(SkippedPackage {
73 name: pkg.name.to_string(),
74 version: pkg.version.to_string(),
75 reason,
76 });
77 None
78 }
79 })
80 .collect();
81
82 let resolve = metadata
84 .resolve
85 .as_ref()
86 .context("cargo metadata did not include a resolve graph")?;
87
88 let mut deps_of: BTreeMap<PackageId, BTreeSet<PackageId>> = BTreeMap::new();
89 let mut dependents_of: BTreeMap<PackageId, BTreeSet<PackageId>> = BTreeMap::new();
90
91 for node in &resolve.nodes {
92 if !publishable.contains(&node.id) {
93 continue;
94 }
95 for dep in &node.deps {
96 if !publishable.contains(&dep.pkg) {
97 continue;
98 }
99
100 let is_relevant = dep
101 .dep_kinds
102 .iter()
103 .any(|k| matches!(k.kind, DependencyKind::Normal | DependencyKind::Build));
104 if !is_relevant {
105 continue;
106 }
107
108 deps_of
109 .entry(node.id.clone())
110 .or_default()
111 .insert(dep.pkg.clone());
112 dependents_of
113 .entry(dep.pkg.clone())
114 .or_default()
115 .insert(node.id.clone());
116 }
117 }
118
119 let included: BTreeSet<PackageId> = if let Some(sel) = &spec.selected_packages {
121 let mut name_to_id: BTreeMap<String, PackageId> = BTreeMap::new();
123 for id in &publishable {
124 let pkg = pkg_map
125 .get(id)
126 .context("workspace package missing from metadata")?;
127 name_to_id.insert(pkg.name.to_string(), id.clone());
128 }
129
130 let mut queue: VecDeque<PackageId> = VecDeque::new();
131 let mut set: BTreeSet<PackageId> = BTreeSet::new();
132
133 for name in sel {
134 let id = name_to_id
135 .get(name)
136 .with_context(|| format!("selected package not found or not publishable: {name}"))?
137 .clone();
138 if set.insert(id.clone()) {
139 queue.push_back(id);
140 }
141 }
142
143 while let Some(id) = queue.pop_front() {
145 if let Some(deps) = deps_of.get(&id) {
146 for dep in deps {
147 if set.insert(dep.clone()) {
148 queue.push_back(dep.clone());
149 }
150 }
151 }
152 }
153
154 set
155 } else {
156 publishable.clone()
157 };
158
159 for node in &resolve.nodes {
161 if !included.contains(&node.id) {
162 continue;
163 }
164 for dep in &node.deps {
165 if publishable.contains(&dep.pkg) || !workspace_ids.contains(&dep.pkg) {
167 continue;
168 }
169 let is_normal_or_build = dep
170 .dep_kinds
171 .iter()
172 .any(|k| matches!(k.kind, DependencyKind::Normal | DependencyKind::Build));
173 if is_normal_or_build {
174 let pkg_name = pkg_map
175 .get(&node.id)
176 .map(|p| p.name.as_str())
177 .unwrap_or("unknown");
178 let dep_name = pkg_map
179 .get(&dep.pkg)
180 .map(|p| p.name.as_str())
181 .unwrap_or("unknown");
182 bail!(
183 "publishable package '{}' depends on non-publishable workspace member '{}'",
184 pkg_name,
185 dep_name
186 );
187 }
188 }
189 }
190
191 let order = topo_sort(&included, &deps_of, &dependents_of, &pkg_map)?;
193
194 let packages: Vec<PlannedPackage> = order
195 .iter()
196 .map(|id| {
197 let pkg = pkg_map.get(id).expect("pkg exists");
198 PlannedPackage {
199 name: pkg.name.to_string(),
200 version: pkg.version.to_string(),
201 manifest_path: pkg.manifest_path.clone().into_std_path_buf(),
202 }
203 })
204 .collect();
205
206 let mut dependencies: BTreeMap<String, Vec<String>> = BTreeMap::new();
208 for id in &order {
209 let pkg = pkg_map.get(id).expect("pkg exists");
210 let pkg_name = pkg.name.to_string();
211
212 let dep_names: Vec<String> = deps_of
214 .get(id)
215 .map(|deps| {
216 deps.iter()
217 .filter_map(|dep_id| {
218 if included.contains(dep_id) {
219 pkg_map.get(dep_id).map(|p| p.name.to_string())
220 } else {
221 None
222 }
223 })
224 .collect()
225 })
226 .unwrap_or_default();
227
228 dependencies.insert(pkg_name, dep_names);
229 }
230
231 let plan_id = compute_plan_id(&spec.registry.api_base, &packages);
232
233 Ok(PlannedWorkspace {
234 workspace_root,
235 plan: ReleasePlan {
236 plan_version: crate::state::execution_state::CURRENT_PLAN_VERSION.to_string(),
237 plan_id,
238 created_at: Utc::now(),
239 registry: spec.registry.clone(),
240 packages,
241 dependencies,
242 },
243 skipped,
244 })
245}
246
247fn load_metadata(manifest_path: &Path) -> Result<Metadata> {
248 crate::ops::cargo::load_metadata(manifest_path)
249}
250
251fn publish_allowed(pkg: &cargo_metadata::Package, registry_name: &str) -> bool {
252 match &pkg.publish {
253 None => true,
254 Some(list) if list.is_empty() => false,
255 Some(list) => {
256 list.iter().any(|r| r == registry_name)
258 }
259 }
260}
261
262fn topo_sort(
263 included: &BTreeSet<PackageId>,
264 deps_of: &BTreeMap<PackageId, BTreeSet<PackageId>>,
265 dependents_of: &BTreeMap<PackageId, BTreeSet<PackageId>>,
266 pkg_map: &BTreeMap<PackageId, &cargo_metadata::Package>,
267) -> Result<Vec<PackageId>> {
268 let mut indegree: BTreeMap<PackageId, usize> = BTreeMap::new();
269 for id in included {
270 let deps = deps_of.get(id).cloned().unwrap_or_default();
271 let count = deps.into_iter().filter(|d| included.contains(d)).count();
272 indegree.insert(id.clone(), count);
273 }
274
275 let mut ready: BTreeSet<(String, PackageId)> = BTreeSet::new();
277 for (id, deg) in &indegree {
278 if *deg == 0 {
279 let name = pkg_map
280 .get(id)
281 .map(|p| p.name.to_string())
282 .unwrap_or_else(|| String::from("unknown"));
283 ready.insert((name, id.clone()));
284 }
285 }
286
287 let mut out: Vec<PackageId> = Vec::with_capacity(included.len());
288
289 while let Some((_, id)) = ready.iter().next().cloned() {
290 ready.remove(&(pkg_map.get(&id).unwrap().name.to_string(), id.clone()));
291 out.push(id.clone());
292
293 if let Some(deps) = dependents_of.get(&id) {
294 for dep in deps {
295 if !included.contains(dep) {
296 continue;
297 }
298 let d = indegree
299 .get_mut(dep)
300 .expect("included package must have indegree");
301 *d = d.saturating_sub(1);
302 if *d == 0 {
303 let name = pkg_map.get(dep).unwrap().name.to_string();
304 ready.insert((name, dep.clone()));
305 }
306 }
307 }
308 }
309
310 if out.len() != included.len() {
311 bail!("dependency cycle detected within workspace publish set");
312 }
313
314 Ok(out)
315}
316
317fn compute_plan_id(registry_api_base: &str, packages: &[PlannedPackage]) -> String {
318 let mut hasher = Sha256::new();
319 hasher.update(registry_api_base.as_bytes());
320 hasher.update(b"\n");
321 for p in packages {
322 hasher.update(p.name.as_bytes());
323 hasher.update(b"@");
324 hasher.update(p.version.as_bytes());
325 hasher.update(b"\n");
326 }
327 let digest = hasher.finalize();
328 hex::encode(digest)
329}
330
331pub(crate) mod chunking;
332pub(crate) mod levels;
333
334#[cfg(test)]
335mod tests {
336 use std::fs;
337 use std::path::{Path, PathBuf};
338
339 use cargo_metadata::{MetadataCommand, PackageId};
340 use proptest::prelude::*;
341 use shipper_types::Registry;
342 use tempfile::tempdir;
343
344 use super::*;
345
346 fn write_file(path: &Path, content: &str) {
347 if let Some(parent) = path.parent() {
348 fs::create_dir_all(parent).expect("mkdir");
349 }
350 fs::write(path, content).expect("write");
351 }
352
353 fn create_workspace(root: &Path) {
354 create_workspace_with_npdep(root, false);
355 }
356
357 fn create_workspace_with_npdep(root: &Path, include_npdep: bool) {
358 let members = if include_npdep {
359 r#"members = ["a", "b", "c", "d", "zeta", "alpha", "npdep"]"#
360 } else {
361 r#"members = ["a", "b", "c", "d", "zeta", "alpha"]"#
362 };
363 write_file(
364 &root.join("Cargo.toml"),
365 &format!(
366 r#"
367[workspace]
368{members}
369resolver = "2"
370"#
371 ),
372 );
373
374 write_file(
375 &root.join("a/Cargo.toml"),
376 r#"
377[package]
378name = "a"
379version = "0.1.0"
380edition = "2021"
381"#,
382 );
383 write_file(&root.join("a/src/lib.rs"), "pub fn a() {}\n");
384
385 write_file(
386 &root.join("b/Cargo.toml"),
387 r#"
388[package]
389name = "b"
390version = "0.1.0"
391edition = "2021"
392
393[dependencies]
394a = { path = "../a", version = "0.1.0" }
395"#,
396 );
397 write_file(&root.join("b/src/lib.rs"), "pub fn b() {}\n");
398
399 write_file(
400 &root.join("c/Cargo.toml"),
401 r#"
402[package]
403name = "c"
404version = "0.1.0"
405edition = "2021"
406publish = false
407"#,
408 );
409 write_file(&root.join("c/src/lib.rs"), "pub fn c() {}\n");
410
411 write_file(
412 &root.join("d/Cargo.toml"),
413 r#"
414[package]
415name = "d"
416version = "0.1.0"
417edition = "2021"
418publish = ["private-reg"]
419"#,
420 );
421 write_file(&root.join("d/src/lib.rs"), "pub fn d() {}\n");
422
423 write_file(
424 &root.join("zeta/Cargo.toml"),
425 r#"
426[package]
427name = "zeta"
428version = "0.1.0"
429edition = "2021"
430"#,
431 );
432 write_file(&root.join("zeta/src/lib.rs"), "pub fn zeta() {}\n");
433
434 write_file(
435 &root.join("alpha/Cargo.toml"),
436 r#"
437[package]
438name = "alpha"
439version = "0.1.0"
440edition = "2021"
441
442[dev-dependencies]
443a = { path = "../a", version = "0.1.0" }
444"#,
445 );
446 write_file(&root.join("alpha/src/lib.rs"), "pub fn alpha() {}\n");
447
448 if include_npdep {
449 write_file(
450 &root.join("npdep/Cargo.toml"),
451 r#"
452[package]
453name = "npdep"
454version = "0.1.0"
455edition = "2021"
456
457[dependencies]
458c = { path = "../c", version = "0.1.0" }
459"#,
460 );
461 write_file(&root.join("npdep/src/lib.rs"), "pub fn npdep() {}\n");
462 }
463 }
464
465 fn spec_for(root: &Path) -> ReleaseSpec {
466 ReleaseSpec {
467 manifest_path: root.join("Cargo.toml"),
468 registry: Registry::crates_io(),
469 selected_packages: None,
470 }
471 }
472
473 #[test]
474 fn build_plan_filters_publishability_and_orders_dependencies() {
475 let td = tempdir().expect("tempdir");
476 create_workspace(td.path());
477
478 let ws = build_plan(&spec_for(td.path())).expect("plan");
479 let names: Vec<String> = ws.plan.packages.iter().map(|p| p.name.clone()).collect();
480
481 assert!(names.contains(&"a".to_string()));
482 assert!(names.contains(&"b".to_string()));
483 assert!(names.contains(&"alpha".to_string()));
484 assert!(names.contains(&"zeta".to_string()));
485 assert!(!names.contains(&"c".to_string()));
486 assert!(!names.contains(&"d".to_string()));
487
488 let a_idx = names.iter().position(|n| n == "a").expect("a present");
489 let b_idx = names.iter().position(|n| n == "b").expect("b present");
490 assert!(a_idx < b_idx);
491 }
492
493 #[test]
494 fn build_plan_rejects_publishable_depending_on_non_publishable() {
495 let td = tempdir().expect("tempdir");
496 create_workspace_with_npdep(td.path(), true);
497
498 let err = build_plan(&spec_for(td.path())).expect_err("must fail");
500 let msg = format!("{err:#}");
501 assert!(
502 msg.contains(
503 "publishable package 'npdep' depends on non-publishable workspace member 'c'"
504 ),
505 "unexpected error: {msg}"
506 );
507
508 let mut spec = spec_for(td.path());
510 spec.selected_packages = Some(vec!["npdep".to_string()]);
511 let err2 = build_plan(&spec).expect_err("must fail for selected npdep");
512 let msg2 = format!("{err2:#}");
513 assert!(
514 msg2.contains(
515 "publishable package 'npdep' depends on non-publishable workspace member 'c'"
516 ),
517 "unexpected error: {msg2}"
518 );
519 }
520
521 #[test]
522 fn build_plan_package_selection_ignores_unrelated_invalid_deps() {
523 let td = tempdir().expect("tempdir");
524 create_workspace_with_npdep(td.path(), true);
525
526 let mut spec = spec_for(td.path());
529 spec.selected_packages = Some(vec!["a".to_string()]);
530 let ws = build_plan(&spec).expect("plan should succeed");
531 let names: Vec<String> = ws.plan.packages.iter().map(|p| p.name.clone()).collect();
532 assert_eq!(names, vec!["a".to_string()]);
533 }
534
535 #[test]
536 fn build_plan_allows_dev_dep_on_non_publishable() {
537 let td = tempdir().expect("tempdir");
538 create_workspace(td.path());
539
540 let ws = build_plan(&spec_for(td.path())).expect("plan");
543 assert!(ws.plan.packages.iter().any(|p| p.name == "alpha"));
544 }
545
546 #[test]
547 fn build_plan_selected_packages_include_internal_dependencies() {
548 let td = tempdir().expect("tempdir");
549 create_workspace(td.path());
550
551 let mut spec = spec_for(td.path());
552 spec.selected_packages = Some(vec!["b".to_string()]);
553 let ws = build_plan(&spec).expect("plan");
554 let names: Vec<String> = ws.plan.packages.iter().map(|p| p.name.clone()).collect();
555 assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
556 }
557
558 #[test]
559 fn build_plan_selected_single_package_does_not_include_dependents() {
560 let td = tempdir().expect("tempdir");
561 create_workspace(td.path());
562
563 let mut spec = spec_for(td.path());
564 spec.selected_packages = Some(vec!["a".to_string()]);
565 let ws = build_plan(&spec).expect("plan");
566 let names: Vec<String> = ws.plan.packages.iter().map(|p| p.name.clone()).collect();
567 assert_eq!(names, vec!["a".to_string()]);
568 }
569
570 #[test]
571 fn build_plan_errors_for_unknown_selected_package() {
572 let td = tempdir().expect("tempdir");
573 create_workspace(td.path());
574
575 let mut spec = spec_for(td.path());
576 spec.selected_packages = Some(vec!["does-not-exist".to_string()]);
577 let err = build_plan(&spec).expect_err("must fail");
578 assert!(format!("{err:#}").contains("selected package not found"));
579 }
580
581 #[test]
582 fn topo_sort_reports_cycles() {
583 let td = tempdir().expect("tempdir");
584 create_workspace(td.path());
585 let manifest = td.path().join("Cargo.toml");
586
587 let metadata = MetadataCommand::new()
588 .manifest_path(&manifest)
589 .exec()
590 .expect("metadata");
591
592 let pkg_map = metadata
593 .packages
594 .iter()
595 .map(|p| (p.id.clone(), p))
596 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
597 let mut by_name = BTreeMap::<String, PackageId>::new();
598 for pkg in &metadata.packages {
599 by_name.insert(pkg.name.to_string(), pkg.id.clone());
600 }
601
602 let a = by_name.get("a").expect("a").clone();
603 let b = by_name.get("b").expect("b").clone();
604
605 let included = [a.clone(), b.clone()].into_iter().collect::<BTreeSet<_>>();
606 let deps_of = BTreeMap::from([
607 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
608 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
609 ]);
610 let dependents_of = BTreeMap::from([
611 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
612 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
613 ]);
614
615 let err = topo_sort(&included, &deps_of, &dependents_of, &pkg_map).expect_err("cycle");
616 assert!(format!("{err:#}").contains("dependency cycle detected"));
617 }
618
619 #[test]
620 fn build_plan_is_deterministic_for_independent_nodes_by_name() {
621 let td = tempdir().expect("tempdir");
622 create_workspace(td.path());
623 let ws = build_plan(&spec_for(td.path())).expect("plan");
624 let alpha_idx = ws
625 .plan
626 .packages
627 .iter()
628 .position(|p| p.name == "alpha")
629 .expect("alpha");
630 let zeta_idx = ws
631 .plan
632 .packages
633 .iter()
634 .position(|p| p.name == "zeta")
635 .expect("zeta");
636 assert!(alpha_idx < zeta_idx);
637 }
638
639 #[test]
640 fn build_plan_errors_for_missing_manifest() {
641 let spec = ReleaseSpec {
642 manifest_path: Path::new("missing").join("Cargo.toml"),
643 registry: Registry::crates_io(),
644 selected_packages: None,
645 };
646 let err = build_plan(&spec).expect_err("must fail");
647 assert!(format!("{err:#}").contains("failed to execute cargo metadata"));
648 }
649
650 fn create_single_crate_workspace(root: &Path) {
653 write_file(
654 &root.join("Cargo.toml"),
655 r#"
656[workspace]
657members = ["only"]
658resolver = "2"
659"#,
660 );
661 write_file(
662 &root.join("only/Cargo.toml"),
663 r#"
664[package]
665name = "only"
666version = "1.2.3"
667edition = "2021"
668"#,
669 );
670 write_file(&root.join("only/src/lib.rs"), "pub fn only() {}\n");
671 }
672
673 #[test]
674 fn build_plan_single_crate_workspace() {
675 let td = tempdir().expect("tempdir");
676 create_single_crate_workspace(td.path());
677
678 let ws = build_plan(&spec_for(td.path())).expect("plan");
679 assert_eq!(ws.plan.packages.len(), 1);
680 assert_eq!(ws.plan.packages[0].name, "only");
681 assert_eq!(ws.plan.packages[0].version, "1.2.3");
682 assert!(ws.skipped.is_empty());
683 assert_eq!(ws.plan.dependencies.get("only").map(|v| v.len()), Some(0));
685 }
686
687 #[test]
690 fn build_plan_deterministic_across_runs() {
691 let td = tempdir().expect("tempdir");
692 create_workspace(td.path());
693 let spec = spec_for(td.path());
694
695 let ws1 = build_plan(&spec).expect("plan1");
696 let ws2 = build_plan(&spec).expect("plan2");
697
698 let names1: Vec<&str> = ws1.plan.packages.iter().map(|p| p.name.as_str()).collect();
699 let names2: Vec<&str> = ws2.plan.packages.iter().map(|p| p.name.as_str()).collect();
700 assert_eq!(names1, names2, "package order must be deterministic");
701 assert_eq!(
702 ws1.plan.plan_id, ws2.plan.plan_id,
703 "plan_id must be deterministic"
704 );
705 assert_eq!(ws1.plan.dependencies, ws2.plan.dependencies);
706 }
707
708 #[test]
711 fn build_plan_tracks_skipped_packages() {
712 let td = tempdir().expect("tempdir");
713 create_workspace(td.path());
714
715 let ws = build_plan(&spec_for(td.path())).expect("plan");
716 let skipped_names: Vec<&str> = ws.skipped.iter().map(|s| s.name.as_str()).collect();
717 assert!(
719 skipped_names.contains(&"c"),
720 "c should be skipped (publish=false)"
721 );
722 assert!(
723 skipped_names.contains(&"d"),
724 "d should be skipped (wrong registry)"
725 );
726 assert_eq!(ws.skipped.len(), 2);
727 }
728
729 #[test]
732 fn build_plan_includes_crate_when_registry_matches() {
733 let td = tempdir().expect("tempdir");
734 create_workspace(td.path());
735
736 let spec = ReleaseSpec {
737 manifest_path: td.path().join("Cargo.toml"),
738 registry: Registry {
739 name: "private-reg".to_string(),
740 api_base: "https://private.example.com".to_string(),
741 index_base: None,
742 },
743 selected_packages: None,
744 };
745 let ws = build_plan(&spec).expect("plan");
746 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
747 assert!(names.contains(&"d"));
749 assert!(!names.contains(&"c"));
751 }
752
753 #[test]
756 fn build_plan_dependencies_map_reflects_edges() {
757 let td = tempdir().expect("tempdir");
758 create_workspace(td.path());
759
760 let ws = build_plan(&spec_for(td.path())).expect("plan");
761 let b_deps = ws.plan.dependencies.get("b").expect("b in deps map");
763 assert!(b_deps.contains(&"a".to_string()));
764 let a_deps = ws.plan.dependencies.get("a").expect("a in deps map");
766 assert!(a_deps.is_empty());
767 let alpha_deps = ws
769 .plan
770 .dependencies
771 .get("alpha")
772 .expect("alpha in deps map");
773 assert!(
774 alpha_deps.is_empty(),
775 "dev-deps should not appear in plan deps"
776 );
777 }
778
779 #[test]
782 fn build_plan_sets_correct_plan_version() {
783 let td = tempdir().expect("tempdir");
784 create_single_crate_workspace(td.path());
785
786 let ws = build_plan(&spec_for(td.path())).expect("plan");
787 assert_eq!(
788 ws.plan.plan_version,
789 crate::state::execution_state::CURRENT_PLAN_VERSION
790 );
791 }
792
793 #[test]
796 fn publish_allowed_none_allows_all() {
797 let td = tempdir().expect("tempdir");
798 create_single_crate_workspace(td.path());
799 let metadata = MetadataCommand::new()
800 .manifest_path(td.path().join("Cargo.toml"))
801 .exec()
802 .expect("metadata");
803 let pkg = metadata
805 .packages
806 .iter()
807 .find(|p| p.name == "only")
808 .expect("only");
809 assert!(publish_allowed(pkg, "crates-io"));
810 assert!(publish_allowed(pkg, "some-other-reg"));
811 }
812
813 #[test]
814 fn publish_allowed_false_blocks_all() {
815 let td = tempdir().expect("tempdir");
816 create_workspace(td.path());
817 let metadata = MetadataCommand::new()
818 .manifest_path(td.path().join("Cargo.toml"))
819 .exec()
820 .expect("metadata");
821 let pkg = metadata.packages.iter().find(|p| p.name == "c").expect("c");
823 assert!(!publish_allowed(pkg, "crates-io"));
824 assert!(!publish_allowed(pkg, "private-reg"));
825 }
826
827 #[test]
828 fn publish_allowed_list_matches_registry() {
829 let td = tempdir().expect("tempdir");
830 create_workspace(td.path());
831 let metadata = MetadataCommand::new()
832 .manifest_path(td.path().join("Cargo.toml"))
833 .exec()
834 .expect("metadata");
835 let pkg = metadata.packages.iter().find(|p| p.name == "d").expect("d");
837 assert!(publish_allowed(pkg, "private-reg"));
838 assert!(!publish_allowed(pkg, "crates-io"));
839 }
840
841 #[test]
844 fn compute_plan_id_differs_for_different_packages() {
845 let pkgs_a = vec![PlannedPackage {
846 name: "foo".to_string(),
847 version: "1.0.0".to_string(),
848 manifest_path: PathBuf::from("foo/Cargo.toml"),
849 }];
850 let pkgs_b = vec![PlannedPackage {
851 name: "bar".to_string(),
852 version: "1.0.0".to_string(),
853 manifest_path: PathBuf::from("bar/Cargo.toml"),
854 }];
855 let id_a = compute_plan_id("https://crates.io", &pkgs_a);
856 let id_b = compute_plan_id("https://crates.io", &pkgs_b);
857 assert_ne!(id_a, id_b);
858 }
859
860 #[test]
861 fn compute_plan_id_differs_for_different_registries() {
862 let pkgs = vec![PlannedPackage {
863 name: "foo".to_string(),
864 version: "1.0.0".to_string(),
865 manifest_path: PathBuf::from("foo/Cargo.toml"),
866 }];
867 let id1 = compute_plan_id("https://crates.io", &pkgs);
868 let id2 = compute_plan_id("https://private.example.com", &pkgs);
869 assert_ne!(id1, id2);
870 }
871
872 #[test]
873 fn compute_plan_id_differs_for_different_versions() {
874 let pkgs1 = vec![PlannedPackage {
875 name: "foo".to_string(),
876 version: "1.0.0".to_string(),
877 manifest_path: PathBuf::from("foo/Cargo.toml"),
878 }];
879 let pkgs2 = vec![PlannedPackage {
880 name: "foo".to_string(),
881 version: "2.0.0".to_string(),
882 manifest_path: PathBuf::from("foo/Cargo.toml"),
883 }];
884 let id1 = compute_plan_id("https://crates.io", &pkgs1);
885 let id2 = compute_plan_id("https://crates.io", &pkgs2);
886 assert_ne!(id1, id2);
887 }
888
889 #[test]
890 fn compute_plan_id_empty_packages() {
891 let id = compute_plan_id("https://crates.io", &[]);
892 assert_eq!(id.len(), 64);
893 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
894 }
895
896 #[test]
899 fn build_plan_sets_workspace_root() {
900 let td = tempdir().expect("tempdir");
901 create_single_crate_workspace(td.path());
902
903 let ws = build_plan(&spec_for(td.path())).expect("plan");
904 assert!(ws.workspace_root.exists());
906 }
907
908 #[test]
911 fn topo_sort_independent_nodes_sorted_by_name() {
912 let td = tempdir().expect("tempdir");
913 create_workspace(td.path());
914 let metadata = MetadataCommand::new()
915 .manifest_path(td.path().join("Cargo.toml"))
916 .exec()
917 .expect("metadata");
918
919 let pkg_map = metadata
920 .packages
921 .iter()
922 .map(|p| (p.id.clone(), p))
923 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
924 let mut by_name = BTreeMap::<String, PackageId>::new();
925 for pkg in &metadata.packages {
926 by_name.insert(pkg.name.to_string(), pkg.id.clone());
927 }
928
929 let alpha = by_name.get("alpha").expect("alpha").clone();
930 let zeta = by_name.get("zeta").expect("zeta").clone();
931
932 let included = [alpha.clone(), zeta.clone()]
934 .into_iter()
935 .collect::<BTreeSet<_>>();
936 let deps_of = BTreeMap::new();
937 let dependents_of = BTreeMap::new();
938
939 let order = topo_sort(&included, &deps_of, &dependents_of, &pkg_map).expect("topo");
940 let names: Vec<&str> = order
941 .iter()
942 .map(|id| pkg_map.get(id).unwrap().name.as_str())
943 .collect();
944 assert_eq!(
945 names,
946 vec!["alpha", "zeta"],
947 "independent nodes sorted alphabetically"
948 );
949 }
950
951 #[test]
954 fn build_plan_deep_dependency_chain() {
955 let td = tempdir().expect("tempdir");
956 write_file(
957 &td.path().join("Cargo.toml"),
958 r#"
959[workspace]
960members = ["x", "y", "z"]
961resolver = "2"
962"#,
963 );
964 write_file(
965 &td.path().join("x/Cargo.toml"),
966 r#"
967[package]
968name = "x"
969version = "0.1.0"
970edition = "2021"
971"#,
972 );
973 write_file(&td.path().join("x/src/lib.rs"), "");
974 write_file(
975 &td.path().join("y/Cargo.toml"),
976 r#"
977[package]
978name = "y"
979version = "0.1.0"
980edition = "2021"
981
982[dependencies]
983x = { path = "../x", version = "0.1.0" }
984"#,
985 );
986 write_file(&td.path().join("y/src/lib.rs"), "");
987 write_file(
988 &td.path().join("z/Cargo.toml"),
989 r#"
990[package]
991name = "z"
992version = "0.1.0"
993edition = "2021"
994
995[dependencies]
996y = { path = "../y", version = "0.1.0" }
997"#,
998 );
999 write_file(&td.path().join("z/src/lib.rs"), "");
1000
1001 let ws = build_plan(&spec_for(td.path())).expect("plan");
1002 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1003 assert_eq!(names, vec!["x", "y", "z"]);
1004
1005 assert!(ws.plan.dependencies["x"].is_empty());
1007 assert_eq!(ws.plan.dependencies["y"], vec!["x".to_string()]);
1008 assert_eq!(ws.plan.dependencies["z"], vec!["y".to_string()]);
1009 }
1010
1011 #[test]
1014 fn build_plan_all_unpublishable_produces_empty_plan() {
1015 let td = tempdir().expect("tempdir");
1016 write_file(
1017 &td.path().join("Cargo.toml"),
1018 r#"
1019[workspace]
1020members = ["priv"]
1021resolver = "2"
1022"#,
1023 );
1024 write_file(
1025 &td.path().join("priv/Cargo.toml"),
1026 r#"
1027[package]
1028name = "priv"
1029version = "0.1.0"
1030edition = "2021"
1031publish = false
1032"#,
1033 );
1034 write_file(&td.path().join("priv/src/lib.rs"), "");
1035
1036 let ws = build_plan(&spec_for(td.path())).expect("plan");
1037 assert!(ws.plan.packages.is_empty());
1038 assert_eq!(ws.skipped.len(), 1);
1039 assert_eq!(ws.skipped[0].name, "priv");
1040 }
1041
1042 #[test]
1045 fn build_plan_selecting_non_publishable_package_errors() {
1046 let td = tempdir().expect("tempdir");
1047 create_workspace(td.path());
1048
1049 let mut spec = spec_for(td.path());
1050 spec.selected_packages = Some(vec!["c".to_string()]);
1052 let err = build_plan(&spec).expect_err("must fail");
1053 assert!(format!("{err:#}").contains("selected package not found or not publishable"));
1054 }
1055
1056 #[test]
1059 fn build_plan_registry_in_output_matches_spec() {
1060 let td = tempdir().expect("tempdir");
1061 create_single_crate_workspace(td.path());
1062
1063 let ws = build_plan(&spec_for(td.path())).expect("plan");
1064 assert_eq!(ws.plan.registry.name, "crates-io");
1065 assert_eq!(ws.plan.registry.api_base, "https://crates.io");
1066 }
1067
1068 #[derive(serde::Serialize)]
1075 struct PlanSnapshot {
1076 packages: Vec<PkgSnapshot>,
1077 dependencies: std::collections::BTreeMap<String, Vec<String>>,
1078 skipped: Vec<SkippedPackage>,
1079 registry_name: String,
1080 }
1081
1082 #[derive(serde::Serialize)]
1083 struct PkgSnapshot {
1084 name: String,
1085 version: String,
1086 }
1087
1088 fn snapshot_of(ws: &PlannedWorkspace) -> PlanSnapshot {
1089 PlanSnapshot {
1090 packages: ws
1091 .plan
1092 .packages
1093 .iter()
1094 .map(|p| PkgSnapshot {
1095 name: p.name.clone(),
1096 version: p.version.clone(),
1097 })
1098 .collect(),
1099 dependencies: ws.plan.dependencies.clone(),
1100 skipped: ws.skipped.clone(),
1101 registry_name: ws.plan.registry.name.clone(),
1102 }
1103 }
1104
1105 #[test]
1108 fn snapshot_single_crate_plan() {
1109 let td = tempdir().expect("tempdir");
1110 create_single_crate_workspace(td.path());
1111
1112 let ws = build_plan(&spec_for(td.path())).expect("plan");
1113 insta::assert_yaml_snapshot!("single_crate_plan", snapshot_of(&ws));
1114 }
1115
1116 #[test]
1117 fn snapshot_multi_crate_plan_with_deps() {
1118 let td = tempdir().expect("tempdir");
1119 create_workspace(td.path());
1120
1121 let ws = build_plan(&spec_for(td.path())).expect("plan");
1122 insta::assert_yaml_snapshot!("multi_crate_plan_with_deps", snapshot_of(&ws));
1123 }
1124
1125 #[test]
1126 fn snapshot_deep_chain_plan() {
1127 let td = tempdir().expect("tempdir");
1128 write_file(
1129 &td.path().join("Cargo.toml"),
1130 r#"
1131[workspace]
1132members = ["x", "y", "z"]
1133resolver = "2"
1134"#,
1135 );
1136 write_file(
1137 &td.path().join("x/Cargo.toml"),
1138 r#"
1139[package]
1140name = "x"
1141version = "0.1.0"
1142edition = "2021"
1143"#,
1144 );
1145 write_file(&td.path().join("x/src/lib.rs"), "");
1146 write_file(
1147 &td.path().join("y/Cargo.toml"),
1148 r#"
1149[package]
1150name = "y"
1151version = "0.1.0"
1152edition = "2021"
1153
1154[dependencies]
1155x = { path = "../x", version = "0.1.0" }
1156"#,
1157 );
1158 write_file(&td.path().join("y/src/lib.rs"), "");
1159 write_file(
1160 &td.path().join("z/Cargo.toml"),
1161 r#"
1162[package]
1163name = "z"
1164version = "0.1.0"
1165edition = "2021"
1166
1167[dependencies]
1168y = { path = "../y", version = "0.1.0" }
1169"#,
1170 );
1171 write_file(&td.path().join("z/src/lib.rs"), "");
1172
1173 let ws = build_plan(&spec_for(td.path())).expect("plan");
1174 insta::assert_yaml_snapshot!("deep_chain_plan", snapshot_of(&ws));
1175 }
1176
1177 #[test]
1178 fn snapshot_package_selection() {
1179 let td = tempdir().expect("tempdir");
1180 create_workspace(td.path());
1181
1182 let mut spec = spec_for(td.path());
1183 spec.selected_packages = Some(vec!["b".to_string()]);
1184 let ws = build_plan(&spec).expect("plan");
1185 insta::assert_yaml_snapshot!("package_selection_b", snapshot_of(&ws));
1186 }
1187
1188 #[test]
1189 fn snapshot_error_unknown_package() {
1190 let td = tempdir().expect("tempdir");
1191 create_workspace(td.path());
1192
1193 let mut spec = spec_for(td.path());
1194 spec.selected_packages = Some(vec!["does-not-exist".to_string()]);
1195 let err = build_plan(&spec).expect_err("must fail");
1196 insta::assert_snapshot!("error_unknown_package", format!("{err:#}"));
1197 }
1198
1199 #[test]
1200 fn snapshot_error_non_publishable_dep() {
1201 let td = tempdir().expect("tempdir");
1202 create_workspace_with_npdep(td.path(), true);
1203
1204 let err = build_plan(&spec_for(td.path())).expect_err("must fail");
1205 insta::assert_snapshot!("error_non_publishable_dep", format!("{err:#}"));
1206 }
1207
1208 #[test]
1209 fn snapshot_error_selecting_non_publishable() {
1210 let td = tempdir().expect("tempdir");
1211 create_workspace(td.path());
1212
1213 let mut spec = spec_for(td.path());
1214 spec.selected_packages = Some(vec!["c".to_string()]);
1215 let err = build_plan(&spec).expect_err("must fail");
1216 insta::assert_snapshot!("error_selecting_non_publishable", format!("{err:#}"));
1217 }
1218
1219 #[test]
1220 fn snapshot_error_cycle_detection() {
1221 let td = tempdir().expect("tempdir");
1222 create_workspace(td.path());
1223 let metadata = MetadataCommand::new()
1224 .manifest_path(td.path().join("Cargo.toml"))
1225 .exec()
1226 .expect("metadata");
1227
1228 let pkg_map = metadata
1229 .packages
1230 .iter()
1231 .map(|p| (p.id.clone(), p))
1232 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
1233 let mut by_name = BTreeMap::<String, PackageId>::new();
1234 for pkg in &metadata.packages {
1235 by_name.insert(pkg.name.to_string(), pkg.id.clone());
1236 }
1237
1238 let a = by_name.get("a").expect("a").clone();
1239 let b = by_name.get("b").expect("b").clone();
1240
1241 let included = [a.clone(), b.clone()].into_iter().collect::<BTreeSet<_>>();
1242 let deps_of = BTreeMap::from([
1243 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
1244 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
1245 ]);
1246 let dependents_of = BTreeMap::from([
1247 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
1248 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
1249 ]);
1250
1251 let err = topo_sort(&included, &deps_of, &dependents_of, &pkg_map).expect_err("cycle");
1252 insta::assert_snapshot!("error_cycle_detection", format!("{err:#}"));
1253 }
1254
1255 #[test]
1256 fn snapshot_plan_summary_display() {
1257 let td = tempdir().expect("tempdir");
1258 create_workspace(td.path());
1259
1260 let ws = build_plan(&spec_for(td.path())).expect("plan");
1261 let mut summary = String::new();
1262 summary.push_str(&format!("Registry: {}\n", ws.plan.registry.name));
1263 summary.push_str(&format!(
1264 "Packages to publish ({}):\n",
1265 ws.plan.packages.len()
1266 ));
1267 for (i, pkg) in ws.plan.packages.iter().enumerate() {
1268 let deps = ws
1269 .plan
1270 .dependencies
1271 .get(&pkg.name)
1272 .cloned()
1273 .unwrap_or_default();
1274 if deps.is_empty() {
1275 summary.push_str(&format!(" {}. {} v{}\n", i + 1, pkg.name, pkg.version));
1276 } else {
1277 summary.push_str(&format!(
1278 " {}. {} v{} (depends on: {})\n",
1279 i + 1,
1280 pkg.name,
1281 pkg.version,
1282 deps.join(", ")
1283 ));
1284 }
1285 }
1286 if !ws.skipped.is_empty() {
1287 summary.push_str(&format!("Skipped ({}):\n", ws.skipped.len()));
1288 for s in &ws.skipped {
1289 summary.push_str(&format!(" - {} v{}: {}\n", s.name, s.version, s.reason));
1290 }
1291 }
1292 insta::assert_snapshot!("plan_summary_display", summary);
1293 }
1294
1295 #[test]
1296 fn snapshot_skipped_packages_detail() {
1297 let td = tempdir().expect("tempdir");
1298 create_workspace(td.path());
1299
1300 let ws = build_plan(&spec_for(td.path())).expect("plan");
1301 insta::assert_yaml_snapshot!("skipped_packages_detail", &ws.skipped);
1302 }
1303
1304 #[test]
1307 fn build_plan_empty_workspace_all_unpublishable() {
1308 let td = tempdir().expect("tempdir");
1309 write_file(
1310 &td.path().join("Cargo.toml"),
1311 r#"
1312[workspace]
1313members = ["internal-a", "internal-b"]
1314resolver = "2"
1315"#,
1316 );
1317 write_file(
1318 &td.path().join("internal-a/Cargo.toml"),
1319 r#"
1320[package]
1321name = "internal-a"
1322version = "0.1.0"
1323edition = "2021"
1324publish = false
1325"#,
1326 );
1327 write_file(&td.path().join("internal-a/src/lib.rs"), "");
1328 write_file(
1329 &td.path().join("internal-b/Cargo.toml"),
1330 r#"
1331[package]
1332name = "internal-b"
1333version = "0.1.0"
1334edition = "2021"
1335publish = false
1336"#,
1337 );
1338 write_file(&td.path().join("internal-b/src/lib.rs"), "");
1339
1340 let ws = build_plan(&spec_for(td.path())).expect("plan");
1341 assert!(ws.plan.packages.is_empty());
1342 assert_eq!(ws.skipped.len(), 2);
1343 assert!(ws.plan.dependencies.is_empty());
1344 }
1345
1346 fn create_linear_chain_workspace(root: &Path) {
1349 write_file(
1350 &root.join("Cargo.toml"),
1351 r#"
1352[workspace]
1353members = ["chain-a", "chain-b", "chain-c", "chain-d"]
1354resolver = "2"
1355"#,
1356 );
1357 write_file(
1358 &root.join("chain-d/Cargo.toml"),
1359 r#"
1360[package]
1361name = "chain-d"
1362version = "0.1.0"
1363edition = "2021"
1364"#,
1365 );
1366 write_file(&root.join("chain-d/src/lib.rs"), "");
1367 write_file(
1368 &root.join("chain-c/Cargo.toml"),
1369 r#"
1370[package]
1371name = "chain-c"
1372version = "0.1.0"
1373edition = "2021"
1374
1375[dependencies]
1376chain-d = { path = "../chain-d", version = "0.1.0" }
1377"#,
1378 );
1379 write_file(&root.join("chain-c/src/lib.rs"), "");
1380 write_file(
1381 &root.join("chain-b/Cargo.toml"),
1382 r#"
1383[package]
1384name = "chain-b"
1385version = "0.1.0"
1386edition = "2021"
1387
1388[dependencies]
1389chain-c = { path = "../chain-c", version = "0.1.0" }
1390"#,
1391 );
1392 write_file(&root.join("chain-b/src/lib.rs"), "");
1393 write_file(
1394 &root.join("chain-a/Cargo.toml"),
1395 r#"
1396[package]
1397name = "chain-a"
1398version = "0.1.0"
1399edition = "2021"
1400
1401[dependencies]
1402chain-b = { path = "../chain-b", version = "0.1.0" }
1403"#,
1404 );
1405 write_file(&root.join("chain-a/src/lib.rs"), "");
1406 }
1407
1408 #[test]
1409 fn build_plan_linear_chain_a_b_c_d() {
1410 let td = tempdir().expect("tempdir");
1411 create_linear_chain_workspace(td.path());
1412
1413 let ws = build_plan(&spec_for(td.path())).expect("plan");
1414 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1415 assert_eq!(names, vec!["chain-d", "chain-c", "chain-b", "chain-a"]);
1416
1417 assert!(ws.plan.dependencies["chain-d"].is_empty());
1419 assert_eq!(ws.plan.dependencies["chain-c"], vec!["chain-d".to_string()]);
1420 assert_eq!(ws.plan.dependencies["chain-b"], vec!["chain-c".to_string()]);
1421 assert_eq!(ws.plan.dependencies["chain-a"], vec!["chain-b".to_string()]);
1422 }
1423
1424 #[test]
1425 fn build_plan_linear_chain_selecting_middle_pulls_transitive_deps() {
1426 let td = tempdir().expect("tempdir");
1427 create_linear_chain_workspace(td.path());
1428
1429 let mut spec = spec_for(td.path());
1430 spec.selected_packages = Some(vec!["chain-b".to_string()]);
1431 let ws = build_plan(&spec).expect("plan");
1432 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1433 assert_eq!(names, vec!["chain-d", "chain-c", "chain-b"]);
1435 }
1436
1437 fn create_diamond_workspace(root: &Path) {
1440 write_file(
1441 &root.join("Cargo.toml"),
1442 r#"
1443[workspace]
1444members = ["diamond-a", "diamond-b", "diamond-c", "diamond-d"]
1445resolver = "2"
1446"#,
1447 );
1448 write_file(
1449 &root.join("diamond-d/Cargo.toml"),
1450 r#"
1451[package]
1452name = "diamond-d"
1453version = "0.1.0"
1454edition = "2021"
1455"#,
1456 );
1457 write_file(&root.join("diamond-d/src/lib.rs"), "");
1458 write_file(
1459 &root.join("diamond-b/Cargo.toml"),
1460 r#"
1461[package]
1462name = "diamond-b"
1463version = "0.1.0"
1464edition = "2021"
1465
1466[dependencies]
1467diamond-d = { path = "../diamond-d", version = "0.1.0" }
1468"#,
1469 );
1470 write_file(&root.join("diamond-b/src/lib.rs"), "");
1471 write_file(
1472 &root.join("diamond-c/Cargo.toml"),
1473 r#"
1474[package]
1475name = "diamond-c"
1476version = "0.1.0"
1477edition = "2021"
1478
1479[dependencies]
1480diamond-d = { path = "../diamond-d", version = "0.1.0" }
1481"#,
1482 );
1483 write_file(&root.join("diamond-c/src/lib.rs"), "");
1484 write_file(
1485 &root.join("diamond-a/Cargo.toml"),
1486 r#"
1487[package]
1488name = "diamond-a"
1489version = "0.1.0"
1490edition = "2021"
1491
1492[dependencies]
1493diamond-b = { path = "../diamond-b", version = "0.1.0" }
1494diamond-c = { path = "../diamond-c", version = "0.1.0" }
1495"#,
1496 );
1497 write_file(&root.join("diamond-a/src/lib.rs"), "");
1498 }
1499
1500 #[test]
1501 fn build_plan_diamond_dependency() {
1502 let td = tempdir().expect("tempdir");
1503 create_diamond_workspace(td.path());
1504
1505 let ws = build_plan(&spec_for(td.path())).expect("plan");
1506 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1507
1508 assert_eq!(
1510 names,
1511 vec!["diamond-d", "diamond-b", "diamond-c", "diamond-a"]
1512 );
1513
1514 assert!(ws.plan.dependencies["diamond-d"].is_empty());
1516 assert_eq!(
1517 ws.plan.dependencies["diamond-b"],
1518 vec!["diamond-d".to_string()]
1519 );
1520 assert_eq!(
1521 ws.plan.dependencies["diamond-c"],
1522 vec!["diamond-d".to_string()]
1523 );
1524 let mut a_deps = ws.plan.dependencies["diamond-a"].clone();
1525 a_deps.sort();
1526 assert_eq!(
1527 a_deps,
1528 vec!["diamond-b".to_string(), "diamond-c".to_string()]
1529 );
1530 }
1531
1532 #[test]
1533 fn build_plan_diamond_all_deps_before_dependents() {
1534 let td = tempdir().expect("tempdir");
1535 create_diamond_workspace(td.path());
1536
1537 let ws = build_plan(&spec_for(td.path())).expect("plan");
1538 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1539 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
1540
1541 assert!(pos("diamond-d") < pos("diamond-b"));
1543 assert!(pos("diamond-d") < pos("diamond-c"));
1544 assert!(pos("diamond-b") < pos("diamond-a"));
1545 assert!(pos("diamond-c") < pos("diamond-a"));
1546 }
1547
1548 fn create_wide_flat_workspace(root: &Path, count: usize) {
1551 let members: Vec<String> = (0..count).map(|i| format!("\"pkg-{i:02}\"")).collect();
1552 write_file(
1553 &root.join("Cargo.toml"),
1554 &format!(
1555 r#"
1556[workspace]
1557members = [{members}]
1558resolver = "2"
1559"#,
1560 members = members.join(", ")
1561 ),
1562 );
1563 for i in 0..count {
1564 let name = format!("pkg-{i:02}");
1565 write_file(
1566 &root.join(format!("{name}/Cargo.toml")),
1567 &format!(
1568 r#"
1569[package]
1570name = "{name}"
1571version = "0.1.0"
1572edition = "2021"
1573"#
1574 ),
1575 );
1576 write_file(&root.join(format!("{name}/src/lib.rs")), "");
1577 }
1578 }
1579
1580 #[test]
1581 fn build_plan_wide_flat_workspace_20_packages() {
1582 let td = tempdir().expect("tempdir");
1583 create_wide_flat_workspace(td.path(), 20);
1584
1585 let ws = build_plan(&spec_for(td.path())).expect("plan");
1586 assert_eq!(ws.plan.packages.len(), 20);
1587 assert!(ws.skipped.is_empty());
1588
1589 for pkg in &ws.plan.packages {
1591 let deps = ws.plan.dependencies.get(&pkg.name).expect("in deps map");
1592 assert!(deps.is_empty(), "{} should have no deps", pkg.name);
1593 }
1594
1595 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1597 let mut sorted_names = names.clone();
1598 sorted_names.sort();
1599 assert_eq!(names, sorted_names, "independent packages sorted by name");
1600 }
1601
1602 fn create_special_names_workspace(root: &Path) {
1605 write_file(
1606 &root.join("Cargo.toml"),
1607 r#"
1608[workspace]
1609members = ["my-hyphen-pkg", "my_underscore_pkg", "a-b_c-d_e"]
1610resolver = "2"
1611"#,
1612 );
1613 write_file(
1614 &root.join("my-hyphen-pkg/Cargo.toml"),
1615 r#"
1616[package]
1617name = "my-hyphen-pkg"
1618version = "0.1.0"
1619edition = "2021"
1620"#,
1621 );
1622 write_file(&root.join("my-hyphen-pkg/src/lib.rs"), "");
1623 write_file(
1624 &root.join("my_underscore_pkg/Cargo.toml"),
1625 r#"
1626[package]
1627name = "my_underscore_pkg"
1628version = "0.1.0"
1629edition = "2021"
1630
1631[dependencies]
1632my-hyphen-pkg = { path = "../my-hyphen-pkg", version = "0.1.0" }
1633"#,
1634 );
1635 write_file(&root.join("my_underscore_pkg/src/lib.rs"), "");
1636 write_file(
1637 &root.join("a-b_c-d_e/Cargo.toml"),
1638 r#"
1639[package]
1640name = "a-b_c-d_e"
1641version = "0.2.0"
1642edition = "2021"
1643"#,
1644 );
1645 write_file(&root.join("a-b_c-d_e/src/lib.rs"), "");
1646 }
1647
1648 #[test]
1649 fn build_plan_special_character_names() {
1650 let td = tempdir().expect("tempdir");
1651 create_special_names_workspace(td.path());
1652
1653 let ws = build_plan(&spec_for(td.path())).expect("plan");
1654 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1655
1656 assert!(names.contains(&"my-hyphen-pkg"));
1657 assert!(names.contains(&"my_underscore_pkg"));
1658 assert!(names.contains(&"a-b_c-d_e"));
1659 assert_eq!(ws.plan.packages.len(), 3);
1660
1661 let hyphen_idx = names.iter().position(|n| *n == "my-hyphen-pkg").unwrap();
1663 let underscore_idx = names
1664 .iter()
1665 .position(|n| *n == "my_underscore_pkg")
1666 .unwrap();
1667 assert!(hyphen_idx < underscore_idx);
1668 }
1669
1670 #[test]
1671 fn build_plan_special_names_dependency_map() {
1672 let td = tempdir().expect("tempdir");
1673 create_special_names_workspace(td.path());
1674
1675 let ws = build_plan(&spec_for(td.path())).expect("plan");
1676 assert!(ws.plan.dependencies["my-hyphen-pkg"].is_empty());
1677 assert_eq!(
1678 ws.plan.dependencies["my_underscore_pkg"],
1679 vec!["my-hyphen-pkg".to_string()]
1680 );
1681 assert!(ws.plan.dependencies["a-b_c-d_e"].is_empty());
1682 }
1683
1684 #[test]
1687 fn snapshot_linear_chain_plan() {
1688 let td = tempdir().expect("tempdir");
1689 create_linear_chain_workspace(td.path());
1690
1691 let ws = build_plan(&spec_for(td.path())).expect("plan");
1692 insta::assert_yaml_snapshot!("linear_chain_plan", snapshot_of(&ws));
1693 }
1694
1695 #[test]
1696 fn snapshot_diamond_plan() {
1697 let td = tempdir().expect("tempdir");
1698 create_diamond_workspace(td.path());
1699
1700 let ws = build_plan(&spec_for(td.path())).expect("plan");
1701 insta::assert_yaml_snapshot!("diamond_plan", snapshot_of(&ws));
1702 }
1703
1704 #[test]
1705 fn snapshot_wide_flat_plan() {
1706 let td = tempdir().expect("tempdir");
1707 create_wide_flat_workspace(td.path(), 5);
1708
1709 let ws = build_plan(&spec_for(td.path())).expect("plan");
1710 insta::assert_yaml_snapshot!("wide_flat_plan_5", snapshot_of(&ws));
1711 }
1712
1713 #[test]
1714 fn snapshot_special_names_plan() {
1715 let td = tempdir().expect("tempdir");
1716 create_special_names_workspace(td.path());
1717
1718 let ws = build_plan(&spec_for(td.path())).expect("plan");
1719 insta::assert_yaml_snapshot!("special_names_plan", snapshot_of(&ws));
1720 }
1721
1722 #[test]
1723 fn snapshot_empty_workspace_plan() {
1724 let td = tempdir().expect("tempdir");
1725 write_file(
1726 &td.path().join("Cargo.toml"),
1727 r#"
1728[workspace]
1729members = ["priv-a", "priv-b"]
1730resolver = "2"
1731"#,
1732 );
1733 write_file(
1734 &td.path().join("priv-a/Cargo.toml"),
1735 r#"
1736[package]
1737name = "priv-a"
1738version = "0.1.0"
1739edition = "2021"
1740publish = false
1741"#,
1742 );
1743 write_file(&td.path().join("priv-a/src/lib.rs"), "");
1744 write_file(
1745 &td.path().join("priv-b/Cargo.toml"),
1746 r#"
1747[package]
1748name = "priv-b"
1749version = "0.1.0"
1750edition = "2021"
1751publish = false
1752"#,
1753 );
1754 write_file(&td.path().join("priv-b/src/lib.rs"), "");
1755
1756 let ws = build_plan(&spec_for(td.path())).expect("plan");
1757 insta::assert_yaml_snapshot!("empty_workspace_plan", snapshot_of(&ws));
1758 }
1759
1760 #[test]
1763 fn plan_stability_diamond_10_runs() {
1764 let td = tempdir().expect("tempdir");
1765 create_diamond_workspace(td.path());
1766 let spec = spec_for(td.path());
1767
1768 let baseline = build_plan(&spec).expect("plan");
1769 let baseline_names: Vec<&str> = baseline
1770 .plan
1771 .packages
1772 .iter()
1773 .map(|p| p.name.as_str())
1774 .collect();
1775 let baseline_id = &baseline.plan.plan_id;
1776
1777 for _ in 0..10 {
1778 let ws = build_plan(&spec).expect("plan");
1779 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1780 assert_eq!(names, baseline_names, "order must be stable across runs");
1781 assert_eq!(&ws.plan.plan_id, baseline_id, "plan_id must be stable");
1782 }
1783 }
1784
1785 #[test]
1786 fn plan_stability_linear_chain_10_runs() {
1787 let td = tempdir().expect("tempdir");
1788 create_linear_chain_workspace(td.path());
1789 let spec = spec_for(td.path());
1790
1791 let baseline = build_plan(&spec).expect("plan");
1792 let baseline_names: Vec<&str> = baseline
1793 .plan
1794 .packages
1795 .iter()
1796 .map(|p| p.name.as_str())
1797 .collect();
1798
1799 for _ in 0..10 {
1800 let ws = build_plan(&spec).expect("plan");
1801 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1802 assert_eq!(names, baseline_names);
1803 }
1804 }
1805
1806 #[test]
1807 fn plan_stability_wide_flat_10_runs() {
1808 let td = tempdir().expect("tempdir");
1809 create_wide_flat_workspace(td.path(), 10);
1810 let spec = spec_for(td.path());
1811
1812 let baseline = build_plan(&spec).expect("plan");
1813 let baseline_names: Vec<&str> = baseline
1814 .plan
1815 .packages
1816 .iter()
1817 .map(|p| p.name.as_str())
1818 .collect();
1819
1820 for _ in 0..10 {
1821 let ws = build_plan(&spec).expect("plan");
1822 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1823 assert_eq!(names, baseline_names);
1824 }
1825 }
1826
1827 fn create_build_dep_workspace(root: &Path) {
1830 write_file(
1831 &root.join("Cargo.toml"),
1832 r#"
1833[workspace]
1834members = ["codegen", "app"]
1835resolver = "2"
1836"#,
1837 );
1838 write_file(
1839 &root.join("codegen/Cargo.toml"),
1840 r#"
1841[package]
1842name = "codegen"
1843version = "0.1.0"
1844edition = "2021"
1845"#,
1846 );
1847 write_file(&root.join("codegen/src/lib.rs"), "");
1848 write_file(
1849 &root.join("app/Cargo.toml"),
1850 r#"
1851[package]
1852name = "app"
1853version = "0.1.0"
1854edition = "2021"
1855
1856[build-dependencies]
1857codegen = { path = "../codegen", version = "0.1.0" }
1858"#,
1859 );
1860 write_file(&root.join("app/src/lib.rs"), "");
1861 write_file(&root.join("app/build.rs"), "fn main() {}");
1862 }
1863
1864 #[test]
1865 fn build_plan_build_dependency_ordering() {
1866 let td = tempdir().expect("tempdir");
1867 create_build_dep_workspace(td.path());
1868
1869 let ws = build_plan(&spec_for(td.path())).expect("plan");
1870 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1871 assert_eq!(names, vec!["codegen", "app"]);
1873 assert_eq!(ws.plan.dependencies["app"], vec!["codegen".to_string()]);
1874 }
1875
1876 #[test]
1877 fn snapshot_build_dep_plan() {
1878 let td = tempdir().expect("tempdir");
1879 create_build_dep_workspace(td.path());
1880
1881 let ws = build_plan(&spec_for(td.path())).expect("plan");
1882 insta::assert_yaml_snapshot!("build_dep_plan", snapshot_of(&ws));
1883 }
1884
1885 #[test]
1888 fn build_plan_multiple_selected_packages() {
1889 let td = tempdir().expect("tempdir");
1890 create_workspace(td.path());
1891
1892 let mut spec = spec_for(td.path());
1893 spec.selected_packages = Some(vec!["b".to_string(), "zeta".to_string()]);
1895 let ws = build_plan(&spec).expect("plan");
1896 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1897 assert_eq!(names, vec!["a", "b", "zeta"]);
1899 }
1900
1901 #[test]
1902 fn snapshot_multi_select_plan() {
1903 let td = tempdir().expect("tempdir");
1904 create_workspace(td.path());
1905
1906 let mut spec = spec_for(td.path());
1907 spec.selected_packages = Some(vec!["b".to_string(), "zeta".to_string()]);
1908 let ws = build_plan(&spec).expect("plan");
1909 insta::assert_yaml_snapshot!("multi_select_plan", snapshot_of(&ws));
1910 }
1911
1912 #[test]
1915 fn build_plan_selecting_leaf_is_standalone() {
1916 let td = tempdir().expect("tempdir");
1917 create_diamond_workspace(td.path());
1918
1919 let mut spec = spec_for(td.path());
1921 spec.selected_packages = Some(vec!["diamond-d".to_string()]);
1922 let ws = build_plan(&spec).expect("plan");
1923 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
1924 assert_eq!(names, vec!["diamond-d"]);
1925 }
1926
1927 #[test]
1930 fn build_plan_selecting_all_equals_no_selection() {
1931 let td = tempdir().expect("tempdir");
1932 create_workspace(td.path());
1933
1934 let ws_all = build_plan(&spec_for(td.path())).expect("plan");
1935 let all_names: Vec<&str> = ws_all
1936 .plan
1937 .packages
1938 .iter()
1939 .map(|p| p.name.as_str())
1940 .collect();
1941
1942 let mut spec = spec_for(td.path());
1943 spec.selected_packages = Some(all_names.iter().map(|n| n.to_string()).collect());
1944 let ws_explicit = build_plan(&spec).expect("plan");
1945 let explicit_names: Vec<&str> = ws_explicit
1946 .plan
1947 .packages
1948 .iter()
1949 .map(|p| p.name.as_str())
1950 .collect();
1951
1952 assert_eq!(all_names, explicit_names);
1953 assert_eq!(ws_all.plan.plan_id, ws_explicit.plan.plan_id);
1954 }
1955
1956 #[test]
1959 fn topo_sort_three_node_cycle() {
1960 let td = tempdir().expect("tempdir");
1961 create_workspace(td.path());
1962 let metadata = MetadataCommand::new()
1963 .manifest_path(td.path().join("Cargo.toml"))
1964 .exec()
1965 .expect("metadata");
1966
1967 let pkg_map = metadata
1968 .packages
1969 .iter()
1970 .map(|p| (p.id.clone(), p))
1971 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
1972 let mut by_name = BTreeMap::<String, PackageId>::new();
1973 for pkg in &metadata.packages {
1974 by_name.insert(pkg.name.to_string(), pkg.id.clone());
1975 }
1976
1977 let a = by_name.get("a").expect("a").clone();
1978 let b = by_name.get("b").expect("b").clone();
1979 let alpha = by_name.get("alpha").expect("alpha").clone();
1980
1981 let included = [a.clone(), b.clone(), alpha.clone()]
1983 .into_iter()
1984 .collect::<BTreeSet<_>>();
1985 let deps_of = BTreeMap::from([
1986 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
1987 (
1988 b.clone(),
1989 [alpha.clone()].into_iter().collect::<BTreeSet<_>>(),
1990 ),
1991 (
1992 alpha.clone(),
1993 [a.clone()].into_iter().collect::<BTreeSet<_>>(),
1994 ),
1995 ]);
1996 let dependents_of = BTreeMap::from([
1997 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
1998 (
1999 alpha.clone(),
2000 [b.clone()].into_iter().collect::<BTreeSet<_>>(),
2001 ),
2002 (
2003 a.clone(),
2004 [alpha.clone()].into_iter().collect::<BTreeSet<_>>(),
2005 ),
2006 ]);
2007
2008 let err = topo_sort(&included, &deps_of, &dependents_of, &pkg_map).expect_err("cycle");
2009 assert!(format!("{err:#}").contains("dependency cycle detected"));
2010 }
2011
2012 #[test]
2015 fn build_plan_mixed_versions() {
2016 let td = tempdir().expect("tempdir");
2017 write_file(
2018 &td.path().join("Cargo.toml"),
2019 r#"
2020[workspace]
2021members = ["core", "util"]
2022resolver = "2"
2023"#,
2024 );
2025 write_file(
2026 &td.path().join("core/Cargo.toml"),
2027 r#"
2028[package]
2029name = "core"
2030version = "2.5.0"
2031edition = "2021"
2032"#,
2033 );
2034 write_file(&td.path().join("core/src/lib.rs"), "");
2035 write_file(
2036 &td.path().join("util/Cargo.toml"),
2037 r#"
2038[package]
2039name = "util"
2040version = "0.3.1"
2041edition = "2021"
2042
2043[dependencies]
2044core = { path = "../core", version = "2.5.0" }
2045"#,
2046 );
2047 write_file(&td.path().join("util/src/lib.rs"), "");
2048
2049 let ws = build_plan(&spec_for(td.path())).expect("plan");
2050 assert_eq!(ws.plan.packages[0].name, "core");
2051 assert_eq!(ws.plan.packages[0].version, "2.5.0");
2052 assert_eq!(ws.plan.packages[1].name, "util");
2053 assert_eq!(ws.plan.packages[1].version, "0.3.1");
2054 }
2055
2056 #[test]
2059 fn build_plan_plan_id_differs_for_different_selections() {
2060 let td = tempdir().expect("tempdir");
2061 create_workspace(td.path());
2062
2063 let ws_all = build_plan(&spec_for(td.path())).expect("plan");
2064
2065 let mut spec_a = spec_for(td.path());
2066 spec_a.selected_packages = Some(vec!["a".to_string()]);
2067 let ws_a = build_plan(&spec_a).expect("plan");
2068
2069 assert_ne!(ws_all.plan.plan_id, ws_a.plan.plan_id);
2070 }
2071
2072 #[test]
2075 fn build_plan_dev_deps_excluded_from_transitive() {
2076 let td = tempdir().expect("tempdir");
2077 create_workspace(td.path());
2078
2079 let mut spec = spec_for(td.path());
2081 spec.selected_packages = Some(vec!["alpha".to_string()]);
2082 let ws = build_plan(&spec).expect("plan");
2083 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2084 assert_eq!(names, vec!["alpha"]);
2085 }
2086
2087 #[test]
2090 fn compute_plan_id_no_collision_on_name_version_boundary() {
2091 let pkgs_a = vec![PlannedPackage {
2093 name: "foo".to_string(),
2094 version: "1.0.0".to_string(),
2095 manifest_path: PathBuf::from("a/Cargo.toml"),
2096 }];
2097 let pkgs_b = vec![PlannedPackage {
2098 name: "fo".to_string(),
2099 version: "o1.0.0".to_string(),
2100 manifest_path: PathBuf::from("b/Cargo.toml"),
2101 }];
2102 let id_a = compute_plan_id("https://crates.io", &pkgs_a);
2103 let id_b = compute_plan_id("https://crates.io", &pkgs_b);
2104 assert_ne!(id_a, id_b);
2105 }
2106
2107 #[test]
2110 fn compute_plan_id_is_order_sensitive() {
2111 let pkg_a = PlannedPackage {
2112 name: "aaa".to_string(),
2113 version: "1.0.0".to_string(),
2114 manifest_path: PathBuf::from("a/Cargo.toml"),
2115 };
2116 let pkg_b = PlannedPackage {
2117 name: "bbb".to_string(),
2118 version: "1.0.0".to_string(),
2119 manifest_path: PathBuf::from("b/Cargo.toml"),
2120 };
2121 let id_ab = compute_plan_id("https://crates.io", &[pkg_a.clone(), pkg_b.clone()]);
2122 let id_ba = compute_plan_id("https://crates.io", &[pkg_b, pkg_a]);
2123 assert_ne!(id_ab, id_ba);
2124 }
2125
2126 #[test]
2129 fn compute_plan_id_is_sha256_hex() {
2130 let pkgs = vec![
2131 PlannedPackage {
2132 name: "x".to_string(),
2133 version: "0.0.1".to_string(),
2134 manifest_path: PathBuf::from("x/Cargo.toml"),
2135 },
2136 PlannedPackage {
2137 name: "y".to_string(),
2138 version: "0.0.2".to_string(),
2139 manifest_path: PathBuf::from("y/Cargo.toml"),
2140 },
2141 ];
2142 let id = compute_plan_id("https://example.com", &pkgs);
2143 assert_eq!(id.len(), 64, "SHA256 hex digest must be 64 chars");
2144 assert!(
2145 id.chars().all(|c| c.is_ascii_hexdigit()),
2146 "all chars must be hex digits"
2147 );
2148 }
2149
2150 #[test]
2153 fn build_plan_deps_map_keys_match_packages() {
2154 let td = tempdir().expect("tempdir");
2155 create_diamond_workspace(td.path());
2156
2157 let ws = build_plan(&spec_for(td.path())).expect("plan");
2158 let pkg_names: BTreeSet<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2159 let dep_keys: BTreeSet<&str> = ws.plan.dependencies.keys().map(|k| k.as_str()).collect();
2160 assert_eq!(pkg_names, dep_keys);
2161 }
2162
2163 #[test]
2166 fn plan_stability_build_dep_10_runs() {
2167 let td = tempdir().expect("tempdir");
2168 create_build_dep_workspace(td.path());
2169 let spec = spec_for(td.path());
2170
2171 let baseline = build_plan(&spec).expect("plan");
2172 let baseline_names: Vec<&str> = baseline
2173 .plan
2174 .packages
2175 .iter()
2176 .map(|p| p.name.as_str())
2177 .collect();
2178
2179 for _ in 0..10 {
2180 let ws = build_plan(&spec).expect("plan");
2181 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2182 assert_eq!(names, baseline_names);
2183 assert_eq!(ws.plan.plan_id, baseline.plan.plan_id);
2184 }
2185 }
2186
2187 proptest! {
2188 #[test]
2189 fn compute_plan_id_is_stable_and_hex(
2190 registry in "[a-z]{1,8}",
2191 packages in prop::collection::vec(("[a-z]{1,6}", 0u8..10u8, 0u8..10u8, 0u8..10u8), 1..8),
2192 ) {
2193 let pkgs: Vec<PlannedPackage> = packages
2194 .iter()
2195 .map(|(name, major, minor, patch)| PlannedPackage {
2196 name: name.clone(),
2197 version: format!("{}.{}.{}", major, minor, patch),
2198 manifest_path: Path::new("x").join(format!("{name}.toml")),
2199 })
2200 .collect();
2201
2202 let id1 = compute_plan_id(®istry, &pkgs);
2203 let id2 = compute_plan_id(®istry, &pkgs);
2204 prop_assert_eq!(&id1, &id2);
2205 prop_assert_eq!(id1.len(), 64);
2206 prop_assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
2207 }
2208
2209 #[test]
2211 fn prop_plan_id_deterministic_for_same_input(
2212 registry in "[a-z]{1,10}",
2213 pkg_count in 0usize..10,
2214 ) {
2215 let pkgs: Vec<PlannedPackage> = (0..pkg_count)
2216 .map(|i| PlannedPackage {
2217 name: format!("crate-{i}"),
2218 version: format!("{i}.0.0"),
2219 manifest_path: Path::new("x").join(format!("crate-{i}.toml")),
2220 })
2221 .collect();
2222
2223 let id1 = compute_plan_id(®istry, &pkgs);
2224 let id2 = compute_plan_id(®istry, &pkgs);
2225 prop_assert_eq!(id1, id2);
2226 }
2227
2228 #[test]
2231 fn prop_plan_ordering_respects_dependencies(chain_len in 1usize..7) {
2232 let td = tempdir().expect("tempdir");
2234 let members: Vec<String> = (0..chain_len).map(|i| format!("\"p{i}\"")).collect();
2235 write_file(
2236 &td.path().join("Cargo.toml"),
2237 &format!(
2238 "[workspace]\nmembers = [{members}]\nresolver = \"2\"\n",
2239 members = members.join(", ")
2240 ),
2241 );
2242
2243 for i in 0..chain_len {
2244 let name = format!("p{i}");
2245 let deps = if i > 0 {
2246 let prev = format!("p{}", i - 1);
2247 format!(
2248 "\n[dependencies]\n{prev} = {{ path = \"../{prev}\", version = \"0.1.0\" }}\n"
2249 )
2250 } else {
2251 String::new()
2252 };
2253 write_file(
2254 &td.path().join(format!("{name}/Cargo.toml")),
2255 &format!(
2256 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n{deps}"
2257 ),
2258 );
2259 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2260 }
2261
2262 let ws = build_plan(&spec_for(td.path())).expect("plan");
2263 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2264
2265 for pkg in &ws.plan.packages {
2267 let pkg_pos = names.iter().position(|n| *n == pkg.name).unwrap();
2268 if let Some(deps) = ws.plan.dependencies.get(&pkg.name) {
2269 for dep in deps {
2270 let dep_pos = names.iter().position(|n| n == dep).unwrap();
2271 prop_assert!(
2272 dep_pos < pkg_pos,
2273 "dependency {dep} (pos {dep_pos}) must come before {name} (pos {pkg_pos})",
2274 name = pkg.name
2275 );
2276 }
2277 }
2278 }
2279 }
2280
2281 #[test]
2283 fn prop_plan_id_differs_for_distinct_packages(
2284 name_a in "[a-z]{1,6}",
2285 name_b in "[a-z]{1,6}",
2286 ver_a in 0u8..20u8,
2287 ver_b in 0u8..20u8,
2288 ) {
2289 prop_assume!(name_a != name_b || ver_a != ver_b);
2291 let pkgs_a = vec![PlannedPackage {
2292 name: name_a,
2293 version: format!("{ver_a}.0.0"),
2294 manifest_path: Path::new("a").join("Cargo.toml"),
2295 }];
2296 let pkgs_b = vec![PlannedPackage {
2297 name: name_b,
2298 version: format!("{ver_b}.0.0"),
2299 manifest_path: Path::new("b").join("Cargo.toml"),
2300 }];
2301 let id_a = compute_plan_id("https://crates.io", &pkgs_a);
2302 let id_b = compute_plan_id("https://crates.io", &pkgs_b);
2303 prop_assert_ne!(id_a, id_b);
2304 }
2305
2306 #[test]
2308 fn prop_independent_packages_sorted_alphabetically(count in 2usize..8) {
2309 let td = tempdir().expect("tempdir");
2310 let names: Vec<String> = (0..count).map(|i| format!("ind-{i:02}")).collect();
2312 let members: Vec<String> = names.iter().map(|n| format!("\"{n}\"")).collect();
2313 write_file(
2314 &td.path().join("Cargo.toml"),
2315 &format!(
2316 "[workspace]\nmembers = [{members}]\nresolver = \"2\"\n",
2317 members = members.join(", ")
2318 ),
2319 );
2320 for name in &names {
2321 write_file(
2322 &td.path().join(format!("{name}/Cargo.toml")),
2323 &format!(
2324 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
2325 ),
2326 );
2327 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2328 }
2329
2330 let ws = build_plan(&spec_for(td.path())).expect("plan");
2331 let plan_names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2332 let mut sorted = plan_names.clone();
2333 sorted.sort();
2334 prop_assert_eq!(plan_names, sorted, "independent packages must be alphabetical");
2335 }
2336
2337 #[test]
2340 fn prop_diamond_dag_deps_before_dependents(
2341 extra_leaves in 0usize..4,
2342 ) {
2343 let mut members = vec!["base".to_string()];
2345 let mid_count = 2 + extra_leaves; for i in 0..mid_count {
2347 members.push(format!("mid-{i}"));
2348 }
2349 members.push("top".to_string());
2350 for i in 0..extra_leaves {
2351 members.push(format!("leaf-{i}"));
2352 }
2353
2354 let td = tempdir().expect("tempdir");
2355 let quoted: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
2356 write_file(
2357 &td.path().join("Cargo.toml"),
2358 &format!(
2359 "[workspace]\nmembers = [{ms}]\nresolver = \"2\"\n",
2360 ms = quoted.join(", ")
2361 ),
2362 );
2363
2364 write_file(
2366 &td.path().join("base/Cargo.toml"),
2367 "[package]\nname = \"base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
2368 );
2369 write_file(&td.path().join("base/src/lib.rs"), "");
2370
2371 for i in 0..mid_count {
2373 let name = format!("mid-{i}");
2374 write_file(
2375 &td.path().join(format!("{name}/Cargo.toml")),
2376 &format!(
2377 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbase = {{ path = \"../base\", version = \"0.1.0\" }}\n"
2378 ),
2379 );
2380 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2381 }
2382
2383 let mut top_deps = String::from("[dependencies]\n");
2385 for i in 0..mid_count {
2386 top_deps.push_str(&format!(
2387 "mid-{i} = {{ path = \"../mid-{i}\", version = \"0.1.0\" }}\n"
2388 ));
2389 }
2390 write_file(
2391 &td.path().join("top/Cargo.toml"),
2392 &format!(
2393 "[package]\nname = \"top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n{top_deps}"
2394 ),
2395 );
2396 write_file(&td.path().join("top/src/lib.rs"), "");
2397
2398 for i in 0..extra_leaves {
2400 let name = format!("leaf-{i}");
2401 write_file(
2402 &td.path().join(format!("{name}/Cargo.toml")),
2403 &format!(
2404 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
2405 ),
2406 );
2407 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2408 }
2409
2410 let ws = build_plan(&spec_for(td.path())).expect("plan");
2411 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2412
2413 for pkg in &ws.plan.packages {
2415 let pkg_pos = names.iter().position(|n| *n == pkg.name).unwrap();
2416 if let Some(deps) = ws.plan.dependencies.get(&pkg.name) {
2417 for dep in deps {
2418 let dep_pos = names.iter().position(|n| n == dep).unwrap();
2419 prop_assert!(
2420 dep_pos < pkg_pos,
2421 "dep {dep} (pos {dep_pos}) must come before {} (pos {pkg_pos})",
2422 pkg.name
2423 );
2424 }
2425 }
2426 }
2427 }
2428
2429 #[test]
2432 fn prop_deps_map_size_equals_package_count(chain_len in 1usize..6) {
2433 let td = tempdir().expect("tempdir");
2434 let members: Vec<String> = (0..chain_len).map(|i| format!("\"q{i}\"")).collect();
2435 write_file(
2436 &td.path().join("Cargo.toml"),
2437 &format!(
2438 "[workspace]\nmembers = [{ms}]\nresolver = \"2\"\n",
2439 ms = members.join(", ")
2440 ),
2441 );
2442 for i in 0..chain_len {
2443 let name = format!("q{i}");
2444 let deps = if i > 0 {
2445 let prev = format!("q{}", i - 1);
2446 format!("\n[dependencies]\n{prev} = {{ path = \"../{prev}\", version = \"0.1.0\" }}\n")
2447 } else {
2448 String::new()
2449 };
2450 write_file(
2451 &td.path().join(format!("{name}/Cargo.toml")),
2452 &format!(
2453 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n{deps}"
2454 ),
2455 );
2456 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2457 }
2458
2459 let ws = build_plan(&spec_for(td.path())).expect("plan");
2460 prop_assert_eq!(ws.plan.packages.len(), ws.plan.dependencies.len());
2461 }
2462 }
2463
2464 fn create_double_diamond_workspace(root: &Path) {
2467 write_file(
2468 &root.join("Cargo.toml"),
2469 r#"
2470[workspace]
2471members = ["dd-base", "dd-m1", "dd-m2", "dd-mid", "dd-t1", "dd-t2", "dd-top"]
2472resolver = "2"
2473"#,
2474 );
2475 write_file(
2477 &root.join("dd-base/Cargo.toml"),
2478 r#"
2479[package]
2480name = "dd-base"
2481version = "0.1.0"
2482edition = "2021"
2483"#,
2484 );
2485 write_file(&root.join("dd-base/src/lib.rs"), "");
2486
2487 for m in &["dd-m1", "dd-m2"] {
2489 write_file(
2490 &root.join(format!("{m}/Cargo.toml")),
2491 &format!(
2492 r#"
2493[package]
2494name = "{m}"
2495version = "0.1.0"
2496edition = "2021"
2497
2498[dependencies]
2499dd-base = {{ path = "../dd-base", version = "0.1.0" }}
2500"#
2501 ),
2502 );
2503 write_file(&root.join(format!("{m}/src/lib.rs")), "");
2504 }
2505
2506 write_file(
2508 &root.join("dd-mid/Cargo.toml"),
2509 r#"
2510[package]
2511name = "dd-mid"
2512version = "0.1.0"
2513edition = "2021"
2514
2515[dependencies]
2516dd-m1 = { path = "../dd-m1", version = "0.1.0" }
2517dd-m2 = { path = "../dd-m2", version = "0.1.0" }
2518"#,
2519 );
2520 write_file(&root.join("dd-mid/src/lib.rs"), "");
2521
2522 for t in &["dd-t1", "dd-t2"] {
2524 write_file(
2525 &root.join(format!("{t}/Cargo.toml")),
2526 &format!(
2527 r#"
2528[package]
2529name = "{t}"
2530version = "0.1.0"
2531edition = "2021"
2532
2533[dependencies]
2534dd-mid = {{ path = "../dd-mid", version = "0.1.0" }}
2535"#
2536 ),
2537 );
2538 write_file(&root.join(format!("{t}/src/lib.rs")), "");
2539 }
2540
2541 write_file(
2543 &root.join("dd-top/Cargo.toml"),
2544 r#"
2545[package]
2546name = "dd-top"
2547version = "0.1.0"
2548edition = "2021"
2549
2550[dependencies]
2551dd-t1 = { path = "../dd-t1", version = "0.1.0" }
2552dd-t2 = { path = "../dd-t2", version = "0.1.0" }
2553"#,
2554 );
2555 write_file(&root.join("dd-top/src/lib.rs"), "");
2556 }
2557
2558 #[test]
2559 fn build_plan_double_diamond_ordering() {
2560 let td = tempdir().expect("tempdir");
2561 create_double_diamond_workspace(td.path());
2562
2563 let ws = build_plan(&spec_for(td.path())).expect("plan");
2564 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2565 assert_eq!(ws.plan.packages.len(), 7);
2566
2567 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
2568
2569 assert!(pos("dd-base") < pos("dd-m1"));
2571 assert!(pos("dd-base") < pos("dd-m2"));
2572 assert!(pos("dd-m1") < pos("dd-mid"));
2573 assert!(pos("dd-m2") < pos("dd-mid"));
2574 assert!(pos("dd-mid") < pos("dd-t1"));
2575 assert!(pos("dd-mid") < pos("dd-t2"));
2576 assert!(pos("dd-t1") < pos("dd-top"));
2577 assert!(pos("dd-t2") < pos("dd-top"));
2578 }
2579
2580 #[test]
2581 fn build_plan_double_diamond_deps_map() {
2582 let td = tempdir().expect("tempdir");
2583 create_double_diamond_workspace(td.path());
2584
2585 let ws = build_plan(&spec_for(td.path())).expect("plan");
2586 assert!(ws.plan.dependencies["dd-base"].is_empty());
2587 assert_eq!(ws.plan.dependencies["dd-m1"], vec!["dd-base".to_string()]);
2588 assert_eq!(ws.plan.dependencies["dd-m2"], vec!["dd-base".to_string()]);
2589 let mut mid_deps = ws.plan.dependencies["dd-mid"].clone();
2590 mid_deps.sort();
2591 assert_eq!(mid_deps, vec!["dd-m1".to_string(), "dd-m2".to_string()]);
2592 assert_eq!(ws.plan.dependencies["dd-t1"], vec!["dd-mid".to_string()]);
2593 assert_eq!(ws.plan.dependencies["dd-t2"], vec!["dd-mid".to_string()]);
2594 let mut top_deps = ws.plan.dependencies["dd-top"].clone();
2595 top_deps.sort();
2596 assert_eq!(top_deps, vec!["dd-t1".to_string(), "dd-t2".to_string()]);
2597 }
2598
2599 #[test]
2600 fn snapshot_double_diamond_plan() {
2601 let td = tempdir().expect("tempdir");
2602 create_double_diamond_workspace(td.path());
2603
2604 let ws = build_plan(&spec_for(td.path())).expect("plan");
2605 insta::assert_yaml_snapshot!("double_diamond_plan", snapshot_of(&ws));
2606 }
2607
2608 #[test]
2611 fn build_plan_fan_in_many_dependents_on_one_root() {
2612 let td = tempdir().expect("tempdir");
2613 let fan_count = 8;
2614 let mut members = vec!["\"root\"".to_string()];
2615 for i in 0..fan_count {
2616 members.push(format!("\"fan-{i:02}\""));
2617 }
2618 write_file(
2619 &td.path().join("Cargo.toml"),
2620 &format!(
2621 "[workspace]\nmembers = [{ms}]\nresolver = \"2\"\n",
2622 ms = members.join(", ")
2623 ),
2624 );
2625 write_file(
2626 &td.path().join("root/Cargo.toml"),
2627 "[package]\nname = \"root\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
2628 );
2629 write_file(&td.path().join("root/src/lib.rs"), "");
2630 for i in 0..fan_count {
2631 let name = format!("fan-{i:02}");
2632 write_file(
2633 &td.path().join(format!("{name}/Cargo.toml")),
2634 &format!(
2635 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nroot = {{ path = \"../root\", version = \"0.1.0\" }}\n"
2636 ),
2637 );
2638 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2639 }
2640
2641 let ws = build_plan(&spec_for(td.path())).expect("plan");
2642 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2643 assert_eq!(names[0], "root", "root must be first");
2644 let fans: Vec<&str> = names[1..].to_vec();
2646 let mut fans_sorted = fans.clone();
2647 fans_sorted.sort();
2648 assert_eq!(fans, fans_sorted);
2649 assert_eq!(ws.plan.packages.len(), fan_count + 1);
2650 }
2651
2652 #[test]
2655 fn build_plan_fan_out_one_dependent_on_many() {
2656 let td = tempdir().expect("tempdir");
2657 let root_count = 5;
2658 let mut members = Vec::new();
2659 for i in 0..root_count {
2660 members.push(format!("\"base-{i:02}\""));
2661 }
2662 members.push("\"consumer\"".to_string());
2663 write_file(
2664 &td.path().join("Cargo.toml"),
2665 &format!(
2666 "[workspace]\nmembers = [{ms}]\nresolver = \"2\"\n",
2667 ms = members.join(", ")
2668 ),
2669 );
2670 for i in 0..root_count {
2671 let name = format!("base-{i:02}");
2672 write_file(
2673 &td.path().join(format!("{name}/Cargo.toml")),
2674 &format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
2675 );
2676 write_file(&td.path().join(format!("{name}/src/lib.rs")), "");
2677 }
2678 let mut consumer_deps = String::from("[dependencies]\n");
2679 for i in 0..root_count {
2680 consumer_deps.push_str(&format!(
2681 "base-{i:02} = {{ path = \"../base-{i:02}\", version = \"0.1.0\" }}\n"
2682 ));
2683 }
2684 write_file(
2685 &td.path().join("consumer/Cargo.toml"),
2686 &format!(
2687 "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n{consumer_deps}"
2688 ),
2689 );
2690 write_file(&td.path().join("consumer/src/lib.rs"), "");
2691
2692 let ws = build_plan(&spec_for(td.path())).expect("plan");
2693 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2694 assert_eq!(*names.last().unwrap(), "consumer");
2696 let bases: Vec<&str> = names[..root_count].to_vec();
2698 let mut bases_sorted = bases.clone();
2699 bases_sorted.sort();
2700 assert_eq!(bases, bases_sorted);
2701 }
2702
2703 fn create_mixed_dep_kinds_workspace(root: &Path) {
2706 write_file(
2707 &root.join("Cargo.toml"),
2708 r#"
2709[workspace]
2710members = ["build-tool", "runtime-lib", "app-mixed"]
2711resolver = "2"
2712"#,
2713 );
2714 write_file(
2715 &root.join("build-tool/Cargo.toml"),
2716 r#"
2717[package]
2718name = "build-tool"
2719version = "0.1.0"
2720edition = "2021"
2721"#,
2722 );
2723 write_file(&root.join("build-tool/src/lib.rs"), "");
2724 write_file(
2725 &root.join("runtime-lib/Cargo.toml"),
2726 r#"
2727[package]
2728name = "runtime-lib"
2729version = "0.1.0"
2730edition = "2021"
2731"#,
2732 );
2733 write_file(&root.join("runtime-lib/src/lib.rs"), "");
2734 write_file(
2735 &root.join("app-mixed/Cargo.toml"),
2736 r#"
2737[package]
2738name = "app-mixed"
2739version = "0.1.0"
2740edition = "2021"
2741
2742[dependencies]
2743runtime-lib = { path = "../runtime-lib", version = "0.1.0" }
2744
2745[build-dependencies]
2746build-tool = { path = "../build-tool", version = "0.1.0" }
2747"#,
2748 );
2749 write_file(&root.join("app-mixed/src/lib.rs"), "");
2750 write_file(&root.join("app-mixed/build.rs"), "fn main() {}");
2751 }
2752
2753 #[test]
2754 fn build_plan_mixed_build_and_runtime_deps() {
2755 let td = tempdir().expect("tempdir");
2756 create_mixed_dep_kinds_workspace(td.path());
2757
2758 let ws = build_plan(&spec_for(td.path())).expect("plan");
2759 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2760
2761 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
2763 assert!(pos("build-tool") < pos("app-mixed"));
2764 assert!(pos("runtime-lib") < pos("app-mixed"));
2765 assert_eq!(ws.plan.packages.len(), 3);
2766
2767 let mut app_deps = ws.plan.dependencies["app-mixed"].clone();
2769 app_deps.sort();
2770 assert_eq!(
2771 app_deps,
2772 vec!["build-tool".to_string(), "runtime-lib".to_string()]
2773 );
2774 }
2775
2776 #[test]
2777 fn snapshot_mixed_dep_kinds_plan() {
2778 let td = tempdir().expect("tempdir");
2779 create_mixed_dep_kinds_workspace(td.path());
2780
2781 let ws = build_plan(&spec_for(td.path())).expect("plan");
2782 insta::assert_yaml_snapshot!("mixed_dep_kinds_plan", snapshot_of(&ws));
2783 }
2784
2785 #[test]
2788 fn build_plan_dev_dep_on_non_publishable_is_fine() {
2789 let td = tempdir().expect("tempdir");
2790 write_file(
2791 &td.path().join("Cargo.toml"),
2792 r#"
2793[workspace]
2794members = ["pub-crate", "test-helper"]
2795resolver = "2"
2796"#,
2797 );
2798 write_file(
2799 &td.path().join("test-helper/Cargo.toml"),
2800 r#"
2801[package]
2802name = "test-helper"
2803version = "0.1.0"
2804edition = "2021"
2805publish = false
2806"#,
2807 );
2808 write_file(&td.path().join("test-helper/src/lib.rs"), "");
2809 write_file(
2810 &td.path().join("pub-crate/Cargo.toml"),
2811 r#"
2812[package]
2813name = "pub-crate"
2814version = "0.1.0"
2815edition = "2021"
2816
2817[dev-dependencies]
2818test-helper = { path = "../test-helper", version = "0.1.0" }
2819"#,
2820 );
2821 write_file(&td.path().join("pub-crate/src/lib.rs"), "");
2822
2823 let ws = build_plan(&spec_for(td.path())).expect("plan should succeed");
2824 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2825 assert_eq!(names, vec!["pub-crate"]);
2826 assert_eq!(ws.skipped.len(), 1);
2827 assert_eq!(ws.skipped[0].name, "test-helper");
2828 assert!(ws.plan.dependencies["pub-crate"].is_empty());
2830 }
2831
2832 #[test]
2835 fn build_plan_dev_dep_would_be_cycle_is_not_error() {
2836 let td = tempdir().expect("tempdir");
2837 write_file(
2838 &td.path().join("Cargo.toml"),
2839 r#"
2840[workspace]
2841members = ["crate-x", "crate-y"]
2842resolver = "2"
2843"#,
2844 );
2845 write_file(
2847 &td.path().join("crate-x/Cargo.toml"),
2848 r#"
2849[package]
2850name = "crate-x"
2851version = "0.1.0"
2852edition = "2021"
2853
2854[dependencies]
2855crate-y = { path = "../crate-y", version = "0.1.0" }
2856"#,
2857 );
2858 write_file(&td.path().join("crate-x/src/lib.rs"), "");
2859 write_file(
2861 &td.path().join("crate-y/Cargo.toml"),
2862 r#"
2863[package]
2864name = "crate-y"
2865version = "0.1.0"
2866edition = "2021"
2867
2868[dev-dependencies]
2869crate-x = { path = "../crate-x", version = "0.1.0" }
2870"#,
2871 );
2872 write_file(&td.path().join("crate-y/src/lib.rs"), "");
2873
2874 let ws = build_plan(&spec_for(td.path())).expect("plan should succeed (dev-dep cycle ok)");
2875 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
2876 assert_eq!(names, vec!["crate-y", "crate-x"]);
2878 assert!(ws.plan.dependencies["crate-y"].is_empty());
2879 assert_eq!(ws.plan.dependencies["crate-x"], vec!["crate-y".to_string()]);
2880 }
2881
2882 #[test]
2885 fn build_plan_explicit_crates_io_publish_list() {
2886 let td = tempdir().expect("tempdir");
2887 write_file(
2888 &td.path().join("Cargo.toml"),
2889 r#"
2890[workspace]
2891members = ["explicit-pub"]
2892resolver = "2"
2893"#,
2894 );
2895 write_file(
2896 &td.path().join("explicit-pub/Cargo.toml"),
2897 r#"
2898[package]
2899name = "explicit-pub"
2900version = "1.0.0"
2901edition = "2021"
2902publish = ["crates-io"]
2903"#,
2904 );
2905 write_file(&td.path().join("explicit-pub/src/lib.rs"), "");
2906
2907 let ws = build_plan(&spec_for(td.path())).expect("plan");
2908 assert_eq!(ws.plan.packages.len(), 1);
2909 assert_eq!(ws.plan.packages[0].name, "explicit-pub");
2910 assert!(ws.skipped.is_empty());
2911 }
2912
2913 #[test]
2916 fn build_plan_multi_registry_publish_list() {
2917 let td = tempdir().expect("tempdir");
2918 write_file(
2919 &td.path().join("Cargo.toml"),
2920 r#"
2921[workspace]
2922members = ["multi-reg"]
2923resolver = "2"
2924"#,
2925 );
2926 write_file(
2927 &td.path().join("multi-reg/Cargo.toml"),
2928 r#"
2929[package]
2930name = "multi-reg"
2931version = "0.5.0"
2932edition = "2021"
2933publish = ["crates-io", "private-reg"]
2934"#,
2935 );
2936 write_file(&td.path().join("multi-reg/src/lib.rs"), "");
2937
2938 let ws_cio = build_plan(&spec_for(td.path())).expect("plan");
2940 assert_eq!(ws_cio.plan.packages.len(), 1);
2941 assert_eq!(ws_cio.plan.packages[0].name, "multi-reg");
2942
2943 let spec_priv = ReleaseSpec {
2945 manifest_path: td.path().join("Cargo.toml"),
2946 registry: Registry {
2947 name: "private-reg".to_string(),
2948 api_base: "https://private.example.com".to_string(),
2949 index_base: None,
2950 },
2951 selected_packages: None,
2952 };
2953 let ws_priv = build_plan(&spec_priv).expect("plan");
2954 assert_eq!(ws_priv.plan.packages.len(), 1);
2955
2956 let spec_other = ReleaseSpec {
2958 manifest_path: td.path().join("Cargo.toml"),
2959 registry: Registry {
2960 name: "other-reg".to_string(),
2961 api_base: "https://other.example.com".to_string(),
2962 index_base: None,
2963 },
2964 selected_packages: None,
2965 };
2966 let ws_other = build_plan(&spec_other).expect("plan");
2967 assert!(ws_other.plan.packages.is_empty());
2968 assert_eq!(ws_other.skipped.len(), 1);
2969 }
2970
2971 #[test]
2974 fn build_plan_prerelease_versions() {
2975 let td = tempdir().expect("tempdir");
2976 write_file(
2977 &td.path().join("Cargo.toml"),
2978 r#"
2979[workspace]
2980members = ["pre-alpha", "pre-beta"]
2981resolver = "2"
2982"#,
2983 );
2984 write_file(
2985 &td.path().join("pre-alpha/Cargo.toml"),
2986 r#"
2987[package]
2988name = "pre-alpha"
2989version = "0.1.0-alpha.1"
2990edition = "2021"
2991"#,
2992 );
2993 write_file(&td.path().join("pre-alpha/src/lib.rs"), "");
2994 write_file(
2995 &td.path().join("pre-beta/Cargo.toml"),
2996 r#"
2997[package]
2998name = "pre-beta"
2999version = "2.0.0-rc.3"
3000edition = "2021"
3001
3002[dependencies]
3003pre-alpha = { path = "../pre-alpha", version = "0.1.0-alpha.1" }
3004"#,
3005 );
3006 write_file(&td.path().join("pre-beta/src/lib.rs"), "");
3007
3008 let ws = build_plan(&spec_for(td.path())).expect("plan");
3009 assert_eq!(ws.plan.packages[0].name, "pre-alpha");
3010 assert_eq!(ws.plan.packages[0].version, "0.1.0-alpha.1");
3011 assert_eq!(ws.plan.packages[1].name, "pre-beta");
3012 assert_eq!(ws.plan.packages[1].version, "2.0.0-rc.3");
3013 }
3014
3015 #[test]
3018 fn build_plan_id_changes_on_version_bump() {
3019 let td = tempdir().expect("tempdir");
3020 write_file(
3021 &td.path().join("Cargo.toml"),
3022 r#"
3023[workspace]
3024members = ["bump-me"]
3025resolver = "2"
3026"#,
3027 );
3028 write_file(
3029 &td.path().join("bump-me/Cargo.toml"),
3030 r#"
3031[package]
3032name = "bump-me"
3033version = "1.0.0"
3034edition = "2021"
3035"#,
3036 );
3037 write_file(&td.path().join("bump-me/src/lib.rs"), "");
3038
3039 let ws1 = build_plan(&spec_for(td.path())).expect("plan");
3040
3041 write_file(
3043 &td.path().join("bump-me/Cargo.toml"),
3044 r#"
3045[package]
3046name = "bump-me"
3047version = "1.1.0"
3048edition = "2021"
3049"#,
3050 );
3051
3052 let ws2 = build_plan(&spec_for(td.path())).expect("plan");
3053 assert_ne!(ws1.plan.plan_id, ws2.plan.plan_id);
3054 assert_eq!(ws2.plan.packages[0].version, "1.1.0");
3055 }
3056
3057 #[test]
3060 fn build_plan_selecting_diamond_middle_pulls_transitive() {
3061 let td = tempdir().expect("tempdir");
3062 create_double_diamond_workspace(td.path());
3063
3064 let mut spec = spec_for(td.path());
3066 spec.selected_packages = Some(vec!["dd-mid".to_string()]);
3067 let ws = build_plan(&spec).expect("plan");
3068 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
3069 assert!(names.contains(&"dd-base"));
3070 assert!(names.contains(&"dd-m1"));
3071 assert!(names.contains(&"dd-m2"));
3072 assert!(names.contains(&"dd-mid"));
3073 assert!(!names.contains(&"dd-t1"));
3074 assert!(!names.contains(&"dd-t2"));
3075 assert!(!names.contains(&"dd-top"));
3076 assert_eq!(names.len(), 4);
3077 }
3078
3079 #[test]
3082 fn build_plan_selecting_double_diamond_top_pulls_all() {
3083 let td = tempdir().expect("tempdir");
3084 create_double_diamond_workspace(td.path());
3085
3086 let mut spec = spec_for(td.path());
3087 spec.selected_packages = Some(vec!["dd-top".to_string()]);
3088 let ws = build_plan(&spec).expect("plan");
3089 assert_eq!(ws.plan.packages.len(), 7);
3090 }
3091
3092 #[test]
3095 fn build_plan_w_shape_two_independent_diamonds() {
3096 let td = tempdir().expect("tempdir");
3097 write_file(
3098 &td.path().join("Cargo.toml"),
3099 r#"
3100[workspace]
3101members = ["l-base", "l-mid", "l-top", "r-base", "r-mid", "r-top"]
3102resolver = "2"
3103"#,
3104 );
3105 write_file(
3107 &td.path().join("l-base/Cargo.toml"),
3108 "[package]\nname = \"l-base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3109 );
3110 write_file(&td.path().join("l-base/src/lib.rs"), "");
3111 write_file(
3112 &td.path().join("l-mid/Cargo.toml"),
3113 "[package]\nname = \"l-mid\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nl-base = { path = \"../l-base\", version = \"0.1.0\" }\n",
3114 );
3115 write_file(&td.path().join("l-mid/src/lib.rs"), "");
3116 write_file(
3117 &td.path().join("l-top/Cargo.toml"),
3118 "[package]\nname = \"l-top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nl-mid = { path = \"../l-mid\", version = \"0.1.0\" }\n",
3119 );
3120 write_file(&td.path().join("l-top/src/lib.rs"), "");
3121 write_file(
3123 &td.path().join("r-base/Cargo.toml"),
3124 "[package]\nname = \"r-base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3125 );
3126 write_file(&td.path().join("r-base/src/lib.rs"), "");
3127 write_file(
3128 &td.path().join("r-mid/Cargo.toml"),
3129 "[package]\nname = \"r-mid\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nr-base = { path = \"../r-base\", version = \"0.1.0\" }\n",
3130 );
3131 write_file(&td.path().join("r-mid/src/lib.rs"), "");
3132 write_file(
3133 &td.path().join("r-top/Cargo.toml"),
3134 "[package]\nname = \"r-top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nr-mid = { path = \"../r-mid\", version = \"0.1.0\" }\n",
3135 );
3136 write_file(&td.path().join("r-top/src/lib.rs"), "");
3137
3138 let ws = build_plan(&spec_for(td.path())).expect("plan");
3139 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
3140 assert_eq!(ws.plan.packages.len(), 6);
3141
3142 let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
3143 assert!(pos("l-base") < pos("l-mid"));
3145 assert!(pos("l-mid") < pos("l-top"));
3146 assert!(pos("r-base") < pos("r-mid"));
3148 assert!(pos("r-mid") < pos("r-top"));
3149 }
3150
3151 #[test]
3154 fn build_plan_w_shape_selecting_one_chain_excludes_other() {
3155 let td = tempdir().expect("tempdir");
3156 write_file(
3157 &td.path().join("Cargo.toml"),
3158 r#"
3159[workspace]
3160members = ["l-base", "l-top", "r-base", "r-top"]
3161resolver = "2"
3162"#,
3163 );
3164 write_file(
3165 &td.path().join("l-base/Cargo.toml"),
3166 "[package]\nname = \"l-base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3167 );
3168 write_file(&td.path().join("l-base/src/lib.rs"), "");
3169 write_file(
3170 &td.path().join("l-top/Cargo.toml"),
3171 "[package]\nname = \"l-top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nl-base = { path = \"../l-base\", version = \"0.1.0\" }\n",
3172 );
3173 write_file(&td.path().join("l-top/src/lib.rs"), "");
3174 write_file(
3175 &td.path().join("r-base/Cargo.toml"),
3176 "[package]\nname = \"r-base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3177 );
3178 write_file(&td.path().join("r-base/src/lib.rs"), "");
3179 write_file(
3180 &td.path().join("r-top/Cargo.toml"),
3181 "[package]\nname = \"r-top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nr-base = { path = \"../r-base\", version = \"0.1.0\" }\n",
3182 );
3183 write_file(&td.path().join("r-top/src/lib.rs"), "");
3184
3185 let mut spec = spec_for(td.path());
3186 spec.selected_packages = Some(vec!["l-top".to_string()]);
3187 let ws = build_plan(&spec).expect("plan");
3188 let names: Vec<&str> = ws.plan.packages.iter().map(|p| p.name.as_str()).collect();
3189 assert_eq!(names, vec!["l-base", "l-top"]);
3190 }
3191
3192 #[test]
3195 fn build_plan_workspace_inherited_version() {
3196 let td = tempdir().expect("tempdir");
3197 write_file(
3198 &td.path().join("Cargo.toml"),
3199 r#"
3200[workspace]
3201members = ["inherited"]
3202resolver = "2"
3203
3204[workspace.package]
3205version = "3.7.2"
3206edition = "2021"
3207"#,
3208 );
3209 write_file(
3210 &td.path().join("inherited/Cargo.toml"),
3211 r#"
3212[package]
3213name = "inherited"
3214version.workspace = true
3215edition.workspace = true
3216"#,
3217 );
3218 write_file(&td.path().join("inherited/src/lib.rs"), "");
3219
3220 let ws = build_plan(&spec_for(td.path())).expect("plan");
3221 assert_eq!(ws.plan.packages.len(), 1);
3222 assert_eq!(ws.plan.packages[0].name, "inherited");
3223 assert_eq!(ws.plan.packages[0].version, "3.7.2");
3224 }
3225
3226 #[test]
3229 fn build_plan_skipped_reasons_are_descriptive() {
3230 let td = tempdir().expect("tempdir");
3231 write_file(
3232 &td.path().join("Cargo.toml"),
3233 r#"
3234[workspace]
3235members = ["pub-false", "pub-other-reg", "pub-ok"]
3236resolver = "2"
3237"#,
3238 );
3239 write_file(
3240 &td.path().join("pub-false/Cargo.toml"),
3241 "[package]\nname = \"pub-false\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n",
3242 );
3243 write_file(&td.path().join("pub-false/src/lib.rs"), "");
3244 write_file(
3245 &td.path().join("pub-other-reg/Cargo.toml"),
3246 "[package]\nname = \"pub-other-reg\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = [\"other\"]\n",
3247 );
3248 write_file(&td.path().join("pub-other-reg/src/lib.rs"), "");
3249 write_file(
3250 &td.path().join("pub-ok/Cargo.toml"),
3251 "[package]\nname = \"pub-ok\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
3252 );
3253 write_file(&td.path().join("pub-ok/src/lib.rs"), "");
3254
3255 let ws = build_plan(&spec_for(td.path())).expect("plan");
3256 assert_eq!(ws.plan.packages.len(), 1);
3257 assert_eq!(ws.skipped.len(), 2);
3258
3259 let pub_false_skip = ws.skipped.iter().find(|s| s.name == "pub-false").unwrap();
3260 assert!(
3261 pub_false_skip.reason.contains("publish = false"),
3262 "reason: {}",
3263 pub_false_skip.reason
3264 );
3265
3266 let pub_other_skip = ws
3267 .skipped
3268 .iter()
3269 .find(|s| s.name == "pub-other-reg")
3270 .unwrap();
3271 assert!(
3272 pub_other_skip.reason.contains("registry not in list"),
3273 "reason: {}",
3274 pub_other_skip.reason
3275 );
3276 }
3277
3278 #[test]
3281 fn compute_plan_id_differs_for_single_vs_duplicated_package() {
3282 let pkg = PlannedPackage {
3283 name: "foo".to_string(),
3284 version: "1.0.0".to_string(),
3285 manifest_path: PathBuf::from("foo/Cargo.toml"),
3286 };
3287 let id_one = compute_plan_id("https://crates.io", std::slice::from_ref(&pkg));
3288 let id_two = compute_plan_id("https://crates.io", &[pkg.clone(), pkg]);
3289 assert_ne!(id_one, id_two);
3290 }
3291
3292 #[test]
3295 fn snapshot_double_diamond_selected_mid() {
3296 let td = tempdir().expect("tempdir");
3297 create_double_diamond_workspace(td.path());
3298
3299 let mut spec = spec_for(td.path());
3300 spec.selected_packages = Some(vec!["dd-mid".to_string()]);
3301 let ws = build_plan(&spec).expect("plan");
3302 insta::assert_yaml_snapshot!("double_diamond_selected_mid", snapshot_of(&ws));
3303 }
3304
3305 #[test]
3306 fn snapshot_prerelease_versions() {
3307 let td = tempdir().expect("tempdir");
3308 write_file(
3309 &td.path().join("Cargo.toml"),
3310 r#"
3311[workspace]
3312members = ["pre-alpha", "pre-beta"]
3313resolver = "2"
3314"#,
3315 );
3316 write_file(
3317 &td.path().join("pre-alpha/Cargo.toml"),
3318 r#"
3319[package]
3320name = "pre-alpha"
3321version = "0.1.0-alpha.1"
3322edition = "2021"
3323"#,
3324 );
3325 write_file(&td.path().join("pre-alpha/src/lib.rs"), "");
3326 write_file(
3327 &td.path().join("pre-beta/Cargo.toml"),
3328 r#"
3329[package]
3330name = "pre-beta"
3331version = "2.0.0-rc.3"
3332edition = "2021"
3333
3334[dependencies]
3335pre-alpha = { path = "../pre-alpha", version = "0.1.0-alpha.1" }
3336"#,
3337 );
3338 write_file(&td.path().join("pre-beta/src/lib.rs"), "");
3339
3340 let ws = build_plan(&spec_for(td.path())).expect("plan");
3341 insta::assert_yaml_snapshot!("prerelease_versions_plan", snapshot_of(&ws));
3342 }
3343
3344 #[test]
3345 fn snapshot_dev_dep_cycle_plan() {
3346 let td = tempdir().expect("tempdir");
3347 write_file(
3348 &td.path().join("Cargo.toml"),
3349 r#"
3350[workspace]
3351members = ["crate-x", "crate-y"]
3352resolver = "2"
3353"#,
3354 );
3355 write_file(
3356 &td.path().join("crate-x/Cargo.toml"),
3357 r#"
3358[package]
3359name = "crate-x"
3360version = "0.1.0"
3361edition = "2021"
3362
3363[dependencies]
3364crate-y = { path = "../crate-y", version = "0.1.0" }
3365"#,
3366 );
3367 write_file(&td.path().join("crate-x/src/lib.rs"), "");
3368 write_file(
3369 &td.path().join("crate-y/Cargo.toml"),
3370 r#"
3371[package]
3372name = "crate-y"
3373version = "0.1.0"
3374edition = "2021"
3375
3376[dev-dependencies]
3377crate-x = { path = "../crate-x", version = "0.1.0" }
3378"#,
3379 );
3380 write_file(&td.path().join("crate-y/src/lib.rs"), "");
3381
3382 let ws = build_plan(&spec_for(td.path())).expect("plan");
3383 insta::assert_yaml_snapshot!("dev_dep_cycle_plan", snapshot_of(&ws));
3384 }
3385
3386 fn normalize_error_message(err: &str) -> String {
3389 let stripped = console::strip_ansi_codes(err);
3390 stripped.replace('\\', "/")
3391 }
3392
3393 #[test]
3394 fn snapshot_error_message_missing_manifest() {
3395 let spec = ReleaseSpec {
3396 manifest_path: Path::new("nonexistent-dir").join("Cargo.toml"),
3397 registry: Registry::crates_io(),
3398 selected_packages: None,
3399 };
3400 let err = build_plan(&spec).expect_err("must fail");
3401 insta::assert_snapshot!(
3402 "error_msg_missing_manifest",
3403 normalize_error_message(&format!("{err:#}"))
3404 );
3405 }
3406
3407 #[test]
3408 fn snapshot_error_message_unknown_selected_package() {
3409 let td = tempdir().expect("tempdir");
3410 create_workspace(td.path());
3411 let mut spec = spec_for(td.path());
3412 spec.selected_packages = Some(vec!["totally-unknown-crate".to_string()]);
3413 let err = build_plan(&spec).expect_err("must fail");
3414 insta::assert_snapshot!("error_msg_unknown_selected_package", format!("{err:#}"));
3415 }
3416
3417 #[test]
3418 fn snapshot_error_message_non_publishable_dep() {
3419 let td = tempdir().expect("tempdir");
3420 create_workspace_with_npdep(td.path(), true);
3421 let err = build_plan(&spec_for(td.path())).expect_err("must fail");
3422 insta::assert_snapshot!("error_msg_non_publishable_dep", format!("{err:#}"));
3423 }
3424
3425 #[test]
3426 fn snapshot_error_message_selecting_non_publishable() {
3427 let td = tempdir().expect("tempdir");
3428 create_workspace(td.path());
3429 let mut spec = spec_for(td.path());
3430 spec.selected_packages = Some(vec!["c".to_string()]);
3431 let err = build_plan(&spec).expect_err("must fail");
3432 insta::assert_snapshot!("error_msg_selecting_non_publishable", format!("{err:#}"));
3433 }
3434
3435 #[test]
3436 fn snapshot_error_message_cycle_detection() {
3437 let td = tempdir().expect("tempdir");
3438 create_workspace(td.path());
3439 let metadata = MetadataCommand::new()
3440 .manifest_path(td.path().join("Cargo.toml"))
3441 .exec()
3442 .expect("metadata");
3443
3444 let pkg_map = metadata
3445 .packages
3446 .iter()
3447 .map(|p| (p.id.clone(), p))
3448 .collect::<BTreeMap<PackageId, &cargo_metadata::Package>>();
3449 let mut by_name = BTreeMap::<String, PackageId>::new();
3450 for pkg in &metadata.packages {
3451 by_name.insert(pkg.name.to_string(), pkg.id.clone());
3452 }
3453
3454 let a = by_name.get("a").expect("a").clone();
3455 let b = by_name.get("b").expect("b").clone();
3456
3457 let included = [a.clone(), b.clone()].into_iter().collect::<BTreeSet<_>>();
3458 let deps_of = BTreeMap::from([
3459 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
3460 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
3461 ]);
3462 let dependents_of = BTreeMap::from([
3463 (a.clone(), [b.clone()].into_iter().collect::<BTreeSet<_>>()),
3464 (b.clone(), [a.clone()].into_iter().collect::<BTreeSet<_>>()),
3465 ]);
3466
3467 let err = topo_sort(&included, &deps_of, &dependents_of, &pkg_map).expect_err("cycle");
3468 insta::assert_snapshot!("error_msg_cycle_detection", format!("{err:#}"));
3469 }
3470}