Skip to main content

cabin_workspace/
graph.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::PathBuf;
3
4use cabin_core::standard_compatibility::{ConsumerStandards, dependency_attributes};
5use cabin_core::{
6    CStandard, CompilerWrapperRequest, Condition, CxxStandard, DependencyKind, Package,
7    PatchManifestSettings, ProfileDefinition, ProfileName, ToolchainSettings,
8    resolve_language_standards,
9};
10
11/// Root-manifest policy settings that apply workspace-wide even
12/// when the entry manifest is a pure `[workspace]` manifest.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct RootSettings {
15    pub profiles: BTreeMap<ProfileName, ProfileDefinition>,
16    pub toolchain: ToolchainSettings,
17    pub compiler_wrapper: Option<CompilerWrapperRequest>,
18    pub patches: PatchManifestSettings,
19}
20
21impl From<cabin_manifest::RootSettings> for RootSettings {
22    fn from(value: cabin_manifest::RootSettings) -> Self {
23        Self {
24            profiles: value.profiles,
25            toolchain: value.toolchain,
26            compiler_wrapper: value.compiler_wrapper,
27            patches: value.patches,
28        }
29    }
30}
31
32/// A loaded set of local Cabin packages with their dependency edges
33/// resolved against the local filesystem.
34///
35/// Packages appear in topological order: a package's local dependencies
36/// always appear before the package itself in [`PackageGraph::packages`].
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PackageGraph {
39    /// Path to the manifest the user passed (canonicalized to absolute).
40    pub root_manifest_path: PathBuf,
41    /// Directory containing the root manifest.
42    pub root_dir: PathBuf,
43    /// Whether the root manifest declares a `[workspace]` table.
44    pub is_workspace_root: bool,
45    /// If the root manifest itself is a package (i.e. has a `[package]`
46    /// table), the index of that package in [`PackageGraph::packages`].
47    pub root_package: Option<usize>,
48    /// Root-manifest policy settings.  For package roots this
49    /// mirrors the root package's root-owned fields; for pure
50    /// workspace roots this is the only place those settings are
51    /// exposed.
52    pub root_settings: RootSettings,
53    /// Indices of packages that count as "primary" - i.e. would be built
54    /// when no narrower package selection is given.
55    ///
56    /// For a single package this is the root.  For a workspace root it
57    /// is every member declared by `[workspace.members]`.  Path dependencies
58    /// pulled in transitively are *not* primary.
59    pub primary_packages: Vec<usize>,
60    /// Indices of packages listed under
61    /// `[workspace.default-members]`, validated to be members.  Empty
62    /// when the workspace declares no defaults - callers fall back to
63    /// the documented "all members" behavior.  Always a subset of
64    /// `primary_packages`.
65    pub default_members: Vec<usize>,
66    /// Relative paths under `root_dir` for any directories
67    /// dropped by `[workspace.exclude]`.  Carried through purely for
68    /// metadata reporting; the loader has already removed them from
69    /// `primary_packages`.
70    pub excluded_members: Vec<PathBuf>,
71    /// All loaded packages, in topological order.
72    pub packages: Vec<WorkspacePackage>,
73}
74
75impl PackageGraph {
76    /// Find a package by name.  Linear scan; package counts are small.
77    pub fn package_by_name(&self, name: &str) -> Option<&WorkspacePackage> {
78        self.packages
79            .iter()
80            .find(|p| p.package.name.as_str() == name)
81    }
82
83    /// Index of a package by name.  Returned together with the reference
84    /// for callers that need to record edges by index.
85    pub fn index_of(&self, name: &str) -> Option<usize> {
86        self.packages
87            .iter()
88            .position(|p| p.package.name.as_str() == name)
89    }
90
91    /// The consumer standards for standard-aware version preference
92    /// (`docs/design/standard-compatibility/preference-mode.md`
93    /// section 1): per language, the minimum effective implementation
94    /// standard (spec D6 `impl_L`) across the targets of `members` that
95    /// implement it.  `None` for a language none of them compiles - it
96    /// then imposes nothing on candidate ordering.
97    ///
98    /// `members` must be the package set the resolve is actually for -
99    /// the selected closure
100    /// ([`ResolvedSelection::closure`](crate::ResolvedSelection::closure)),
101    /// not the whole graph - so an unselected member never lowers the
102    /// consumer standard for a scoped resolve.  Within each member the
103    /// targets this invocation can build count: default-buildable kinds
104    /// always, plus dev-only (`test` / `example`) targets for packages
105    /// named in `dev_for` (the set whose `[dev-dependencies]` this
106    /// invocation activates, e.g. `cabin test`), and in both cases only
107    /// when their `required-features` are satisfied by `enabled_features`
108    /// (keyed by package index).  A target gated behind an unenabled
109    /// feature, or a `test` / `example` under a plain `cabin build`, does
110    /// not lower the consumer standard.  Dev-only targets are counted
111    /// whenever `dev_for` activates them, without a per-target reachability
112    /// walk - the same conservative over-approximation applied to a path
113    /// dependency's libraries (below): it can only prefer an older, more
114    /// broadly compatible version, never lock one a built target (such as
115    /// an example a selected target references in `deps`) cannot consume.
116    ///
117    /// The set is deliberately every default-buildable (plus `dev_for`)
118    /// target of the selected packages, **not** the single target a
119    /// `--bin` / `--example` / test-name narrows a later build to.
120    /// `cabin.lock` is shared per project, so its versions must suit
121    /// every target `cabin build` compiles; scoping resolution to one
122    /// run/test target would under-constrain the shared lock for its
123    /// siblings.  Which target is finally compiled is a build-time
124    /// decision, downstream of resolution.
125    ///
126    /// This is the Cargo-style workspace-level approximation used during
127    /// a partial solve: exactness is not required because the
128    /// post-resolution validation remains the correctness authority.
129    ///
130    /// `primary` is the originally selected package set
131    /// ([`ResolvedSelection::packages`](crate::ResolvedSelection::packages)),
132    /// a subset of `members`: `members` also holds the transitive
133    /// path-dependency packages the closure pulls in.  A path dependency
134    /// is built only for the library targets its consumers link, never
135    /// for its own executables/tests, so a non-primary member counts
136    /// only its archive-producing (library) targets.  Whether each such
137    /// library is in turn *reachable* (linked by a consumer target) is
138    /// deliberately not computed here: that per-target build-graph walk
139    /// is the planner's post-resolution job, and counting a path
140    /// dependency's archive targets is a conservative over-approximation
141    /// in the safe direction - it can only prefer an older, more broadly
142    /// compatible version, never cause a wrong build.
143    ///
144    /// This extends to a path dependency reached only through a
145    /// feature-disabled optional edge: the loader records optional path
146    /// edges unconditionally (only disabled optional *registry* deps are
147    /// pruned), and this walk does no package-level feature-reachability
148    /// pruning of `members`.  That is deliberate and equally safe - each
149    /// added member contributes only to the per-language `min`, which
150    /// extra targets can lower but never raise, so an unbuilt optional
151    /// dependency can at most prefer an older, more broadly compatible
152    /// version. Pruning it would only ever raise the preferred version
153    /// and never changes solvability, so it is left to the planner.
154    #[must_use]
155    pub fn consumer_standards(
156        &self,
157        members: &BTreeSet<usize>,
158        primary: &[usize],
159        enabled_features: &HashMap<usize, BTreeSet<String>>,
160        dev_for: &BTreeSet<String>,
161    ) -> ConsumerStandards {
162        let empty = BTreeSet::new();
163        let mut c: Option<CStandard> = None;
164        let mut cxx: Option<CxxStandard> = None;
165        for &index in members {
166            let member = &self.packages[index];
167            let is_primary = primary.contains(&index);
168            let enabled = enabled_features.get(&index).unwrap_or(&empty);
169            let dev_active = dev_for.contains(member.package.name.as_str());
170            let resolved = resolve_language_standards(&member.package.language);
171            for target in &member.package.targets {
172                // A header-only target has no translation units, so as a
173                // consumer it compiles nothing (spec D7 `langs = empty`)
174                // and imposes no consumer level - even though
175                // `dependency_attributes` reports its header-only
176                // inference on the dependency side.
177                if target.kind.is_header_only() {
178                    continue;
179                }
180                // Only targets this invocation can compile count.  A
181                // primary package builds its default-buildable targets
182                // and, under `dev_for` (`cabin test`), its dev-only
183                // (`test` / `example`) targets; a path-dep member builds
184                // only the library targets it is linked for.  Dev-only
185                // targets are counted whenever `dev_for` activates them,
186                // without walking which are actually reachable from a
187                // selected target - the same safe over-approximation
188                // applied to a path dependency's libraries below.  A
189                // selected target may reference an `example` in its
190                // `deps` (the planner then compiles it), so excluding
191                // examples could raise the consumer above a built
192                // example's standard and lock a version it cannot
193                // consume; counting one that a run does not reach only
194                // lowers the consumer (an older, more broadly compatible
195                // pick), never raises it.
196                let built = if is_primary {
197                    target.kind.is_default_buildable() || (dev_active && target.kind.is_dev_only())
198                } else {
199                    target.kind.produces_archive()
200                };
201                if !built || !target.missing_required_features(enabled).is_empty() {
202                    continue;
203                }
204                let attributes = dependency_attributes(target, &resolved, &member.package.language);
205                if let Some(level) = attributes.impl_c {
206                    c = Some(c.map_or(level, |current| current.min(level)));
207                }
208                if let Some(level) = attributes.impl_cxx {
209                    cxx = Some(cxx.map_or(level, |current| current.min(level)));
210                }
211            }
212        }
213        ConsumerStandards { c, cxx }
214    }
215}
216
217/// A single loaded package.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct WorkspacePackage {
220    pub package: Package,
221    /// Absolute path to this package's `cabin.toml`.
222    pub manifest_path: PathBuf,
223    /// Absolute path to the directory containing `manifest_path`.
224    pub manifest_dir: PathBuf,
225    /// Resolved package-dependency edges, in declaration order.
226    /// Each edge carries the index of the depended-on package
227    /// inside [`PackageGraph::packages`] together with the
228    /// [`DependencyKind`] under which it was declared.
229    ///
230    /// `Normal`-kind edges always appear here.  `Dev`-kind edges
231    /// appear only when the loader was asked to activate this
232    /// package's `[dev-dependencies]` via
233    /// `WorkspaceLoadOptions::include_dev_for` - `cabin test` does
234    /// that for the selected packages; ordinary commands keep dev
235    /// deps declaration-only.  The kind is preserved per-edge so
236    /// consumers can filter appropriately.
237    pub deps: Vec<DependencyEdge>,
238    /// Whether this package was loaded from a local source tree
239    /// or from an extracted registry archive.
240    pub kind: PackageKind,
241    /// Whether this package is a prepared foundation port (its
242    /// source tree was materialized from a `port.toml` recipe).
243    /// Ports are also [`PackageKind::Local`] - this flag is what
244    /// distinguishes them from ordinary `path` dependencies so
245    /// `cabin tree` / `explain` can tag them `[port]`.
246    pub is_port: bool,
247}
248
249impl WorkspacePackage {
250    /// Iterate dependency edges of a single kind.  Used by the
251    /// build planner, which resolves ordinary targets through
252    /// `Normal`-kind edges only and additionally lets dev-only
253    /// targets (`test` / `example`) see activated `Dev`-kind edges.
254    pub fn deps_of_kind(&self, kind: DependencyKind) -> impl Iterator<Item = usize> + '_ {
255        self.deps
256            .iter()
257            .filter(move |edge| edge.kind == kind)
258            .map(|edge| edge.index)
259    }
260
261    /// Iterate all dependency edges as bare indices, in
262    /// declaration order.  Used by closure walks (resolve / fetch /
263    /// metadata) that include every package-graph-resident kind.
264    pub fn all_dep_indices(&self) -> impl Iterator<Item = usize> + '_ {
265        self.deps.iter().map(|edge| edge.index)
266    }
267}
268
269/// A single resolved package-dependency edge in the package graph.
270///
271/// The graph only contains edges that *could* be active on the
272/// evaluation platform (the loader filters out non-matching
273/// `[target.'cfg(...)'.<kind>]` entries before constructing the
274/// graph), so consumers never need to re-check the condition
275/// against a different platform - the loader already did.  The
276/// edge still records the originating condition for diagnostics
277/// and metadata.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct DependencyEdge {
280    /// Index of the depended-on package in [`PackageGraph::packages`].
281    pub index: usize,
282    /// Which manifest section this edge was declared under.
283    pub kind: DependencyKind,
284    /// `Some` when this edge originated from a
285    /// `[target.'cfg(...)'.<kind>]` table that matched the
286    /// evaluation platform; `None` for unconditional edges.
287    pub condition: Option<Condition>,
288    /// Whether the declaration opted this edge out of the
289    /// standard-compatibility check with
290    /// `ignore-interface-standard = true`.  Per-edge by design;
291    /// inert unless the check runs.
292    pub ignore_interface_standard: bool,
293}
294
295/// Where a [`WorkspacePackage`] came from.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum PackageKind {
298    /// A local-filesystem package: the workspace root or a member, a
299    /// `path = "..."` dependency, a `[patch]`ed package, or a prepared
300    /// foundation port.
301    ///
302    /// `Local` is the trust boundary used when deciding whether to honor
303    /// a package's own raw `[profile]` compiler/linker flags: every
304    /// `Local` source is user-controlled.  Root / members / path deps are
305    /// local working trees; patches are local override copies; and a
306    /// port's build flags come from its trusted overlay recipe (bundled
307    /// or user-pinned), not the downloaded source archive.  The loader
308    /// guarantees a downloaded registry archive can never introduce a
309    /// `Local` package, because it rejects `path` / `port` dependencies
310    /// declared by a [`PackageKind::Registry`] package.
311    Local,
312    /// A registry package whose source archive was already fetched and
313    /// extracted into the artifact cache.  Untrusted: its own `[profile]`
314    /// `cflags` / `cxxflags` / `ldflags` are dropped during build-flag
315    /// resolution.
316    Registry,
317}
318
319/// Synthesize a root identity for resolving over a pure-workspace
320/// root (no `[package]`).  The name is a deterministic
321/// `__workspace_<dirname>` value the resolver uses for diagnostic
322/// output only; nothing else relies on it being canonical.  Lives
323/// here because it is derived purely from a [`PackageGraph`]'s
324/// `root_dir`, keeping the synthetic-root naming rule out of the CLI.
325///
326/// # Panics
327/// Panics only if the constructed name were rejected by
328/// `PackageName::new`, which cannot happen: `sanitized` always begins
329/// with the literal `__workspace_` prefix (so it is non-empty) and
330/// every appended character is ASCII alphanumeric, `_`, or `-`.
331pub fn synthetic_root_identity(graph: &PackageGraph) -> (cabin_core::PackageName, semver::Version) {
332    let dirname = graph
333        .root_dir
334        .file_name()
335        .and_then(|s| s.to_str())
336        .unwrap_or("workspace");
337    let mut sanitized = String::with_capacity(dirname.len() + 12);
338    sanitized.push_str("__workspace_");
339    for c in dirname.chars() {
340        if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') {
341            sanitized.push(c);
342        } else {
343            sanitized.push('_');
344        }
345    }
346    let name =
347        cabin_core::PackageName::new(sanitized).expect("synthesized name is non-empty and ASCII");
348    let version = semver::Version::new(0, 0, 0);
349    (name, version)
350}
351
352#[cfg(test)]
353mod consumer_standards_tests {
354    use super::*;
355    use cabin_core::{
356        CxxStandard, Features, LanguageStandardSettings, PackageConfigInput, PackageName,
357        StandardDeclaration, Target, TargetKind, TargetName,
358    };
359    use camino::Utf8PathBuf;
360
361    fn compiled_target(name: &str, ext: &str, language: LanguageStandardSettings) -> Target {
362        Target {
363            name: TargetName::new(name).unwrap(),
364            kind: TargetKind::Library,
365            sources: vec![Utf8PathBuf::from(format!("src/{name}.{ext}"))],
366            include_dirs: Vec::new(),
367            defines: Vec::new(),
368            deps: Vec::new(),
369            required_features: Vec::new(),
370            language,
371        }
372    }
373
374    fn gated_target(
375        name: &str,
376        ext: &str,
377        language: LanguageStandardSettings,
378        required_features: &[&str],
379    ) -> Target {
380        Target {
381            required_features: required_features.iter().map(|f| (*f).to_owned()).collect(),
382            ..compiled_target(name, ext, language)
383        }
384    }
385
386    fn header_only_target(name: &str, language: LanguageStandardSettings) -> Target {
387        Target {
388            kind: TargetKind::HeaderOnly,
389            sources: Vec::new(),
390            include_dirs: vec![Utf8PathBuf::from("include")],
391            ..compiled_target(name, "h", language)
392        }
393    }
394
395    fn executable_target(name: &str, ext: &str, language: LanguageStandardSettings) -> Target {
396        Target {
397            kind: TargetKind::Executable,
398            ..compiled_target(name, ext, language)
399        }
400    }
401
402    fn member(name: &str, targets: Vec<Target>) -> WorkspacePackage {
403        member_with_features(name, targets, Features::default())
404    }
405
406    fn member_with_features(
407        name: &str,
408        targets: Vec<Target>,
409        features: Features,
410    ) -> WorkspacePackage {
411        let package = Package::with_config(PackageConfigInput {
412            name: PackageName::new(name).unwrap(),
413            version: semver::Version::new(1, 0, 0),
414            targets,
415            dependencies: Vec::new(),
416            system_dependencies: Vec::new(),
417            features,
418        })
419        .unwrap();
420        WorkspacePackage {
421            package,
422            manifest_path: PathBuf::from(format!("/ws/{name}/cabin.toml")),
423            manifest_dir: PathBuf::from(format!("/ws/{name}")),
424            deps: Vec::new(),
425            kind: PackageKind::Local,
426            is_port: false,
427        }
428    }
429
430    fn graph(packages: Vec<WorkspacePackage>) -> PackageGraph {
431        PackageGraph {
432            root_manifest_path: PathBuf::from("/ws/cabin.toml"),
433            root_dir: PathBuf::from("/ws"),
434            is_workspace_root: true,
435            root_package: None,
436            root_settings: RootSettings::default(),
437            primary_packages: (0..packages.len()).collect(),
438            default_members: Vec::new(),
439            excluded_members: Vec::new(),
440            packages,
441        }
442    }
443
444    fn cxx(level: CxxStandard) -> LanguageStandardSettings {
445        LanguageStandardSettings {
446            cxx_standard: Some(StandardDeclaration::Declared(level)),
447            ..Default::default()
448        }
449    }
450
451    fn c(level: CStandard) -> LanguageStandardSettings {
452        LanguageStandardSettings {
453            c_standard: Some(StandardDeclaration::Declared(level)),
454            ..Default::default()
455        }
456    }
457
458    /// The consumer standard is the per-language minimum implementation
459    /// standard across every member target, and `None` for a language
460    /// no member compiles.
461    #[test]
462    fn consumer_standard_is_the_workspace_minimum_per_language() {
463        let workspace = graph(vec![
464            member(
465                "a",
466                vec![compiled_target("a", "cc", cxx(CxxStandard::Cxx20))],
467            ),
468            member(
469                "b",
470                vec![
471                    compiled_target("b", "cc", cxx(CxxStandard::Cxx17)),
472                    compiled_target("bc", "c", c(CStandard::C17)),
473                ],
474            ),
475        ]);
476        let all: BTreeSet<usize> = (0..workspace.packages.len()).collect();
477        let consumer =
478            workspace.consumer_standards(&all, &[0, 1], &HashMap::new(), &BTreeSet::new());
479        assert_eq!(consumer.cxx, Some(CxxStandard::Cxx17));
480        assert_eq!(consumer.c, Some(CStandard::C17));
481    }
482
483    /// Only the given members count: scoping to the C++20 member alone
484    /// keeps the consumer at C++20 even though a C++17 member exists in
485    /// the graph - a scoped resolve is not lowered by an unselected
486    /// member.
487    #[test]
488    fn consumer_standard_is_scoped_to_the_given_members() {
489        let workspace = graph(vec![
490            member(
491                "app20",
492                vec![compiled_target("app20", "cc", cxx(CxxStandard::Cxx20))],
493            ),
494            member(
495                "other17",
496                vec![compiled_target("other17", "cc", cxx(CxxStandard::Cxx17))],
497            ),
498        ]);
499        let only_app20: BTreeSet<usize> = [0].into_iter().collect();
500        let consumer =
501            workspace.consumer_standards(&only_app20, &[0], &HashMap::new(), &BTreeSet::new());
502        assert_eq!(consumer.cxx, Some(CxxStandard::Cxx20));
503    }
504
505    /// A target gated behind an unenabled feature does not lower the
506    /// consumer standard; enabling its feature counts it.
507    #[test]
508    fn feature_gated_target_does_not_lower_consumer_until_enabled() {
509        let features = Features::new(
510            Vec::new(),
511            [("legacy".to_owned(), Vec::new())].into_iter().collect(),
512        )
513        .unwrap();
514        let workspace = graph(vec![member_with_features(
515            "app",
516            vec![
517                compiled_target("app", "cc", cxx(CxxStandard::Cxx20)),
518                gated_target("legacy", "cc", cxx(CxxStandard::Cxx17), &["legacy"]),
519            ],
520            features,
521        )]);
522        let members: BTreeSet<usize> = [0].into_iter().collect();
523
524        // Feature off: the c++17 target is not built, so the consumer
525        // stays at c++20.
526        assert_eq!(
527            workspace
528                .consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new())
529                .cxx,
530            Some(CxxStandard::Cxx20)
531        );
532
533        // Feature on: the c++17 target is built and lowers the consumer.
534        let enabled: HashMap<usize, BTreeSet<String>> =
535            [(0, ["legacy".to_owned()].into_iter().collect())]
536                .into_iter()
537                .collect();
538        assert_eq!(
539            workspace
540                .consumer_standards(&members, &[0], &enabled, &BTreeSet::new())
541                .cxx,
542            Some(CxxStandard::Cxx17)
543        );
544    }
545
546    /// A dev-only (`test`) target counts only when this invocation
547    /// activates the package's dev-dependencies (`dev_for`), matching
548    /// `cabin test`; a plain build does not let it lower the consumer.
549    #[test]
550    fn dev_only_target_counts_only_for_dev_for_packages() {
551        let test_target = Target {
552            kind: TargetKind::Test,
553            ..compiled_target("app_test", "cc", cxx(CxxStandard::Cxx17))
554        };
555        let workspace = graph(vec![member(
556            "app",
557            vec![
558                compiled_target("app", "cc", cxx(CxxStandard::Cxx20)),
559                test_target,
560            ],
561        )]);
562        let members: BTreeSet<usize> = [0].into_iter().collect();
563
564        // Plain build: the c++17 test target is not built.
565        assert_eq!(
566            workspace
567                .consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new())
568                .cxx,
569            Some(CxxStandard::Cxx20)
570        );
571
572        // `cabin test` on this package: the test target is built and
573        // lowers the consumer.
574        let dev_for: BTreeSet<String> = ["app".to_owned()].into_iter().collect();
575        assert_eq!(
576            workspace
577                .consumer_standards(&members, &[0], &HashMap::new(), &dev_for)
578                .cxx,
579            Some(CxxStandard::Cxx17)
580        );
581    }
582
583    /// An `example` is dev-only: it counts under `dev_for` (`cabin test`)
584    /// exactly like a `test` target.  A selected target can reference an
585    /// example in its `deps`, so the planner may compile it; counting
586    /// every activated example (the safe over-approximation) keeps the
587    /// consumer low enough that a built example never gets a version it
588    /// cannot consume.  A plain build does not activate it.
589    #[test]
590    fn example_target_counts_under_dev_for() {
591        let example_target = Target {
592            kind: TargetKind::Example,
593            ..compiled_target("app_example", "cc", cxx(CxxStandard::Cxx17))
594        };
595        let workspace = graph(vec![member(
596            "app",
597            vec![
598                compiled_target("app", "cc", cxx(CxxStandard::Cxx20)),
599                example_target,
600            ],
601        )]);
602        let members: BTreeSet<usize> = [0].into_iter().collect();
603
604        // Plain build: the c++17 example is not built.
605        assert_eq!(
606            workspace
607                .consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new())
608                .cxx,
609            Some(CxxStandard::Cxx20)
610        );
611
612        // `cabin test` activates dev-only targets: the c++17 example
613        // lowers the consumer standard.
614        let dev_for: BTreeSet<String> = ["app".to_owned()].into_iter().collect();
615        assert_eq!(
616            workspace
617                .consumer_standards(&members, &[0], &HashMap::new(), &dev_for)
618                .cxx,
619            Some(CxxStandard::Cxx17)
620        );
621    }
622
623    /// A header-only target has no translation units, so it imposes no
624    /// consumer standard - even though `dependency_attributes` reports
625    /// its header-only inference on the dependency side.
626    #[test]
627    fn header_only_target_imposes_no_consumer_standard() {
628        // A package whose only target is header-only compiles nothing.
629        let workspace = graph(vec![member(
630            "hdr",
631            vec![header_only_target("hdr", cxx(CxxStandard::Cxx20))],
632        )]);
633        let members: BTreeSet<usize> = [0].into_iter().collect();
634        let consumer =
635            workspace.consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new());
636        assert_eq!(consumer.cxx, None);
637        assert_eq!(consumer.c, None);
638
639        // A header-only c++17 target beside a compiled c++20 library does
640        // not lower the consumer below c++20.
641        let workspace = graph(vec![member(
642            "app",
643            vec![
644                header_only_target("hdr", cxx(CxxStandard::Cxx17)),
645                compiled_target("app", "cc", cxx(CxxStandard::Cxx20)),
646            ],
647        )]);
648        assert_eq!(
649            workspace
650                .consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new())
651                .cxx,
652            Some(CxxStandard::Cxx20)
653        );
654    }
655
656    /// A transitive path-dependency package is built only for the
657    /// library targets its consumers link; its own executable is never
658    /// built, so a non-primary member's executable does not lower the
659    /// consumer standard.
660    #[test]
661    fn path_dependency_executable_does_not_lower_consumer() {
662        let workspace = graph(vec![
663            member(
664                "app",
665                vec![compiled_target("app", "cc", cxx(CxxStandard::Cxx20))],
666            ),
667            member(
668                "dep",
669                vec![
670                    compiled_target("dep", "cc", cxx(CxxStandard::Cxx20)),
671                    executable_target("dep_bin", "cc", cxx(CxxStandard::Cxx17)),
672                ],
673            ),
674        ]);
675        // `app` is the selected primary; `dep` is a transitive path
676        // dependency (in the closure, not primary).
677        let members: BTreeSet<usize> = [0, 1].into_iter().collect();
678        let consumer =
679            workspace.consumer_standards(&members, &[0], &HashMap::new(), &BTreeSet::new());
680        assert_eq!(consumer.cxx, Some(CxxStandard::Cxx20));
681    }
682
683    /// No members imposes nothing.
684    #[test]
685    fn empty_member_set_has_no_consumer_standard() {
686        let workspace = graph(vec![member(
687            "a",
688            vec![compiled_target("a", "cc", cxx(CxxStandard::Cxx20))],
689        )]);
690        let consumer =
691            workspace.consumer_standards(&BTreeSet::new(), &[], &HashMap::new(), &BTreeSet::new());
692        assert_eq!(consumer.c, None);
693        assert_eq!(consumer.cxx, None);
694    }
695}