Skip to main content

cabin_build/
validate.rs

1//! Validate that a detected toolchain can run the commands the
2//! C++ backend emits.
3//!
4//! The planner emits commands in one of two dialects - GCC/Clang-style
5//! or MSVC-style - chosen by the detected C++ compiler.  The GCC/Clang
6//! shapes are:
7//!
8//! - C++ compile: `cxx -std=<effective standard> -O… [-g] [-DNDEBUG]
9//!   -MD -MF <depfile> -D<name> -I<dir> [-isystem <dir>]
10//!   [extra-args] -c <src> -o <obj>`.
11//! - Static-library archive: `ar crs <lib> <objs>`.
12//! - Link: `cxx <objs> <libs> [extra-args] -o <exe>`.
13//!
14//! Any compiler / archiver that cannot run those exact shapes - or
15//! that lacks the flag for a *requested* language standard - is
16//! rejected up front rather than left to fail with a confusing
17//! Ninja error.
18
19use std::collections::{BTreeSet, HashMap};
20use std::hash::BuildHasher;
21
22use cabin_core::{
23    ArchiverKind, CStandard, CxxStandard, ResolvedLanguageStandards, ResolvedToolchain,
24    SourceLanguage, ToolchainDetectionReport, classify_source, effective_c, effective_cxx,
25    validate_ar_for_backend, validate_c_standards, validate_cc_for_backend,
26    validate_cxx_for_backend, validate_cxx_standards,
27};
28use cabin_workspace::PackageGraph;
29
30use crate::error::BuildError;
31
32/// The set of language standards a build's compiles request, per
33/// language.  A language's set is empty iff no compile of that
34/// language exists, which is the signal the C-compiler checks key
35/// on.
36///
37/// The authoritative form is [`requested_standards_of`], derived
38/// from a planned [`crate::BuildGraph`] so it covers exactly the compiles
39/// the build will run - no more (an unbuilt sibling target must not
40/// gate validation) and no less. [`collect_requested_standards`]
41/// is the pre-plan package-level approximation used only where a
42/// value is needed *before* planning (the MSVC `/external:I`
43/// fallback decision).
44#[derive(Debug, Default, Clone, PartialEq, Eq)]
45pub struct RequestedStandards {
46    pub c: BTreeSet<CStandard>,
47    pub cxx: BTreeSet<CxxStandard>,
48}
49
50impl RequestedStandards {
51    /// Whether any selected target compiles a `.c` source - the
52    /// signal that scopes the C-compiler checks.
53    #[must_use]
54    pub fn has_c_sources(&self) -> bool {
55        !self.c.is_empty()
56    }
57}
58
59/// The exact standards a planned build requests: every compile
60/// action in the graph contributes its typed standard.  This is the
61/// set [`validate_toolchain_for_backend`] should be called with -
62/// the planner has already narrowed targets to the requested
63/// selection, so a sibling target that is not built cannot gate the
64/// toolchain.
65#[must_use]
66pub fn requested_standards_of(graph: &crate::BuildGraph) -> RequestedStandards {
67    use cabin_core::LanguageStandard;
68    use cabin_driver::BuildAction;
69    let mut out = RequestedStandards::default();
70    for action in &graph.actions {
71        if let BuildAction::Compile(compile) = action {
72            match compile.standard {
73                LanguageStandard::C(standard) => {
74                    out.c.insert(standard);
75                }
76                LanguageStandard::Cxx(standard) => {
77                    out.cxx.insert(standard);
78                }
79            }
80        }
81    }
82    out
83}
84
85/// Pre-plan approximation of [`requested_standards_of`]: walk every
86/// selected package's targets and classify each declared source.
87/// Over-approximates per target (it cannot know which targets the
88/// plan will reach), so it must not gate validation - use it only
89/// for decisions needed before planning, like the MSVC
90/// `/external:I` fallback.  Dev-only targets (`test` / `example`)
91/// contribute only for packages in `dev_for`, mirroring
92/// dev-dependency activation.
93#[must_use]
94pub fn collect_requested_standards<S: BuildHasher>(
95    graph: &PackageGraph,
96    selected: &BTreeSet<usize>,
97    standards: &HashMap<usize, ResolvedLanguageStandards, S>,
98    dev_for: &BTreeSet<String>,
99) -> RequestedStandards {
100    let mut out = RequestedStandards::default();
101    for &idx in selected {
102        let Some(pkg) = graph.packages.get(idx) else {
103            continue;
104        };
105        let resolved = standards.get(&idx).copied().unwrap_or_default();
106        let include_dev = dev_for.contains(pkg.package.name.as_str());
107        for target in &pkg.package.targets {
108            if target.kind.is_dev_only() && !include_dev {
109                continue;
110            }
111            // A missing effective standard is a manifest-load error;
112            // this over-approximation simply skips such sources.
113            for source in &target.sources {
114                match classify_source(source) {
115                    Some(SourceLanguage::C) => {
116                        if let Some(resolved) = effective_c(&resolved, target) {
117                            out.c.insert(resolved.standard);
118                        }
119                    }
120                    Some(SourceLanguage::Cxx) => {
121                        if let Some(resolved) = effective_cxx(&resolved, target) {
122                            out.cxx.insert(resolved.standard);
123                        }
124                    }
125                    None => {}
126                }
127            }
128        }
129    }
130    out
131}
132
133/// Validate that every populated tool in `report` can execute the
134/// command shapes emitted by the current backend.  Returns the
135/// first problem encountered so users see one actionable error,
136/// not a wall of unrelated failures.
137///
138/// Each tool is held to its dialect's contract.  The MSVC dialect
139/// drives `cl.exe` / `lib.exe`; the GCC/Clang dialect drives a
140/// GCC-style compiler (GCC-style flags plus depfile) and an
141/// `ar`-compatible archiver.  The optional C compiler and every
142/// language-standard check are validated separately by
143/// [`validate_toolchain_standards`], post-plan, gated on the
144/// compiles the plan emits - a `cc` slot (or a sibling
145/// target's C source) that no planned compile uses must not gate
146/// the build.
147///
148/// Beyond the per-tool checks, the C++ compiler and archiver must
149/// belong to the *same* dialect: Cabin emits one command-line
150/// dialect per build, so an MSVC compiler paired with a GNU `ar`
151/// (or the reverse) is rejected rather than left to fail
152/// mid-build.
153///
154/// `toolchain` is the matching [`ResolvedToolchain`] - we use it
155/// to recover the user-visible spec strings (`clang++`,
156/// `/opt/llvm/bin/clang++`) for the error messages.
157///
158/// # Errors
159/// Returns [`BuildError::UnsupportedToolchain`] (wrapping the
160/// [`cabin_core::ToolDetectionError`] from the first failing
161/// `validate_*_for_backend` check) when a tool cannot run its
162/// dialect's command shapes, and [`BuildError::MixedToolchainDialects`]
163/// when the resolved tools span both dialects.
164pub fn validate_toolchain_for_backend(
165    toolchain: &ResolvedToolchain,
166    report: &ToolchainDetectionReport,
167) -> Result<(), BuildError> {
168    let cxx_spec = toolchain.cxx.spec.display();
169    validate_cxx_for_backend(&cxx_spec, &report.cxx.identity, &report.cxx.capabilities)?;
170    let ar_spec = toolchain.ar.spec.display();
171    validate_ar_for_backend(&ar_spec, &report.ar.identity, &report.ar.capabilities)?;
172
173    // Both tools individually run; now require them to share a
174    // dialect.  The C++ compiler picks it (MSVC `cl` vs GCC/Clang)
175    // and the archiver must match.
176    let cxx_is_msvc = report.cxx.identity.kind.speaks_msvc_dialect();
177    let ar_is_msvc = report.ar.identity.kind == ArchiverKind::Lib;
178    if cxx_is_msvc != ar_is_msvc {
179        return Err(BuildError::MixedToolchainDialects {
180            detail: format!(
181                "C++ compiler `{cxx_spec}` is {}, but archiver `{ar_spec}` is {}",
182                dialect_label(cxx_is_msvc),
183                dialect_label(ar_is_msvc),
184            ),
185        });
186    }
187    Ok(())
188}
189
190/// Validate the plan-dependent half of the toolchain contract:
191/// every language standard in `requested`, plus - when the plan
192/// compiles C - the C compiler's backend shape and
193/// dialect coherence. `requested` is derived from the *final*
194/// planned graph via [`requested_standards_of`] (after the
195/// `cabin check` rewrite when applicable), so only compiles the
196/// command will run participate: an unbuilt sibling target, an
197/// unrelated C executable in a dependency package, or a dependency
198/// compile that `cabin check` drops can never gate the build.
199/// Runs after `plan()` and before any Ninja file is written; the
200/// plan-independent C++/archiver shape checks in
201/// [`validate_toolchain_for_backend`] stay pre-plan so a broken
202/// toolchain still fails fast.
203///
204/// # Errors
205/// Returns [`BuildError::UnsupportedToolchain`] wrapping the first
206/// failing check ([`cabin_core::ToolDetectionError::CxxLacksStandard`],
207/// [`cabin_core::ToolDetectionError::CLacksStandard`], or a C
208/// backend-shape error) and [`BuildError::MixedToolchainDialects`]
209/// when a C compile exists but the C compiler's dialect differs
210/// from the C++ compiler's.
211pub fn validate_toolchain_standards(
212    toolchain: &ResolvedToolchain,
213    report: &ToolchainDetectionReport,
214    requested: &RequestedStandards,
215) -> Result<(), BuildError> {
216    let cxx_spec = toolchain.cxx.spec.display();
217    validate_cxx_standards(&cxx_spec, &report.cxx.identity, &requested.cxx)?;
218    // The C compiler is only validated when a C compile was
219    // planned.  The resolver fills the optional `cc` slot from the
220    // host default fallback (`cl` on Windows) even for a C++-only
221    // build, so checking it unconditionally would reject e.g.
222    // `CXX=clang++` against a never-used default `cc=cl`.
223    if requested.has_c_sources()
224        && let (Some(cc_tool), Some(cc_detection)) = (toolchain.cc.as_ref(), report.cc.as_ref())
225    {
226        let cc_spec = cc_tool.spec.display();
227        validate_cc_for_backend(&cc_spec, &cc_detection.identity, &cc_detection.capabilities)?;
228        let cxx_is_msvc = report.cxx.identity.kind.speaks_msvc_dialect();
229        let cc_is_msvc = cc_detection.identity.kind.speaks_msvc_dialect();
230        if cc_is_msvc != cxx_is_msvc {
231            return Err(BuildError::MixedToolchainDialects {
232                detail: format!(
233                    "C++ compiler `{cxx_spec}` is {}, but C compiler `{cc_spec}` is {}",
234                    dialect_label(cxx_is_msvc),
235                    dialect_label(cc_is_msvc),
236                ),
237            });
238        }
239        validate_c_standards(&cc_spec, &cc_detection.identity, &requested.c)?;
240    }
241    Ok(())
242}
243
244/// Surface the first standards violation the planner recorded that
245/// survived into the final graph (after the `cabin check` rewrite,
246/// when applicable): an MSVC no-stable-flag compile or an
247/// escape-hatch flag conflict on a planned compile.  Must run before
248/// anything is lowered or written; commands that skip the
249/// toolchain-validation pass (`cabin tidy`'s fail-soft path) must
250/// still call this so a violating compile cannot be silently
251/// dropped from the compile database.
252///
253/// # Errors
254/// Returns [`BuildError::StandardUnsupportedOnMsvcDialect`] or
255/// [`BuildError::StandardFlagConflict`] for the first surviving
256/// violation.
257pub fn validate_planned_standards(graph: &crate::BuildGraph) -> Result<(), BuildError> {
258    match graph.standard_violations.first() {
259        Some(crate::StandardViolation::MsvcSpelling {
260            target,
261            language,
262            standard,
263            ..
264        }) => Err(BuildError::StandardUnsupportedOnMsvcDialect {
265            target: target.clone(),
266            language,
267            standard,
268        }),
269        Some(crate::StandardViolation::MsvcGnuExtensions { target, .. }) => {
270            Err(BuildError::GnuExtensionsUnsupportedOnMsvcDialect {
271                target: target.clone(),
272            })
273        }
274        Some(crate::StandardViolation::FlagConflict { conflict, .. }) => {
275            Err(BuildError::StandardFlagConflict(Box::new(conflict.clone())))
276        }
277        Some(crate::StandardViolation::InterfaceIncompatibility {
278            consumer,
279            dependency,
280            language,
281            consumer_standard,
282            required,
283            requirement_source,
284            ..
285        }) => Err(BuildError::IncompatibleLanguageStandard {
286            consumer: consumer.clone(),
287            dependency: dependency.clone(),
288            language,
289            consumer_standard,
290            required,
291            requirement_source,
292        }),
293        None => Ok(()),
294    }
295}
296
297/// Human-readable dialect name for the mixed-toolchain diagnostic.
298fn dialect_label(is_msvc: bool) -> &'static str {
299    if is_msvc { "MSVC" } else { "GCC/Clang-style" }
300}
301
302/// Whether every compiler that will run a compile in this build
303/// accepts the MSVC-dialect distinct system-include spelling
304/// (`/external:W0` + `/external:I <dir>`).  Mirrors
305/// [`validate_toolchain_for_backend`]'s contract for the optional
306/// `cc` slot: the C compiler only weighs in when a C source exists,
307/// because the planner never invokes it otherwise.
308///
309/// The planner consults the answer through
310/// [`crate::PlanRequest::msvc_external_includes`] and falls back to
311/// plain `/I` when it is `false`.  On a GCC/Clang build the value is
312/// ignored - `-isystem` is part of the base dialect.
313#[must_use]
314pub fn msvc_external_includes_supported(
315    report: &ToolchainDetectionReport,
316    has_c_sources: bool,
317) -> bool {
318    let cxx_ok = report.cxx.capabilities.external_include_dirs.supported;
319    let cc_ok = !has_c_sources
320        || report
321            .cc
322            .as_ref()
323            .is_none_or(|cc| cc.capabilities.external_include_dirs.supported);
324    cxx_ok && cc_ok
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use cabin_core::{
331        ArchiverIdentity, ArchiverKind, CompilerIdentity, CompilerKind, CompilerVersion,
332        ResolvedTool, ResolvedToolchain, StandardDeclaration, ToolDetection, ToolKind, ToolSource,
333        ToolSpec, derive_ar_capabilities, derive_cxx_capabilities,
334    };
335    use camino::Utf8PathBuf;
336
337    fn requested(c: &[CStandard], cxx: &[CxxStandard]) -> RequestedStandards {
338        RequestedStandards {
339            c: c.iter().copied().collect(),
340            cxx: cxx.iter().copied().collect(),
341        }
342    }
343
344    /// The historic defaults: a C++17 build, optionally with C11.
345    fn requested_defaults(with_c: bool) -> RequestedStandards {
346        if with_c {
347            requested(&[CStandard::C11], &[CxxStandard::Cxx17])
348        } else {
349            requested(&[], &[CxxStandard::Cxx17])
350        }
351    }
352
353    fn make_toolchain(cxx_spec: &str, ar_spec: &str) -> ResolvedToolchain {
354        ResolvedToolchain {
355            cxx: ResolvedTool {
356                kind: ToolKind::CxxCompiler,
357                path: Utf8PathBuf::from("/bin").join(cxx_spec),
358                spec: ToolSpec::Name(cxx_spec.into()),
359                source: ToolSource::Default,
360            },
361            ar: ResolvedTool {
362                kind: ToolKind::Archiver,
363                path: Utf8PathBuf::from("/bin").join(ar_spec),
364                spec: ToolSpec::Name(ar_spec.into()),
365                source: ToolSource::Default,
366            },
367            cc: None,
368        }
369    }
370
371    fn report_for(cxx: CompilerIdentity, ar: ArchiverIdentity) -> ToolchainDetectionReport {
372        let cxx_caps = derive_cxx_capabilities(&cxx);
373        let ar_caps = derive_ar_capabilities(&ar);
374        ToolchainDetectionReport {
375            cxx: ToolDetection {
376                path: Utf8PathBuf::from("/bin/cxx"),
377                identity: cxx,
378                capabilities: cxx_caps,
379            },
380            cc: None,
381            ar: ToolDetection {
382                path: Utf8PathBuf::from("/bin/ar"),
383                identity: ar,
384                capabilities: ar_caps,
385            },
386        }
387    }
388
389    #[test]
390    fn accepts_clang_with_gnu_ar() {
391        let toolchain = make_toolchain("clang++", "ar");
392        let report = report_for(
393            CompilerIdentity {
394                kind: CompilerKind::Clang,
395                version: CompilerVersion::parse("17.0.6"),
396                target: None,
397                raw_version_line: "clang version 17.0.6".into(),
398            },
399            ArchiverIdentity {
400                kind: ArchiverKind::Ar,
401                version: CompilerVersion::parse("2.40"),
402                raw_version_line: "GNU ar".into(),
403            },
404        );
405        validate_toolchain_for_backend(&toolchain, &report).unwrap();
406    }
407
408    #[test]
409    fn accepts_full_msvc_toolchain() {
410        // `cl` + `lib` is a coherent MSVC toolchain and is now a
411        // first-class supported backend.
412        let toolchain = make_toolchain("cl.exe", "lib.exe");
413        let report = report_for(
414            CompilerIdentity {
415                kind: CompilerKind::Msvc,
416                version: None,
417                target: None,
418                raw_version_line: "Microsoft Optimizing Compiler".into(),
419            },
420            ArchiverIdentity {
421                kind: ArchiverKind::Lib,
422                version: None,
423                raw_version_line: "Microsoft Library Manager".into(),
424            },
425        );
426        validate_toolchain_for_backend(&toolchain, &report).unwrap();
427    }
428
429    #[test]
430    fn msvc_external_includes_follow_the_cl_version() {
431        let lib = || ArchiverIdentity {
432            kind: ArchiverKind::Lib,
433            version: None,
434            raw_version_line: "Microsoft Library Manager".into(),
435        };
436        let cl = |version: &str| CompilerIdentity {
437            kind: CompilerKind::Msvc,
438            version: CompilerVersion::parse(version),
439            target: None,
440            raw_version_line: format!("Microsoft Optimizing Compiler {version}"),
441        };
442        let modern = report_for(cl("19.29.30133"), lib());
443        assert!(msvc_external_includes_supported(&modern, false));
444        let old = report_for(cl("19.20.27508"), lib());
445        assert!(!msvc_external_includes_supported(&old, false));
446    }
447
448    #[test]
449    fn msvc_external_includes_consider_cc_only_with_c_sources() {
450        // A modern C++ `cl` paired with an old C `cl`: the C compiler
451        // only matters when a C source will be compiled.
452        let lib = ArchiverIdentity {
453            kind: ArchiverKind::Lib,
454            version: None,
455            raw_version_line: "Microsoft Library Manager".into(),
456        };
457        let modern_cl = CompilerIdentity {
458            kind: CompilerKind::Msvc,
459            version: CompilerVersion::parse("19.39.33523"),
460            target: None,
461            raw_version_line: "Microsoft Optimizing Compiler 19.39".into(),
462        };
463        let old_cl = CompilerIdentity {
464            kind: CompilerKind::Msvc,
465            version: CompilerVersion::parse("19.20.27508"),
466            target: None,
467            raw_version_line: "Microsoft Optimizing Compiler 19.20".into(),
468        };
469        let mut report = report_for(modern_cl, lib);
470        let old_caps = derive_cxx_capabilities(&old_cl);
471        report.cc = Some(ToolDetection {
472            path: Utf8PathBuf::from("/bin/cc"),
473            identity: old_cl,
474            capabilities: old_caps,
475        });
476        assert!(msvc_external_includes_supported(&report, false));
477        assert!(!msvc_external_includes_supported(&report, true));
478    }
479
480    #[test]
481    fn accepts_clang_cl_with_lib_and_rejects_it_with_gnu_ar() {
482        // `clang-cl` speaks the MSVC dialect, so it pairs with
483        // `lib.exe` (coherent) but not with GNU `ar` (mixed).
484        let clang_cl = CompilerIdentity {
485            kind: CompilerKind::ClangCl,
486            version: CompilerVersion::parse("17.0.6"),
487            target: None,
488            raw_version_line: "clang version 17.0.6".into(),
489        };
490        let lib = ArchiverIdentity {
491            kind: ArchiverKind::Lib,
492            version: None,
493            raw_version_line: "Microsoft Library Manager".into(),
494        };
495        let report = report_for(clang_cl.clone(), lib);
496        validate_toolchain_for_backend(&make_toolchain("clang-cl", "lib.exe"), &report).unwrap();
497
498        let gnu_ar = ArchiverIdentity {
499            kind: ArchiverKind::Ar,
500            version: CompilerVersion::parse("2.40"),
501            raw_version_line: "GNU ar".into(),
502        };
503        let mixed = report_for(clang_cl, gnu_ar);
504        let err =
505            validate_toolchain_for_backend(&make_toolchain("clang-cl", "ar"), &mixed).unwrap_err();
506        assert!(
507            matches!(err, BuildError::MixedToolchainDialects { .. }),
508            "clang-cl + GNU ar should be rejected as mixed-dialect, got: {err}"
509        );
510    }
511
512    #[test]
513    fn rejects_mixed_dialect_toolchain() {
514        // A GCC/Clang compiler with an MSVC archiver runs each tool
515        // individually but cannot be driven as one dialect.
516        let toolchain = make_toolchain("clang++", "lib.exe");
517        let report = report_for(
518            CompilerIdentity {
519                kind: CompilerKind::Clang,
520                version: CompilerVersion::parse("17.0.6"),
521                target: None,
522                raw_version_line: "clang version 17.0.6".into(),
523            },
524            ArchiverIdentity {
525                kind: ArchiverKind::Lib,
526                version: None,
527                raw_version_line: "Microsoft Library Manager".into(),
528            },
529        );
530        let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err();
531        assert!(
532            matches!(err, BuildError::MixedToolchainDialects { .. }),
533            "expected mixed-dialect rejection, got: {err}"
534        );
535    }
536
537    #[test]
538    fn defers_cc_dialect_check_until_c_compiles_are_planned() {
539        // A C++-only build that selects GNU `clang++` but whose
540        // optional `cc` slot defaulted to MSVC `cl` must not be
541        // rejected: with no planned C compile the planner never
542        // invokes that `cc`.  Once a C compile is planned, the mixed
543        // C/C++ dialect is a real problem and the check fires.
544        let mut toolchain = make_toolchain("clang++", "ar");
545        toolchain.cc = Some(ResolvedTool {
546            kind: ToolKind::CCompiler,
547            path: Utf8PathBuf::from("/bin/cl.exe"),
548            spec: ToolSpec::Name("cl".into()),
549            source: ToolSource::Default,
550        });
551        let cc_identity = CompilerIdentity {
552            kind: CompilerKind::Msvc,
553            version: CompilerVersion::parse("19.39.0"),
554            target: None,
555            raw_version_line: "Microsoft Optimizing Compiler".into(),
556        };
557        let mut report = report_for(
558            CompilerIdentity {
559                kind: CompilerKind::Clang,
560                version: CompilerVersion::parse("17.0.6"),
561                target: None,
562                raw_version_line: "clang version 17.0.6".into(),
563            },
564            ArchiverIdentity {
565                kind: ArchiverKind::Ar,
566                version: CompilerVersion::parse("2.40"),
567                raw_version_line: "GNU ar".into(),
568            },
569        );
570        report.cc = Some(ToolDetection {
571            path: Utf8PathBuf::from("/bin/cl.exe"),
572            capabilities: derive_cxx_capabilities(&cc_identity),
573            identity: cc_identity,
574        });
575
576        // The backend-shape pass never consults `cc`.
577        validate_toolchain_for_backend(&toolchain, &report).unwrap();
578        // No planned C compile: the defaulted MSVC `cc` is never
579        // used, so the C++-only GNU toolchain validates.
580        validate_toolchain_standards(&toolchain, &report, &requested_defaults(false)).unwrap();
581        // A C compile is planned: the mixed C/C++ dialect is rejected.
582        let err = validate_toolchain_standards(&toolchain, &report, &requested_defaults(true))
583            .unwrap_err();
584        assert!(
585            matches!(err, BuildError::MixedToolchainDialects { .. }),
586            "expected mixed-dialect rejection once C compiles are planned, got: {err}"
587        );
588    }
589
590    #[test]
591    fn rejects_unknown_compiler_clearly() {
592        let toolchain = make_toolchain("custom-cxx", "ar");
593        let report = report_for(
594            CompilerIdentity::unknown("???"),
595            ArchiverIdentity {
596                kind: ArchiverKind::Ar,
597                version: None,
598                raw_version_line: "GNU ar".into(),
599            },
600        );
601        let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err();
602        let message = err.to_string();
603        assert!(
604            message.contains("could not be identified"),
605            "expected unknown-compiler error, got: {message}"
606        );
607    }
608
609    #[test]
610    fn c_source_detection_is_scoped_to_the_selected_closure() {
611        use cabin_core::{Package, PackageName, Target, TargetKind, TargetName};
612        use cabin_workspace::{PackageKind, WorkspacePackage};
613
614        fn pkg(name: &str, source: &str) -> WorkspacePackage {
615            let target = Target {
616                name: TargetName::new("lib").unwrap(),
617                kind: TargetKind::Library,
618                sources: vec![Utf8PathBuf::from(source)],
619                include_dirs: Vec::new(),
620                defines: Vec::new(),
621                deps: Vec::new(),
622                required_features: Vec::new(),
623                language: cabin_core::LanguageStandardSettings {
624                    c_standard: Some(StandardDeclaration::Declared(CStandard::C11)),
625                    cxx_standard: Some(StandardDeclaration::Declared(CxxStandard::Cxx17)),
626                    ..Default::default()
627                },
628            };
629            let package = Package::new(
630                PackageName::new(name).unwrap(),
631                semver::Version::parse("0.1.0").unwrap(),
632                vec![target],
633                Vec::new(),
634            )
635            .unwrap();
636            let dir = std::path::PathBuf::from("/tmp").join(name);
637            WorkspacePackage {
638                package,
639                manifest_path: dir.join("cabin.toml"),
640                manifest_dir: dir,
641                deps: Vec::new(),
642                kind: PackageKind::Local,
643                is_port: false,
644            }
645        }
646
647        // Package 0 carries a C source; package 1 is C++-only.
648        let graph = PackageGraph {
649            root_manifest_path: std::path::PathBuf::from("/tmp/cabin.toml"),
650            root_dir: std::path::PathBuf::from("/tmp"),
651            is_workspace_root: true,
652            root_package: None,
653            root_settings: Default::default(),
654            primary_packages: vec![0, 1],
655            default_members: vec![0, 1],
656            excluded_members: Vec::new(),
657            packages: vec![pkg("with_c", "a.c"), pkg("cpp_only", "b.cc")],
658        };
659
660        // Selecting only the C++-only package must not observe the
661        // sibling's `.c` source - the over-broad case Codex reported.
662        let standards = HashMap::new();
663        let no_dev = BTreeSet::new();
664        let cpp_only =
665            collect_requested_standards(&graph, &BTreeSet::from([1usize]), &standards, &no_dev);
666        assert!(!cpp_only.has_c_sources());
667        assert_eq!(cpp_only.cxx, BTreeSet::from([CxxStandard::Cxx17]));
668        // Selecting the C package, or the whole workspace, does.
669        let with_c =
670            collect_requested_standards(&graph, &BTreeSet::from([0usize]), &standards, &no_dev);
671        assert!(with_c.has_c_sources());
672        assert_eq!(with_c.c, BTreeSet::from([CStandard::C11]));
673        let both = collect_requested_standards(
674            &graph,
675            &BTreeSet::from([0usize, 1usize]),
676            &standards,
677            &no_dev,
678        );
679        assert!(both.has_c_sources());
680    }
681
682    #[test]
683    fn requested_standards_skip_dev_only_targets_unless_activated() {
684        use cabin_core::{
685            CxxStandard, LanguageStandardSettings, Package, PackageName, Target, TargetKind,
686            TargetName,
687        };
688        use cabin_workspace::{PackageKind, WorkspacePackage};
689
690        let test_target = Target {
691            name: TargetName::new("t").unwrap(),
692            kind: TargetKind::Test,
693            sources: vec![Utf8PathBuf::from("t.cc")],
694            include_dirs: Vec::new(),
695            defines: Vec::new(),
696            deps: Vec::new(),
697            required_features: Vec::new(),
698            language: LanguageStandardSettings {
699                cxx_standard: Some(StandardDeclaration::Declared(CxxStandard::Cxx20)),
700                ..Default::default()
701            },
702        };
703        let package = Package::new(
704            PackageName::new("demo").unwrap(),
705            semver::Version::parse("0.1.0").unwrap(),
706            vec![test_target],
707            Vec::new(),
708        )
709        .unwrap();
710        let dir = std::path::PathBuf::from("/tmp/demo");
711        let graph = PackageGraph {
712            root_manifest_path: dir.join("cabin.toml"),
713            root_dir: dir.clone(),
714            is_workspace_root: false,
715            root_package: Some(0),
716            root_settings: Default::default(),
717            primary_packages: vec![0],
718            default_members: vec![0],
719            excluded_members: Vec::new(),
720            packages: vec![WorkspacePackage {
721                package,
722                manifest_path: dir.join("cabin.toml"),
723                manifest_dir: dir,
724                deps: Vec::new(),
725                kind: PackageKind::Local,
726                is_port: false,
727            }],
728        };
729        let standards = HashMap::new();
730        // `cabin build` (no dev activation): the test target's c++20
731        // does not gate the build.
732        let plain = collect_requested_standards(
733            &graph,
734            &BTreeSet::from([0usize]),
735            &standards,
736            &BTreeSet::new(),
737        );
738        assert!(plain.cxx.is_empty());
739        // `cabin test` activates the package's dev targets.
740        let dev = collect_requested_standards(
741            &graph,
742            &BTreeSet::from([0usize]),
743            &standards,
744            &BTreeSet::from(["demo".to_owned()]),
745        );
746        assert_eq!(dev.cxx, BTreeSet::from([CxxStandard::Cxx20]));
747    }
748
749    #[test]
750    fn standards_validation_rejects_unsupported_requested_standard() {
751        use cabin_core::CxxStandard;
752        let toolchain = make_toolchain("cl.exe", "lib.exe");
753        let report = report_for(
754            CompilerIdentity {
755                kind: CompilerKind::Msvc,
756                version: CompilerVersion::parse("19.39.33523"),
757                target: None,
758                raw_version_line: "Microsoft Optimizing Compiler 19.39".into(),
759            },
760            ArchiverIdentity {
761                kind: ArchiverKind::Lib,
762                version: None,
763                raw_version_line: "Microsoft Library Manager".into(),
764            },
765        );
766        // The backend shape is fine, and the default request passes...
767        validate_toolchain_for_backend(&toolchain, &report).unwrap();
768        validate_toolchain_standards(&toolchain, &report, &requested_defaults(false)).unwrap();
769        // ...but a planned c++23 compile is rejected: cl has no
770        // stable flag for it.
771        let err = validate_toolchain_standards(
772            &toolchain,
773            &report,
774            &requested(&[], &[CxxStandard::Cxx23]),
775        )
776        .unwrap_err();
777        assert!(
778            err.to_string().contains("c++23"),
779            "expected the offending standard to be named, got: {err}"
780        );
781    }
782}