Skip to main content

cabin_workspace/
selection.rs

1//! `WorkspacePackage` selection across a [`PackageGraph`].
2//!
3//! `cabin` translates user flags (`--workspace`, `--package`,
4//! `--exclude`, `--default-members`) into a [`PackageSelection`]
5//! and hands it to [`resolve_package_selection`], which validates
6//! the request against the graph and returns the deterministic
7//! ordered list of selected primary-package indices. Centralizing
8//! this here keeps CLI code free of workspace-graph algorithms.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::error::WorkspaceError;
13use crate::graph::PackageGraph;
14
15/// Selection mode the user requested.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SelectionMode {
18    /// Default behavior:
19    ///
20    /// - inside a single-package package (no `[workspace]`), select
21    ///   the root package;
22    /// - at a workspace root, select `[workspace.default-members]`
23    ///   when present, otherwise fall back to **all** workspace
24    ///   members. The fallback rule is documented in
25    ///   [`docs/workspaces.md`](../../../docs/workspaces.md).
26    CurrentPackage,
27    /// `--default-members`. Errors when the workspace declares no
28    /// `[workspace.default-members]`.
29    DefaultMembers,
30    /// `--workspace`. Selects every workspace member, then applies
31    /// `--exclude` filtering.
32    WholeWorkspace,
33    /// `-p` / `--package`. Selects exactly the named packages (each
34    /// must be a workspace member).
35    ExplicitPackages(Vec<String>),
36}
37
38/// User-facing selection request, before validation against any
39/// concrete graph.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct PackageSelection {
42    pub mode: SelectionMode,
43    /// Packages to drop from the resolved selection. Only valid in
44    /// combination with `WholeWorkspace` and `DefaultMembers`.
45    pub exclude: Vec<String>,
46}
47
48impl PackageSelection {
49    pub fn current_package() -> Self {
50        Self {
51            mode: SelectionMode::CurrentPackage,
52            exclude: Vec::new(),
53        }
54    }
55}
56
57/// Final, validated selection. Indices are into [`PackageGraph::packages`]
58/// and are ordered to match the graph's primary-package ordering.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ResolvedSelection {
61    pub packages: Vec<usize>,
62}
63
64impl ResolvedSelection {
65    /// Closure of the selection over local
66    /// path-dependency edges. Includes every package reachable from
67    /// `self.packages` by walking `WorkspacePackage::deps` transitively, in
68    /// deterministic ascending-index order. Workspace siblings that
69    /// the selection neither names nor pulls in via path deps are
70    /// **not** in the closure — that is the whole point of this
71    /// helper.
72    pub fn closure(&self, graph: &PackageGraph) -> BTreeSet<usize> {
73        let mut closure: BTreeSet<usize> = BTreeSet::new();
74        let mut stack: Vec<usize> = self.packages.clone();
75        while let Some(idx) = stack.pop() {
76            if !closure.insert(idx) {
77                continue;
78            }
79            for edge in &graph.packages[idx].deps {
80                if !closure.contains(&edge.index) {
81                    stack.push(edge.index);
82                }
83            }
84        }
85        closure
86    }
87
88    /// Names of every package in the selection's path-dependency
89    /// [`closure`](Self::closure), in deterministic order. Convenience
90    /// over `closure(graph)` for the common case where a caller needs a
91    /// set of package *names* — e.g. to seed a strict registry / port
92    /// policy — rather than graph indices.
93    pub fn closure_package_names(&self, graph: &PackageGraph) -> BTreeSet<String> {
94        self.closure(graph)
95            .into_iter()
96            .map(|i| graph.packages[i].package.name.as_str().to_owned())
97            .collect()
98    }
99}
100
101/// Validate a [`PackageSelection`] against `graph` and return the
102/// concrete list of selected primary-package indices. Errors are
103/// emitted with deterministic, actionable messages so the user can
104/// fix typos quickly.
105///
106/// # Errors
107/// Returns a [`WorkspaceError`] when the selection is invalid:
108/// [`WorkspaceError::ExcludeWithoutWorkspaceSelection`] for
109/// `--exclude` outside a workspace selection,
110/// [`WorkspaceError::DefaultMembersWithoutWorkspace`] or
111/// [`WorkspaceError::DefaultMemberNotInMembers`] for default-member
112/// modes that don't apply, [`WorkspaceError::PackageNotInWorkspace`]
113/// for an unknown or non-primary named/excluded package, and
114/// [`WorkspaceError::AmbiguousPackageSelection`] when the selection
115/// resolves to no packages.
116pub fn resolve_package_selection(
117    graph: &PackageGraph,
118    selection: &PackageSelection,
119) -> Result<ResolvedSelection, WorkspaceError> {
120    // `--exclude` requires an explicit
121    // `--workspace` or `--default-members` mode. The
122    // implicit-default `CurrentPackage` mode no longer accepts
123    // `--exclude`: the user must opt into a multi-package
124    // selection before excluding from it. Stricter behavior
125    // matches Cargo and stops `cabin <cmd> --exclude foo` from
126    // silently doing the wrong thing on a single-package package.
127    let exclusion_compatible = matches!(
128        selection.mode,
129        SelectionMode::WholeWorkspace | SelectionMode::DefaultMembers
130    );
131    if !selection.exclude.is_empty() && !exclusion_compatible {
132        return Err(WorkspaceError::ExcludeWithoutWorkspaceSelection);
133    }
134
135    let exclude_indices = exclude_indices(graph, &selection.exclude)?;
136
137    let candidates: Vec<usize> = match &selection.mode {
138        SelectionMode::CurrentPackage => current_package_default(graph),
139        SelectionMode::DefaultMembers => {
140            if !graph.is_workspace_root {
141                return Err(WorkspaceError::DefaultMembersWithoutWorkspace);
142            }
143            if graph.default_members.is_empty() {
144                return Err(WorkspaceError::DefaultMemberNotInMembers {
145                    member: "<no default-members declared>".to_owned(),
146                });
147            }
148            graph.default_members.clone()
149        }
150        SelectionMode::WholeWorkspace => {
151            if graph.is_workspace_root {
152                graph.primary_packages.clone()
153            } else {
154                // `--workspace` against a single-package package
155                // simply selects that package — keeps CI users from
156                // having to special-case a non-workspace tree.
157                current_package_default(graph)
158            }
159        }
160        SelectionMode::ExplicitPackages(names) => {
161            let mut out = Vec::with_capacity(names.len());
162            for name in names {
163                let idx =
164                    graph
165                        .index_of(name)
166                        .ok_or_else(|| WorkspaceError::PackageNotInWorkspace {
167                            name: name.clone(),
168                            members: workspace_member_names(graph),
169                        })?;
170                if !graph.primary_packages.contains(&idx) {
171                    return Err(WorkspaceError::PackageNotInWorkspace {
172                        name: name.clone(),
173                        members: workspace_member_names(graph),
174                    });
175                }
176                if !out.contains(&idx) {
177                    out.push(idx);
178                }
179            }
180            out
181        }
182    };
183
184    let mut packages: Vec<usize> = candidates
185        .into_iter()
186        .filter(|i| !exclude_indices.contains(i))
187        .collect();
188    // Stable, deterministic ordering: by package name.
189    packages.sort_by(|a, b| {
190        graph.packages[*a]
191            .package
192            .name
193            .as_str()
194            .cmp(graph.packages[*b].package.name.as_str())
195    });
196    if packages.is_empty() {
197        return Err(WorkspaceError::AmbiguousPackageSelection);
198    }
199    Ok(ResolvedSelection { packages })
200}
201
202fn current_package_default(graph: &PackageGraph) -> Vec<usize> {
203    if graph.is_workspace_root {
204        if graph.default_members.is_empty() {
205            // Documented fallback: all workspace members
206            // when default-members is absent.
207            graph.primary_packages.clone()
208        } else {
209            graph.default_members.clone()
210        }
211    } else if let Some(root) = graph.root_package {
212        vec![root]
213    } else {
214        graph.primary_packages.clone()
215    }
216}
217
218fn exclude_indices(
219    graph: &PackageGraph,
220    excludes: &[String],
221) -> Result<BTreeSet<usize>, WorkspaceError> {
222    let mut out = BTreeSet::new();
223    for name in excludes {
224        let idx = graph
225            .index_of(name)
226            .ok_or_else(|| WorkspaceError::PackageNotInWorkspace {
227                name: name.clone(),
228                members: workspace_member_names(graph),
229            })?;
230        if !graph.primary_packages.contains(&idx) {
231            return Err(WorkspaceError::PackageNotInWorkspace {
232                name: name.clone(),
233                members: workspace_member_names(graph),
234            });
235        }
236        out.insert(idx);
237    }
238    Ok(out)
239}
240
241/// Combine several version-requirement strings into one
242/// [`semver::VersionReq`] by joining them with `, ` (the comma form
243/// semver reads as an AND of comparators) and re-parsing. On a parse
244/// failure the joined string is returned alongside the error so each
245/// caller can build its own diagnostic. This is the single
246/// join-on-collision kernel shared by the closure and patch
247/// requirement aggregators and the CLI's root-dep merge.
248///
249/// # Errors
250/// Returns `Err((joined, source))` — the comma-joined requirement
251/// string paired with the [`semver::Error`] — when the joined form
252/// is not a valid [`semver::VersionReq`] (the requirements are
253/// mutually incompatible).
254pub fn combine_version_reqs(
255    reqs: &[String],
256) -> Result<semver::VersionReq, (String, semver::Error)> {
257    let joined = reqs.join(", ");
258    match semver::VersionReq::parse(&joined) {
259        Ok(req) => Ok(req),
260        Err(source) => Err((joined, source)),
261    }
262}
263
264/// The per-dependency eligibility predicate shared by the versioned-dep
265/// aggregators. Returns the [`semver::VersionReq`] when `dep` is an active
266/// registry-versioned dependency for this invocation — right kind (normal
267/// kinds, plus `Dev` when `dev_active_here`), matches the host platform,
268/// optional only if enabled, and not excluded — otherwise `None`. `idx` is
269/// the declaring package's closure index, threaded so the optional gate can
270/// consult `is_optional_dep_enabled`.
271fn versioned_dep_active<'a, F>(
272    dep: &'a cabin_core::Dependency,
273    idx: usize,
274    dev_active_here: bool,
275    host: &cabin_core::TargetPlatform,
276    is_optional_dep_enabled: &F,
277    excluded_names: &BTreeSet<String>,
278) -> Option<&'a semver::VersionReq>
279where
280    F: Fn(usize, &str) -> bool,
281{
282    use cabin_core::{DependencyKind, DependencySource};
283    let kind_active =
284        dep.kind.is_resolved_by_default() || (dev_active_here && dep.kind == DependencyKind::Dev);
285    if !kind_active {
286        return None;
287    }
288    if !dep.matches_platform(host) {
289        return None;
290    }
291    if dep.optional && !is_optional_dep_enabled(idx, dep.name.as_str()) {
292        return None;
293    }
294    if excluded_names.contains(dep.name.as_str()) {
295        return None;
296    }
297    match &dep.source {
298        DependencySource::Version(req) => Some(req),
299        _ => None,
300    }
301}
302
303/// Enumerate the versioned dependencies that drive
304/// resolve / fetch / update for a selected package set. Walks the
305/// closure (selection + transitive local path deps) so a registry
306/// dep declared by a path-dep `lib` is visible when the user
307/// selected `app`.
308///
309/// Only dependency kinds that participate in ordinary resolution
310/// (`Normal`, `Build`, `Tool`) are included. Dev dependencies are
311/// excluded so a workspace member's dev-only requirement cannot
312/// break an ordinary `cabin build` / `cabin fetch`. System
313/// dependencies never reach this path because they are never
314/// stored as `DependencySource::Version`.
315///
316/// Optional dependencies are filtered using `is_optional_dep_enabled`:
317/// the closure is `(declaring_package_index, dep_name) -> included`.
318/// Pass `|_, _| false` to include only non-optional deps; pass a
319/// closure backed by a feature resolution to include only optional
320/// deps the user asked for.
321///
322/// Conflicting requirements for the same name (across different
323/// packages or kinds) are joined with `, ` — a form
324/// `semver::VersionReq` accepts — and re-parsed; truly
325/// incompatible requirements surface as a clear parse error
326/// rather than silent unification.
327///
328/// `excluded_names` drops every dependency name in the set —
329/// typically used by the artifact pipeline to skip patched
330/// packages that ship from a local working copy and never need
331/// to be fetched from the index.
332///
333/// `dev_active_for` opts in `[dev-dependencies]` for the named
334/// packages (typically the `cabin test` selection). Dev deps for
335/// packages not in this set stay declaration-only, matching the
336/// `cabin build` policy.
337///
338/// # Errors
339/// Returns [`WorkspaceError::IncompatibleWorkspaceRequirements`]
340/// when the requirements collected for a single dependency name
341/// cannot be combined into one [`semver::VersionReq`] (the joined
342/// requirement string fails to parse).
343///
344/// # Panics
345/// Panics only if the name-lookup invariant were violated: every
346/// dependency name pushed into `combined` is inserted into
347/// `name_lookup` in the same loop iteration, so the `.unwrap()` on
348/// `name_lookup.remove(&name)` always finds the key.
349pub fn collect_closure_versioned_deps_excluding_with_dev<F>(
350    graph: &PackageGraph,
351    closure: &BTreeSet<usize>,
352    is_optional_dep_enabled: F,
353    excluded_names: &BTreeSet<String>,
354    dev_active_for: &BTreeSet<String>,
355) -> Result<BTreeMap<cabin_core::PackageName, semver::VersionReq>, WorkspaceError>
356where
357    F: Fn(usize, &str) -> bool,
358{
359    // Conditional dependencies are evaluated against the host
360    // platform — Cabin does not yet support cross-compilation.
361    let host_platform = cabin_core::TargetPlatform::current();
362    let mut combined: BTreeMap<String, Vec<String>> = BTreeMap::new();
363    let mut name_lookup: BTreeMap<String, cabin_core::PackageName> = BTreeMap::new();
364    for &idx in closure {
365        let pkg = &graph.packages[idx];
366        // Skip registry packages — their declared deps are already
367        // covered by the registry's own metadata, not by the
368        // workspace user's manifests.
369        if !matches!(pkg.kind, crate::graph::PackageKind::Local) {
370            continue;
371        }
372        let dev_active_here = dev_active_for.contains(pkg.package.name.as_str());
373        for dep in &pkg.package.dependencies {
374            if let Some(req) = versioned_dep_active(
375                dep,
376                idx,
377                dev_active_here,
378                &host_platform,
379                &is_optional_dep_enabled,
380                excluded_names,
381            ) {
382                let key = dep.name.as_str().to_owned();
383                combined
384                    .entry(key.clone())
385                    .or_default()
386                    .push(req.to_string());
387                name_lookup.insert(key, dep.name.clone());
388            }
389        }
390    }
391    let mut out = BTreeMap::new();
392    for (name, mut reqs) in combined {
393        reqs.sort();
394        reqs.dedup();
395        let parsed = combine_version_reqs(&reqs).map_err(|(requirements, source)| {
396            WorkspaceError::IncompatibleWorkspaceRequirements {
397                name: name.clone(),
398                requirements,
399                source,
400            }
401        })?;
402        out.insert(name_lookup.remove(&name).unwrap(), parsed);
403    }
404    Ok(out)
405}
406
407/// Whether the supplied closure carries any versioned
408/// (registry-bound) dependency that the artifact pipeline would
409/// need to fetch. Mirrors
410/// [`collect_closure_versioned_deps_excluding_with_dev`] but
411/// returns a `bool` so the CLI can short-circuit before opening
412/// an index.
413///
414/// `dev_active_for` follows the same opt-in policy as
415/// [`collect_closure_versioned_deps_excluding_with_dev`].
416pub fn closure_has_versioned_deps_excluding_with_dev<F>(
417    graph: &PackageGraph,
418    closure: &BTreeSet<usize>,
419    is_optional_dep_enabled: F,
420    excluded_names: &BTreeSet<String>,
421    dev_active_for: &BTreeSet<String>,
422) -> bool
423where
424    F: Fn(usize, &str) -> bool,
425{
426    let host_platform = cabin_core::TargetPlatform::current();
427    closure.iter().any(|&idx| {
428        let pkg = &graph.packages[idx];
429        if !matches!(pkg.kind, crate::graph::PackageKind::Local) {
430            return false;
431        }
432        let dev_active_here = dev_active_for.contains(pkg.package.name.as_str());
433        pkg.package.dependencies.iter().any(|dep| {
434            versioned_dep_active(
435                dep,
436                idx,
437                dev_active_here,
438                &host_platform,
439                &is_optional_dep_enabled,
440                excluded_names,
441            )
442            .is_some()
443        })
444    })
445}
446
447fn workspace_member_names(graph: &PackageGraph) -> Vec<String> {
448    let mut names: Vec<String> = graph
449        .primary_packages
450        .iter()
451        .map(|i| graph.packages[*i].package.name.as_str().to_owned())
452        .collect();
453    names.sort();
454    names
455}
456
457#[cfg(test)]
458mod tests {
459    use std::fmt::Write as _;
460
461    use super::*;
462    use crate::loader::load_workspace;
463    use assert_fs::TempDir;
464    use assert_fs::prelude::*;
465
466    fn workspace_with_two_members(default_members: Option<&str>) -> TempDir {
467        let dir = TempDir::new().unwrap();
468        let mut root = String::from("[workspace]\nmembers = [\"packages/*\"]\n");
469        if let Some(dm) = default_members {
470            writeln!(root, "default-members = [\"packages/{dm}\"]").unwrap();
471        }
472        dir.child("cabin.toml").write_str(&root).unwrap();
473        dir.child("packages/a/cabin.toml")
474            .write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
475            .unwrap();
476        dir.child("packages/b/cabin.toml")
477            .write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
478            .unwrap();
479        dir
480    }
481
482    fn names(graph: &PackageGraph, sel: &ResolvedSelection) -> Vec<String> {
483        sel.packages
484            .iter()
485            .map(|i| graph.packages[*i].package.name.as_str().to_owned())
486            .collect()
487    }
488
489    #[test]
490    fn current_package_falls_back_to_all_members_without_defaults() {
491        let dir = workspace_with_two_members(None);
492        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
493        let sel = resolve_package_selection(&graph, &PackageSelection::current_package()).unwrap();
494        assert_eq!(names(&graph, &sel), vec!["a", "b"]);
495    }
496
497    #[test]
498    fn current_package_uses_declared_defaults() {
499        let dir = workspace_with_two_members(Some("a"));
500        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
501        let sel = resolve_package_selection(&graph, &PackageSelection::current_package()).unwrap();
502        assert_eq!(names(&graph, &sel), vec!["a"]);
503    }
504
505    #[test]
506    fn whole_workspace_selects_all_members() {
507        let dir = workspace_with_two_members(Some("a"));
508        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
509        let sel = resolve_package_selection(
510            &graph,
511            &PackageSelection {
512                mode: SelectionMode::WholeWorkspace,
513                exclude: Vec::new(),
514            },
515        )
516        .unwrap();
517        assert_eq!(names(&graph, &sel), vec!["a", "b"]);
518    }
519
520    #[test]
521    fn whole_workspace_with_exclude_drops_member() {
522        let dir = workspace_with_two_members(None);
523        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
524        let sel = resolve_package_selection(
525            &graph,
526            &PackageSelection {
527                mode: SelectionMode::WholeWorkspace,
528                exclude: vec!["b".into()],
529            },
530        )
531        .unwrap();
532        assert_eq!(names(&graph, &sel), vec!["a"]);
533    }
534
535    #[test]
536    fn explicit_package_selects_named_member() {
537        let dir = workspace_with_two_members(None);
538        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
539        let sel = resolve_package_selection(
540            &graph,
541            &PackageSelection {
542                mode: SelectionMode::ExplicitPackages(vec!["a".into()]),
543                exclude: Vec::new(),
544            },
545        )
546        .unwrap();
547        assert_eq!(names(&graph, &sel), vec!["a"]);
548    }
549
550    #[test]
551    fn explicit_package_unknown_errors() {
552        let dir = workspace_with_two_members(None);
553        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
554        let err = resolve_package_selection(
555            &graph,
556            &PackageSelection {
557                mode: SelectionMode::ExplicitPackages(vec!["nope".into()]),
558                exclude: Vec::new(),
559            },
560        )
561        .unwrap_err();
562        assert!(matches!(err, WorkspaceError::PackageNotInWorkspace { .. }));
563    }
564
565    #[test]
566    fn default_members_mode_errors_when_none_declared() {
567        let dir = workspace_with_two_members(None);
568        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
569        let err = resolve_package_selection(
570            &graph,
571            &PackageSelection {
572                mode: SelectionMode::DefaultMembers,
573                exclude: Vec::new(),
574            },
575        )
576        .unwrap_err();
577        assert!(matches!(
578            err,
579            WorkspaceError::DefaultMemberNotInMembers { .. }
580        ));
581    }
582
583    #[test]
584    fn exclude_with_explicit_packages_errors() {
585        let dir = workspace_with_two_members(None);
586        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
587        let err = resolve_package_selection(
588            &graph,
589            &PackageSelection {
590                mode: SelectionMode::ExplicitPackages(vec!["a".into()]),
591                exclude: vec!["b".into()],
592            },
593        )
594        .unwrap_err();
595        assert!(matches!(
596            err,
597            WorkspaceError::ExcludeWithoutWorkspaceSelection
598        ));
599    }
600
601    // -----------------------------------------------------------------
602    // closure + versioned-deps helpers.
603    // -----------------------------------------------------------------
604
605    /// Workspace where `app` depends on `lib` via path. Selecting
606    /// `app` must include `lib` in the closure; `unrelated` must
607    /// not be in the closure.
608    fn three_member_workspace_app_lib_unrelated() -> TempDir {
609        let dir = TempDir::new().unwrap();
610        dir.child("cabin.toml")
611            .write_str(
612                r#"[workspace]
613members = ["packages/*"]
614"#,
615            )
616            .unwrap();
617        dir.child("packages/app/cabin.toml")
618            .write_str(
619                r#"[package]
620name = "app"
621version = "0.1.0"
622
623[dependencies]
624lib = { path = "../lib" }
625"#,
626            )
627            .unwrap();
628        dir.child("packages/lib/cabin.toml")
629            .write_str(
630                r#"[package]
631name = "lib"
632version = "0.1.0"
633
634[dependencies]
635fmt = ">=10 <11"
636"#,
637            )
638            .unwrap();
639        dir.child("packages/unrelated/cabin.toml")
640            .write_str(
641                r#"[package]
642name = "unrelated"
643version = "0.1.0"
644
645[dependencies]
646spdlog = "^1"
647"#,
648            )
649            .unwrap();
650        dir
651    }
652
653    #[test]
654    fn closure_includes_local_path_dependency() {
655        let dir = three_member_workspace_app_lib_unrelated();
656        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
657        let sel = resolve_package_selection(
658            &graph,
659            &PackageSelection {
660                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
661                exclude: Vec::new(),
662            },
663        )
664        .unwrap();
665        let closure = sel.closure(&graph);
666        let names: Vec<&str> = closure
667            .iter()
668            .map(|i| graph.packages[*i].package.name.as_str())
669            .collect();
670        assert!(names.contains(&"app"), "closure missing app: {names:?}");
671        assert!(names.contains(&"lib"), "closure missing lib: {names:?}");
672        assert!(
673            !names.contains(&"unrelated"),
674            "closure leaked unrelated: {names:?}"
675        );
676    }
677
678    #[test]
679    fn versioned_deps_walks_path_dep_closure() {
680        let dir = three_member_workspace_app_lib_unrelated();
681        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
682        let sel = resolve_package_selection(
683            &graph,
684            &PackageSelection {
685                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
686                exclude: Vec::new(),
687            },
688        )
689        .unwrap();
690        let closure = sel.closure(&graph);
691        let deps = collect_closure_versioned_deps_excluding_with_dev(
692            &graph,
693            &closure,
694            |_, _| false,
695            &BTreeSet::new(),
696            &BTreeSet::new(),
697        )
698        .unwrap();
699        let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
700        assert_eq!(keys, vec!["fmt"], "expected only fmt, got {keys:?}");
701    }
702
703    #[test]
704    fn versioned_deps_skip_unrelated_workspace_members() {
705        let dir = three_member_workspace_app_lib_unrelated();
706        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
707        let sel = resolve_package_selection(
708            &graph,
709            &PackageSelection {
710                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
711                exclude: Vec::new(),
712            },
713        )
714        .unwrap();
715        let closure = sel.closure(&graph);
716        let deps = collect_closure_versioned_deps_excluding_with_dev(
717            &graph,
718            &closure,
719            |_, _| false,
720            &BTreeSet::new(),
721            &BTreeSet::new(),
722        )
723        .unwrap();
724        assert!(
725            !deps.contains_key(&cabin_core::PackageName::new("spdlog").unwrap()),
726            "unrelated spdlog leaked into closure deps"
727        );
728    }
729
730    /// Dev dependencies are excluded from ordinary resolution.
731    /// The closure walker must respect that policy so a workspace
732    /// member's `[dev-dependencies]` requirement cannot block an
733    /// ordinary `cabin build` / `cabin fetch`.
734    #[test]
735    fn versioned_deps_excludes_dev_kind() {
736        let dir = TempDir::new().unwrap();
737        dir.child("cabin.toml")
738            .write_str(
739                r#"[workspace]
740members = ["packages/app"]
741"#,
742            )
743            .unwrap();
744        dir.child("packages/app/cabin.toml")
745            .write_str(
746                r#"[package]
747name = "app"
748version = "0.1.0"
749
750[dependencies]
751fmt = ">=10"
752
753[dev-dependencies]
754gtest = "^1.14"
755"#,
756            )
757            .unwrap();
758        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
759        let sel = resolve_package_selection(
760            &graph,
761            &PackageSelection {
762                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
763                exclude: Vec::new(),
764            },
765        )
766        .unwrap();
767        let closure = sel.closure(&graph);
768        let deps = collect_closure_versioned_deps_excluding_with_dev(
769            &graph,
770            &closure,
771            |_, _| false,
772            &BTreeSet::new(),
773            &BTreeSet::new(),
774        )
775        .unwrap();
776        let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
777        assert_eq!(keys, vec!["fmt"]);
778    }
779
780    #[test]
781    fn excluded_names_are_dropped_from_versioned_deps() {
782        let dir = TempDir::new().unwrap();
783        dir.child("cabin.toml")
784            .write_str(
785                r#"[workspace]
786members = ["packages/app"]
787"#,
788            )
789            .unwrap();
790        dir.child("packages/app/cabin.toml")
791            .write_str(
792                r#"[package]
793name = "app"
794version = "0.1.0"
795
796[dependencies]
797fmt = ">=10"
798spdlog = "^1"
799"#,
800            )
801            .unwrap();
802        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
803        let sel = resolve_package_selection(
804            &graph,
805            &PackageSelection {
806                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
807                exclude: Vec::new(),
808            },
809        )
810        .unwrap();
811        let closure = sel.closure(&graph);
812        let mut excluded: BTreeSet<String> = BTreeSet::new();
813        excluded.insert("fmt".into());
814        let deps = collect_closure_versioned_deps_excluding_with_dev(
815            &graph,
816            &closure,
817            |_, _| false,
818            &excluded,
819            &BTreeSet::new(),
820        )
821        .unwrap();
822        let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
823        assert_eq!(keys, vec!["spdlog"]);
824    }
825
826    #[test]
827    fn closure_has_versioned_deps_excluding_returns_false_when_only_dep_is_excluded() {
828        let dir = TempDir::new().unwrap();
829        dir.child("cabin.toml")
830            .write_str(
831                r#"[workspace]
832members = ["packages/app"]
833"#,
834            )
835            .unwrap();
836        dir.child("packages/app/cabin.toml")
837            .write_str(
838                r#"[package]
839name = "app"
840version = "0.1.0"
841
842[dependencies]
843fmt = ">=10"
844"#,
845            )
846            .unwrap();
847        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
848        let sel = resolve_package_selection(
849            &graph,
850            &PackageSelection {
851                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
852                exclude: Vec::new(),
853            },
854        )
855        .unwrap();
856        let closure = sel.closure(&graph);
857        let mut excluded: BTreeSet<String> = BTreeSet::new();
858        excluded.insert("fmt".into());
859        assert!(!closure_has_versioned_deps_excluding_with_dev(
860            &graph,
861            &closure,
862            |_, _| false,
863            &excluded,
864            &BTreeSet::new(),
865        ));
866        // Empty exclusion set leaves the original positive
867        // result in place.
868        assert!(closure_has_versioned_deps_excluding_with_dev(
869            &graph,
870            &closure,
871            |_, _| false,
872            &BTreeSet::new(),
873            &BTreeSet::new(),
874        ));
875    }
876
877    #[test]
878    fn versioned_deps_excludes_dev_dependencies() {
879        let dir = TempDir::new().unwrap();
880        dir.child("cabin.toml")
881            .write_str(
882                r#"[workspace]
883members = ["packages/app"]
884"#,
885            )
886            .unwrap();
887        dir.child("packages/app/cabin.toml")
888            .write_str(
889                r#"[package]
890name = "app"
891version = "0.1.0"
892
893[dependencies]
894fmt = ">=10"
895
896[dev-dependencies]
897gtest = "^1.14"
898"#,
899            )
900            .unwrap();
901        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
902        let sel = resolve_package_selection(
903            &graph,
904            &PackageSelection {
905                mode: SelectionMode::ExplicitPackages(vec!["app".into()]),
906                exclude: Vec::new(),
907            },
908        )
909        .unwrap();
910        let closure = sel.closure(&graph);
911        let deps = collect_closure_versioned_deps_excluding_with_dev(
912            &graph,
913            &closure,
914            |_, _| false,
915            &BTreeSet::new(),
916            &BTreeSet::new(),
917        )
918        .unwrap();
919        let keys: Vec<&str> = deps.keys().map(cabin_core::PackageName::as_str).collect();
920        assert_eq!(keys, vec!["fmt"]);
921        assert!(
922            !deps.contains_key(&cabin_core::PackageName::new("gtest").unwrap()),
923            "dev-dep gtest must not enter ordinary resolution"
924        );
925    }
926}