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