Skip to main content

shipper_core/plan/
mod.rs

1//! # Plan
2//!
3//! Workspace analysis and deterministic publish-plan generation.
4//!
5//! This crate reads workspace metadata via `cargo_metadata`, filters
6//! publishable crates, and produces a topologically-sorted
7//! [`ReleasePlan`](shipper_types::ReleasePlan) that guarantees
8//! dependencies are published before their dependents.
9//!
10//! ## Workflow
11//!
12//! 1. Load workspace metadata from the given `Cargo.toml`.
13//! 2. Filter crates based on their `publish` field and the target registry.
14//! 3. Optionally narrow the set to user-selected packages (plus transitive deps).
15//! 4. Topologically sort the remaining crates and compute a stable plan ID.
16//!
17//! The resulting [`PlannedWorkspace`](shipper_types::PlannedWorkspace) is the
18//! input to preflight and publish operations in the engine crate.
19
20use 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
30/// Build a deterministic publish plan from a [`ReleaseSpec`].
31///
32/// Reads the workspace via `cargo_metadata`, filters publishable crates
33/// based on the target registry, topologically sorts them, and returns a
34/// [`PlannedWorkspace`] ready for preflight or publish execution.
35///
36/// # Errors
37///
38/// Returns an error if:
39/// - `cargo metadata` fails (e.g. invalid manifest path)
40/// - A selected package is not found or not publishable
41/// - A publishable crate depends on a non-publishable workspace member
42/// - A dependency cycle is detected
43pub 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    // Track skipped packages (publish=false or not in registry list)
56    let mut skipped: Vec<SkippedPackage> = Vec::new();
57
58    // Workspace publishable set (restricted by `[package] publish` where possible).
59    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                // Track why this package was skipped
67                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    // Build dependency edges A->deps (restricted to publishable workspace members).
83    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    // Determine which nodes to include.
120    let included: BTreeSet<PackageId> = if let Some(sel) = &spec.selected_packages {
121        // Map package name -> id (workspace publishable only).
122        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        // Include internal dependencies transitively.
144        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    // Validate: included crates must not have normal/build deps on non-publishable workspace members.
160    for node in &resolve.nodes {
161        if !included.contains(&node.id) {
162            continue;
163        }
164        for dep in &node.deps {
165            // Skip deps that are publishable or not workspace members
166            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    // Topological sort on included nodes.
192    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    // Build dependency map for level-based parallel publishing
207    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        // Get all dependencies of this package that are in the plan
213        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            // Cargo uses `crates-io` as the default registry name.
257            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    // Deterministic queue: sort by package name.
276    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        // When npdep is included (all packages selected), the error should fire.
499        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        // When only npdep is explicitly selected, the error should still fire.
509        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        // Selecting only "a" should succeed even though "npdep" (not selected)
527        // depends on non-publishable "c".
528        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        // alpha has a dev-dependency on a (which is publishable), but let's verify
541        // that the plan succeeds — dev-deps on non-publishable crates are also fine.
542        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    // --- Single-crate workspace ---
651
652    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        // Single crate has no internal deps
684        assert_eq!(ws.plan.dependencies.get("only").map(|v| v.len()), Some(0));
685    }
686
687    // --- Determinism: same input produces identical plans ---
688
689    #[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    // --- Skipped packages tracking ---
709
710    #[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        // c (publish = false) and d (publish = ["private-reg"]) should be skipped for crates-io
718        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    // --- Private registry: d is included when targeting "private-reg" ---
730
731    #[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        // d publishes to private-reg, so it should be included
748        assert!(names.contains(&"d"));
749        // c is publish=false, still excluded
750        assert!(!names.contains(&"c"));
751    }
752
753    // --- Dependencies map correctness ---
754
755    #[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        // b depends on a
762        let b_deps = ws.plan.dependencies.get("b").expect("b in deps map");
763        assert!(b_deps.contains(&"a".to_string()));
764        // a has no internal deps
765        let a_deps = ws.plan.dependencies.get("a").expect("a in deps map");
766        assert!(a_deps.is_empty());
767        // alpha has dev-dep on a, which is NOT a normal dep so shouldn't appear
768        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    // --- Plan version ---
780
781    #[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    // --- publish_allowed unit tests ---
794
795    #[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        // "only" has no publish field (None) — should be allowed for any registry
804        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        // "c" has publish = false → blocked everywhere
822        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        // "d" has publish = ["private-reg"]
836        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    // --- compute_plan_id changes when inputs differ ---
842
843    #[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    // --- Workspace root is set correctly ---
897
898    #[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        // The workspace_root should be a real path pointing at our temp dir
905        assert!(ws.workspace_root.exists());
906    }
907
908    // --- topo_sort with no deps (all independent) produces name-sorted order ---
909
910    #[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        // Two independent nodes with no edges
933        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    // --- Multi-crate deep chain ---
952
953    #[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        // Dependencies map: z->y, y->x, x->[]
1006        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    // --- All crates unpublishable produces empty plan ---
1012
1013    #[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    // --- Selecting a non-publishable package errors ---
1043
1044    #[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        // c is publish=false, not in the publishable set
1051        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    // --- Plan registry matches spec registry ---
1057
1058    #[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    // ── Insta snapshot helpers ──────────────────────────────────────────
1069
1070    /// Stable, redacted summary of a plan suitable for snapshot testing.
1071    /// Dynamic fields (plan_id, created_at, manifest_path, workspace_root) are
1072    /// replaced with deterministic placeholders so snapshots stay stable across
1073    /// machines and runs.
1074    #[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    // ── Insta snapshot tests ────────────────────────────────────────────
1106
1107    #[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    // ── Empty workspace (all packages unpublishable) ──────────────────
1305
1306    #[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    // ── Linear dependency chain: A → B → C → D ────────────────────
1347
1348    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        // Verify dependency map
1418        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        // chain-b depends on chain-c which depends on chain-d
1434        assert_eq!(names, vec!["chain-d", "chain-c", "chain-b"]);
1435    }
1436
1437    // ── Diamond dependency: A → B, A → C, B → D, C → D ────────────
1438
1439    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        // D must come first (no deps), then B and C (alphabetical, both depend on D), then A
1509        assert_eq!(
1510            names,
1511            vec!["diamond-d", "diamond-b", "diamond-c", "diamond-a"]
1512        );
1513
1514        // Verify dependency edges
1515        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        // D before B, D before C, B before A, C before A
1542        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    // ── Wide flat workspace: 20 packages with no dependencies ──────
1549
1550    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        // All packages should have empty dependency lists
1590        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        // Independent packages are sorted alphabetically
1596        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    // ── Package names with special characters (hyphens, underscores) ──
1603
1604    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        // my_underscore_pkg depends on my-hyphen-pkg, so hyphen comes first
1662        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    // ── Snapshot tests for various plan topologies ──────────────────
1685
1686    #[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    // ── Plan stability: same input always produces same order ───────
1761
1762    #[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    // ── Build-dependency ordering ─────────────────────────────────────
1828
1829    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        // Build-dep codegen must appear before app
1872        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    // ── Multiple package selection ────────────────────────────────────
1886
1887    #[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        // Select both "b" (depends on "a") and "zeta" (independent)
1894        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        // "a" is pulled in transitively by "b"
1898        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    // ── Selecting leaf is standalone ──────────────────────────────────
1913
1914    #[test]
1915    fn build_plan_selecting_leaf_is_standalone() {
1916        let td = tempdir().expect("tempdir");
1917        create_diamond_workspace(td.path());
1918
1919        // diamond-d is the leaf (no deps); selecting it gives just that one
1920        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    // ── Selecting all packages equals no selection ────────────────────
1928
1929    #[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    // ── Three-node cycle detection ───────────────────────────────────
1957
1958    #[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        // Synthetic cycle: a -> b -> alpha -> a
1982        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    // ── Mixed versions ───────────────────────────────────────────────
2013
2014    #[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    // ── Plan ID differs for different selections ─────────────────────
2057
2058    #[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    // ── Dev-deps excluded from transitive closure ────────────────────
2073
2074    #[test]
2075    fn build_plan_dev_deps_excluded_from_transitive() {
2076        let td = tempdir().expect("tempdir");
2077        create_workspace(td.path());
2078
2079        // "alpha" has a dev-dep on "a"; selecting "alpha" should NOT pull in "a"
2080        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    // ── compute_plan_id boundary: name@version separator ─────────────
2088
2089    #[test]
2090    fn compute_plan_id_no_collision_on_name_version_boundary() {
2091        // Ensure "foo@1.0.0" and "fo@o1.0.0" produce different IDs
2092        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    // ── compute_plan_id is order-sensitive ────────────────────────────
2108
2109    #[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    // ── compute_plan_id is valid SHA256 hex ──────────────────────────
2127
2128    #[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    // ── Dependencies map keys match planned packages exactly ─────────
2151
2152    #[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    // ── Plan stability for build-dep workspace ───────────────────────
2164
2165    #[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(&registry, &pkgs);
2203            let id2 = compute_plan_id(&registry, &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        /// Property: plan_id is deterministic — same registry + packages = same id.
2210        #[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(&registry, &pkgs);
2224            let id2 = compute_plan_id(&registry, &pkgs);
2225            prop_assert_eq!(id1, id2);
2226        }
2227
2228        /// Property: plan ordering respects all dependencies (for linear chains).
2229        /// Generates chains of length 1..6 and verifies topo order.
2230        #[test]
2231        fn prop_plan_ordering_respects_dependencies(chain_len in 1usize..7) {
2232            // Build a linear chain workspace on disk and verify ordering
2233            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            // Verify: for every package, all its deps appear earlier in the plan
2266            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        /// Property: different package lists produce different plan IDs (high probability).
2282        #[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            // Only test when inputs actually differ
2290            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        /// Property: independent packages are always sorted alphabetically.
2307        #[test]
2308        fn prop_independent_packages_sorted_alphabetically(count in 2usize..8) {
2309            let td = tempdir().expect("tempdir");
2310            // Generate sorted unique names so we can predict the order
2311            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        /// Property: for any generated DAG (diamond-ish), topo sort guarantees
2338        /// all deps appear before their dependents.
2339        #[test]
2340        fn prop_diamond_dag_deps_before_dependents(
2341            extra_leaves in 0usize..4,
2342        ) {
2343            // Build: base -> [mid-0..mid-N] -> top, plus extra independent leaves
2344            let mut members = vec!["base".to_string()];
2345            let mid_count = 2 + extra_leaves; // at least 2 middle nodes
2346            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            // base: no deps
2365            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            // mid-N: depends on base
2372            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            // top: depends on all mid-N
2384            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            // leaf-N: independent
2399            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            // Verify: every dep appears before its dependent
2414            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        /// Property: the plan always contains exactly as many dependency-map entries
2430        /// as there are packages.
2431        #[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    // ── Double diamond: base → [m1, m2] → mid → [t1, t2] → top ────
2465
2466    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        // dd-base: no deps
2476        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        // dd-m1, dd-m2: depend on dd-base
2488        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        // dd-mid: depends on dd-m1 and dd-m2
2507        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        // dd-t1, dd-t2: depend on dd-mid
2523        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        // dd-top: depends on dd-t1 and dd-t2
2542        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        // Layer ordering: base < m1,m2 < mid < t1,t2 < top
2570        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    // ── Fan-in: many crates depend on one root ──────────────────────
2609
2610    #[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        // All fan-NN crates come after root and are sorted alphabetically
2645        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    // ── Fan-out: one crate depends on many roots ────────────────────
2653
2654    #[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        // consumer must be last (it depends on all bases)
2695        assert_eq!(*names.last().unwrap(), "consumer");
2696        // All bases come before consumer, sorted alphabetically
2697        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    // ── Combined build-dep + runtime dep ────────────────────────────
2704
2705    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        // Both build-tool and runtime-lib must come before app-mixed
2762        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        // app-mixed's deps should include both
2768        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    // ── Dev-dep on non-publishable workspace member doesn't error ────
2786
2787    #[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        // pub-crate has no normal/build deps in the plan
2829        assert!(ws.plan.dependencies["pub-crate"].is_empty());
2830    }
2831
2832    // ── Dev-dep would-be cycle (not a real cycle since dev-deps excluded) ──
2833
2834    #[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        // crate-x depends on crate-y (normal)
2846        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        // crate-y dev-depends on crate-x (would be cycle if counted)
2860        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        // y must come before x (x depends on y normally)
2877        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    // ── Explicit publish = ["crates-io"] behaves like publish = None ──
2883
2884    #[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    // ── Multiple registries in publish list ──────────────────────────
2914
2915    #[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        // Included for crates-io
2939        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        // Included for private-reg
2944        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        // Excluded for unknown registry
2957        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    // ── Pre-release and build-metadata versions ─────────────────────
2972
2973    #[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    // ── Plan ID changes when version bumps ──────────────────────────
3016
3017    #[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        // Bump version
3042        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    // ── Selecting middle of diamond pulls both leaves ────────────────
3058
3059    #[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        // Select dd-mid → should pull in dd-m1, dd-m2, dd-base
3065        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    // ── Selecting top of double diamond pulls everything ─────────────
3080
3081    #[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    // ── W-shape graph: two independent diamonds sharing nothing ──────
3093
3094    #[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        // Left diamond: l-base → l-mid → l-top
3106        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        // Right chain: r-base → r-mid → r-top
3122        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        // Left chain ordering
3144        assert!(pos("l-base") < pos("l-mid"));
3145        assert!(pos("l-mid") < pos("l-top"));
3146        // Right chain ordering
3147        assert!(pos("r-base") < pos("r-mid"));
3148        assert!(pos("r-mid") < pos("r-top"));
3149    }
3150
3151    // ── Selection from W-shape picks only one subgraph ───────────────
3152
3153    #[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    // ── Plan with workspace-level version inheritance ────────────────
3193
3194    #[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    // ── Skipped package reason strings ──────────────────────────────
3227
3228    #[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    // ── compute_plan_id: single pkg vs two identical pkgs ───────────
3279
3280    #[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    // ── Snapshot for double diamond with selection ────────────────────
3293
3294    #[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    // ── error message quality snapshots ──────────────────────────────────
3387
3388    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}