cabinpkg-build 0.16.0

Backend-independent build graph planner for Cabin
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
//! Validate that a detected toolchain can run the commands the
//! C++ backend emits.
//!
//! The planner emits commands in one of two dialects — GCC/Clang-style
//! or MSVC-style — chosen by the detected C++ compiler. The GCC/Clang
//! shapes are:
//!
//! - C++ compile: `cxx -std=<effective standard> -O… [-g] [-DNDEBUG]
//!   -MD -MF <depfile> -D<name> -I<dir> [-isystem <dir>]
//!   [extra-args] -c <src> -o <obj>`.
//! - Static-library archive: `ar crs <lib> <objs>`.
//! - Link: `cxx <objs> <libs> [extra-args] -o <exe>`.
//!
//! Any compiler / archiver that cannot run those exact shapes — or
//! that lacks the flag for a *requested* language standard — is
//! rejected up front rather than left to fail with a confusing
//! Ninja error.

use std::collections::{BTreeSet, HashMap};
use std::hash::BuildHasher;

use cabin_core::{
    ArchiverKind, CStandard, CxxStandard, ResolvedLanguageStandards, ResolvedToolchain,
    SourceLanguage, ToolchainDetectionReport, classify_source, effective_c, effective_cxx,
    validate_ar_for_backend, validate_c_standards, validate_cc_for_backend,
    validate_cxx_for_backend, validate_cxx_standards,
};
use cabin_workspace::PackageGraph;

use crate::error::BuildError;

/// The set of language standards a build's compiles request, per
/// language. A language's set is empty iff no compile of that
/// language exists, which is the signal the C-compiler checks key
/// on.
///
/// The authoritative form is [`requested_standards_of`], derived
/// from a planned [`crate::BuildGraph`] so it covers exactly the compiles
/// the build will run — no more (an unbuilt sibling target must not
/// gate validation) and no less. [`collect_requested_standards`]
/// is the pre-plan package-level approximation used only where a
/// value is needed *before* planning (the MSVC `/external:I`
/// fallback decision).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RequestedStandards {
    pub c: BTreeSet<CStandard>,
    pub cxx: BTreeSet<CxxStandard>,
}

impl RequestedStandards {
    /// Whether any selected target compiles a `.c` source — the
    /// signal that scopes the C-compiler checks.
    #[must_use]
    pub fn has_c_sources(&self) -> bool {
        !self.c.is_empty()
    }
}

/// The exact standards a planned build requests: every compile
/// action in the graph contributes its typed standard. This is the
/// set [`validate_toolchain_for_backend`] should be called with —
/// the planner has already narrowed targets to the requested
/// selection, so a sibling target that is not built cannot gate the
/// toolchain.
#[must_use]
pub fn requested_standards_of(graph: &crate::BuildGraph) -> RequestedStandards {
    use cabin_core::LanguageStandard;
    use cabin_driver::BuildAction;
    let mut out = RequestedStandards::default();
    for action in &graph.actions {
        if let BuildAction::Compile(compile) = action {
            match compile.standard {
                LanguageStandard::C(standard) => {
                    out.c.insert(standard);
                }
                LanguageStandard::Cxx(standard) => {
                    out.cxx.insert(standard);
                }
            }
        }
    }
    out
}

/// Pre-plan approximation of [`requested_standards_of`]: walk every
/// selected package's targets and classify each declared source.
/// Over-approximates per target (it cannot know which targets the
/// plan will reach), so it must not gate validation — use it only
/// for decisions needed before planning, like the MSVC
/// `/external:I` fallback. Dev-only targets (`test` / `example`)
/// contribute only for packages in `dev_for`, mirroring
/// dev-dependency activation.
#[must_use]
pub fn collect_requested_standards<S: BuildHasher>(
    graph: &PackageGraph,
    selected: &BTreeSet<usize>,
    standards: &HashMap<usize, ResolvedLanguageStandards, S>,
    dev_for: &BTreeSet<String>,
) -> RequestedStandards {
    let mut out = RequestedStandards::default();
    for &idx in selected {
        let Some(pkg) = graph.packages.get(idx) else {
            continue;
        };
        let resolved = standards.get(&idx).copied().unwrap_or_default();
        let include_dev = dev_for.contains(pkg.package.name.as_str());
        for target in &pkg.package.targets {
            if target.kind.is_dev_only() && !include_dev {
                continue;
            }
            for source in &target.sources {
                match classify_source(source) {
                    Some(SourceLanguage::C) => {
                        out.c.insert(effective_c(&resolved, target).standard);
                    }
                    Some(SourceLanguage::Cxx) => {
                        out.cxx.insert(effective_cxx(&resolved, target).standard);
                    }
                    None => {}
                }
            }
        }
    }
    out
}

/// Validate that every populated tool in `report` can execute the
/// command shapes emitted by the current backend. Returns the
/// first problem encountered so users see one actionable error,
/// not a wall of unrelated failures.
///
/// Each tool is held to its dialect's contract. The MSVC dialect
/// drives `cl.exe` / `lib.exe`; the GCC/Clang dialect drives a
/// GCC-style compiler (GCC-style flags plus depfile) and an
/// `ar`-compatible archiver. The optional C compiler and every
/// language-standard check are validated separately by
/// [`validate_toolchain_standards`], post-plan, gated on the
/// compiles the plan actually emits — a `cc` slot (or a sibling
/// target's C source) that no planned compile uses must not gate
/// the build.
///
/// Beyond the per-tool checks, the C++ compiler and archiver must
/// belong to the *same* dialect: Cabin emits one command-line
/// dialect per build, so an MSVC compiler paired with a GNU `ar`
/// (or the reverse) is rejected rather than left to fail
/// mid-build.
///
/// `toolchain` is the matching [`ResolvedToolchain`] — we use it
/// to recover the user-visible spec strings (`clang++`,
/// `/opt/llvm/bin/clang++`) for the error messages.
///
/// # Errors
/// Returns [`BuildError::UnsupportedToolchain`] (wrapping the
/// [`cabin_core::ToolDetectionError`] from the first failing
/// `validate_*_for_backend` check) when a tool cannot run its
/// dialect's command shapes, and [`BuildError::MixedToolchainDialects`]
/// when the resolved tools span both dialects.
pub fn validate_toolchain_for_backend(
    toolchain: &ResolvedToolchain,
    report: &ToolchainDetectionReport,
) -> Result<(), BuildError> {
    let cxx_spec = toolchain.cxx.spec.display();
    validate_cxx_for_backend(&cxx_spec, &report.cxx.identity, &report.cxx.capabilities)?;
    let ar_spec = toolchain.ar.spec.display();
    validate_ar_for_backend(&ar_spec, &report.ar.identity, &report.ar.capabilities)?;

    // Both tools individually run; now require them to share a
    // dialect. The C++ compiler picks it (MSVC `cl` vs GCC/Clang)
    // and the archiver must match.
    let cxx_is_msvc = report.cxx.identity.kind.speaks_msvc_dialect();
    let ar_is_msvc = report.ar.identity.kind == ArchiverKind::Lib;
    if cxx_is_msvc != ar_is_msvc {
        return Err(BuildError::MixedToolchainDialects {
            detail: format!(
                "C++ compiler `{cxx_spec}` is {}, but archiver `{ar_spec}` is {}",
                dialect_label(cxx_is_msvc),
                dialect_label(ar_is_msvc),
            ),
        });
    }
    Ok(())
}

/// Validate the plan-dependent half of the toolchain contract:
/// every language standard in `requested`, plus — when the plan
/// actually compiles C — the C compiler's backend shape and
/// dialect coherence. `requested` is derived from the *final*
/// planned graph via [`requested_standards_of`] (after the
/// `cabin check` rewrite when applicable), so only compiles the
/// command will run participate: an unbuilt sibling target, an
/// unrelated C executable in a dependency package, or a dependency
/// compile that `cabin check` drops can never gate the build.
/// Runs after `plan()` and before any Ninja file is written; the
/// plan-independent C++/archiver shape checks in
/// [`validate_toolchain_for_backend`] stay pre-plan so a broken
/// toolchain still fails fast.
///
/// # Errors
/// Returns [`BuildError::UnsupportedToolchain`] wrapping the first
/// failing check ([`cabin_core::ToolDetectionError::CxxLacksStandard`],
/// [`cabin_core::ToolDetectionError::CLacksStandard`], or a C
/// backend-shape error) and [`BuildError::MixedToolchainDialects`]
/// when a C compile exists but the C compiler's dialect differs
/// from the C++ compiler's.
pub fn validate_toolchain_standards(
    toolchain: &ResolvedToolchain,
    report: &ToolchainDetectionReport,
    requested: &RequestedStandards,
) -> Result<(), BuildError> {
    let cxx_spec = toolchain.cxx.spec.display();
    validate_cxx_standards(&cxx_spec, &report.cxx.identity, &requested.cxx)?;
    // The C compiler is only validated when a C compile was actually
    // planned. The resolver fills the optional `cc` slot from the
    // host default fallback (`cl` on Windows) even for a C++-only
    // build, so checking it unconditionally would reject e.g.
    // `CXX=clang++` against a never-used default `cc=cl`.
    if requested.has_c_sources()
        && let (Some(cc_tool), Some(cc_detection)) = (toolchain.cc.as_ref(), report.cc.as_ref())
    {
        let cc_spec = cc_tool.spec.display();
        validate_cc_for_backend(&cc_spec, &cc_detection.identity, &cc_detection.capabilities)?;
        let cxx_is_msvc = report.cxx.identity.kind.speaks_msvc_dialect();
        let cc_is_msvc = cc_detection.identity.kind.speaks_msvc_dialect();
        if cc_is_msvc != cxx_is_msvc {
            return Err(BuildError::MixedToolchainDialects {
                detail: format!(
                    "C++ compiler `{cxx_spec}` is {}, but C compiler `{cc_spec}` is {}",
                    dialect_label(cxx_is_msvc),
                    dialect_label(cc_is_msvc),
                ),
            });
        }
        validate_c_standards(&cc_spec, &cc_detection.identity, &requested.c)?;
    }
    Ok(())
}

/// Surface the first standards violation the planner recorded that
/// survived into the final graph (after the `cabin check` rewrite,
/// when applicable): an MSVC no-stable-flag compile or an
/// escape-hatch flag conflict on a planned compile. Must run before
/// anything is lowered or written; commands that skip the
/// toolchain-validation pass (`cabin tidy`'s fail-soft path) must
/// still call this so a violating compile cannot be silently
/// dropped from the compile database.
///
/// # Errors
/// Returns [`BuildError::StandardUnsupportedOnMsvcDialect`] or
/// [`BuildError::StandardFlagConflict`] for the first surviving
/// violation.
pub fn validate_planned_standards(graph: &crate::BuildGraph) -> Result<(), BuildError> {
    match graph.standard_violations.first() {
        Some(crate::StandardViolation::MsvcSpelling {
            target,
            language,
            standard,
            ..
        }) => Err(BuildError::StandardUnsupportedOnMsvcDialect {
            target: target.clone(),
            language,
            standard,
        }),
        Some(crate::StandardViolation::FlagConflict { conflict, .. }) => {
            Err(BuildError::StandardFlagConflict(Box::new(conflict.clone())))
        }
        Some(crate::StandardViolation::InterfaceIncompatibility {
            consumer,
            dependency,
            language,
            consumer_standard,
            required,
            requirement_source,
            ..
        }) => Err(BuildError::IncompatibleLanguageStandard {
            consumer: consumer.clone(),
            dependency: dependency.clone(),
            language,
            consumer_standard,
            required,
            requirement_source,
        }),
        None => Ok(()),
    }
}

/// Human-readable dialect name for the mixed-toolchain diagnostic.
fn dialect_label(is_msvc: bool) -> &'static str {
    if is_msvc { "MSVC" } else { "GCC/Clang-style" }
}

/// Whether every compiler that will run a compile in this build
/// accepts the MSVC-dialect distinct system-include spelling
/// (`/external:W0` + `/external:I <dir>`). Mirrors
/// [`validate_toolchain_for_backend`]'s contract for the optional
/// `cc` slot: the C compiler only weighs in when a C source exists,
/// because the planner never invokes it otherwise.
///
/// The planner consults the answer through
/// [`crate::PlanRequest::msvc_external_includes`] and falls back to
/// plain `/I` when it is `false`. On a GCC/Clang build the value is
/// ignored — `-isystem` is part of the base dialect.
#[must_use]
pub fn msvc_external_includes_supported(
    report: &ToolchainDetectionReport,
    has_c_sources: bool,
) -> bool {
    let cxx_ok = report.cxx.capabilities.external_include_dirs.supported;
    let cc_ok = !has_c_sources
        || report
            .cc
            .as_ref()
            .is_none_or(|cc| cc.capabilities.external_include_dirs.supported);
    cxx_ok && cc_ok
}

#[cfg(test)]
mod tests {
    use super::*;
    use cabin_core::{
        ArchiverIdentity, ArchiverKind, CompilerIdentity, CompilerKind, CompilerVersion,
        ResolvedTool, ResolvedToolchain, StandardDeclaration, ToolDetection, ToolKind, ToolSource,
        ToolSpec, derive_ar_capabilities, derive_cxx_capabilities,
    };
    use camino::Utf8PathBuf;

    fn requested(c: &[CStandard], cxx: &[CxxStandard]) -> RequestedStandards {
        RequestedStandards {
            c: c.iter().copied().collect(),
            cxx: cxx.iter().copied().collect(),
        }
    }

    /// The historic defaults: a C++17 build, optionally with C11.
    fn requested_defaults(with_c: bool) -> RequestedStandards {
        if with_c {
            requested(&[CStandard::C11], &[CxxStandard::Cxx17])
        } else {
            requested(&[], &[CxxStandard::Cxx17])
        }
    }

    fn make_toolchain(cxx_spec: &str, ar_spec: &str) -> ResolvedToolchain {
        ResolvedToolchain {
            cxx: ResolvedTool {
                kind: ToolKind::CxxCompiler,
                path: Utf8PathBuf::from("/bin").join(cxx_spec),
                spec: ToolSpec::Name(cxx_spec.into()),
                source: ToolSource::Default,
            },
            ar: ResolvedTool {
                kind: ToolKind::Archiver,
                path: Utf8PathBuf::from("/bin").join(ar_spec),
                spec: ToolSpec::Name(ar_spec.into()),
                source: ToolSource::Default,
            },
            cc: None,
        }
    }

    fn report_for(cxx: CompilerIdentity, ar: ArchiverIdentity) -> ToolchainDetectionReport {
        let cxx_caps = derive_cxx_capabilities(&cxx);
        let ar_caps = derive_ar_capabilities(&ar);
        ToolchainDetectionReport {
            cxx: ToolDetection {
                path: Utf8PathBuf::from("/bin/cxx"),
                identity: cxx,
                capabilities: cxx_caps,
            },
            cc: None,
            ar: ToolDetection {
                path: Utf8PathBuf::from("/bin/ar"),
                identity: ar,
                capabilities: ar_caps,
            },
        }
    }

    #[test]
    fn accepts_clang_with_gnu_ar() {
        let toolchain = make_toolchain("clang++", "ar");
        let report = report_for(
            CompilerIdentity {
                kind: CompilerKind::Clang,
                version: CompilerVersion::parse("17.0.6"),
                target: None,
                raw_version_line: "clang version 17.0.6".into(),
            },
            ArchiverIdentity {
                kind: ArchiverKind::Ar,
                version: CompilerVersion::parse("2.40"),
                raw_version_line: "GNU ar".into(),
            },
        );
        validate_toolchain_for_backend(&toolchain, &report).unwrap();
    }

    #[test]
    fn accepts_full_msvc_toolchain() {
        // `cl` + `lib` is a coherent MSVC toolchain and is now a
        // first-class supported backend.
        let toolchain = make_toolchain("cl.exe", "lib.exe");
        let report = report_for(
            CompilerIdentity {
                kind: CompilerKind::Msvc,
                version: None,
                target: None,
                raw_version_line: "Microsoft Optimizing Compiler".into(),
            },
            ArchiverIdentity {
                kind: ArchiverKind::Lib,
                version: None,
                raw_version_line: "Microsoft Library Manager".into(),
            },
        );
        validate_toolchain_for_backend(&toolchain, &report).unwrap();
    }

    #[test]
    fn msvc_external_includes_follow_the_cl_version() {
        let lib = || ArchiverIdentity {
            kind: ArchiverKind::Lib,
            version: None,
            raw_version_line: "Microsoft Library Manager".into(),
        };
        let cl = |version: &str| CompilerIdentity {
            kind: CompilerKind::Msvc,
            version: CompilerVersion::parse(version),
            target: None,
            raw_version_line: format!("Microsoft Optimizing Compiler {version}"),
        };
        let modern = report_for(cl("19.29.30133"), lib());
        assert!(msvc_external_includes_supported(&modern, false));
        let old = report_for(cl("19.20.27508"), lib());
        assert!(!msvc_external_includes_supported(&old, false));
    }

    #[test]
    fn msvc_external_includes_consider_cc_only_with_c_sources() {
        // A modern C++ `cl` paired with an old C `cl`: the C compiler
        // only matters when a C source will actually be compiled.
        let lib = ArchiverIdentity {
            kind: ArchiverKind::Lib,
            version: None,
            raw_version_line: "Microsoft Library Manager".into(),
        };
        let modern_cl = CompilerIdentity {
            kind: CompilerKind::Msvc,
            version: CompilerVersion::parse("19.39.33523"),
            target: None,
            raw_version_line: "Microsoft Optimizing Compiler 19.39".into(),
        };
        let old_cl = CompilerIdentity {
            kind: CompilerKind::Msvc,
            version: CompilerVersion::parse("19.20.27508"),
            target: None,
            raw_version_line: "Microsoft Optimizing Compiler 19.20".into(),
        };
        let mut report = report_for(modern_cl, lib);
        let old_caps = derive_cxx_capabilities(&old_cl);
        report.cc = Some(ToolDetection {
            path: Utf8PathBuf::from("/bin/cc"),
            identity: old_cl,
            capabilities: old_caps,
        });
        assert!(msvc_external_includes_supported(&report, false));
        assert!(!msvc_external_includes_supported(&report, true));
    }

    #[test]
    fn accepts_clang_cl_with_lib_and_rejects_it_with_gnu_ar() {
        // `clang-cl` speaks the MSVC dialect, so it pairs with
        // `lib.exe` (coherent) but not with GNU `ar` (mixed).
        let clang_cl = CompilerIdentity {
            kind: CompilerKind::ClangCl,
            version: CompilerVersion::parse("17.0.6"),
            target: None,
            raw_version_line: "clang version 17.0.6".into(),
        };
        let lib = ArchiverIdentity {
            kind: ArchiverKind::Lib,
            version: None,
            raw_version_line: "Microsoft Library Manager".into(),
        };
        let report = report_for(clang_cl.clone(), lib);
        validate_toolchain_for_backend(&make_toolchain("clang-cl", "lib.exe"), &report).unwrap();

        let gnu_ar = ArchiverIdentity {
            kind: ArchiverKind::Ar,
            version: CompilerVersion::parse("2.40"),
            raw_version_line: "GNU ar".into(),
        };
        let mixed = report_for(clang_cl, gnu_ar);
        let err =
            validate_toolchain_for_backend(&make_toolchain("clang-cl", "ar"), &mixed).unwrap_err();
        assert!(
            matches!(err, BuildError::MixedToolchainDialects { .. }),
            "clang-cl + GNU ar should be rejected as mixed-dialect, got: {err}"
        );
    }

    #[test]
    fn rejects_mixed_dialect_toolchain() {
        // A GCC/Clang compiler with an MSVC archiver runs each tool
        // individually but cannot be driven as one dialect.
        let toolchain = make_toolchain("clang++", "lib.exe");
        let report = report_for(
            CompilerIdentity {
                kind: CompilerKind::Clang,
                version: CompilerVersion::parse("17.0.6"),
                target: None,
                raw_version_line: "clang version 17.0.6".into(),
            },
            ArchiverIdentity {
                kind: ArchiverKind::Lib,
                version: None,
                raw_version_line: "Microsoft Library Manager".into(),
            },
        );
        let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err();
        assert!(
            matches!(err, BuildError::MixedToolchainDialects { .. }),
            "expected mixed-dialect rejection, got: {err}"
        );
    }

    #[test]
    fn defers_cc_dialect_check_until_c_compiles_are_planned() {
        // A C++-only build that selects GNU `clang++` but whose
        // optional `cc` slot defaulted to MSVC `cl` must not be
        // rejected: with no planned C compile the planner never
        // invokes that `cc`. Once a C compile is planned, the mixed
        // C/C++ dialect is a real problem and the check fires.
        let mut toolchain = make_toolchain("clang++", "ar");
        toolchain.cc = Some(ResolvedTool {
            kind: ToolKind::CCompiler,
            path: Utf8PathBuf::from("/bin/cl.exe"),
            spec: ToolSpec::Name("cl".into()),
            source: ToolSource::Default,
        });
        let cc_identity = CompilerIdentity {
            kind: CompilerKind::Msvc,
            version: CompilerVersion::parse("19.39.0"),
            target: None,
            raw_version_line: "Microsoft Optimizing Compiler".into(),
        };
        let mut report = report_for(
            CompilerIdentity {
                kind: CompilerKind::Clang,
                version: CompilerVersion::parse("17.0.6"),
                target: None,
                raw_version_line: "clang version 17.0.6".into(),
            },
            ArchiverIdentity {
                kind: ArchiverKind::Ar,
                version: CompilerVersion::parse("2.40"),
                raw_version_line: "GNU ar".into(),
            },
        );
        report.cc = Some(ToolDetection {
            path: Utf8PathBuf::from("/bin/cl.exe"),
            capabilities: derive_cxx_capabilities(&cc_identity),
            identity: cc_identity,
        });

        // The backend-shape pass never consults `cc`.
        validate_toolchain_for_backend(&toolchain, &report).unwrap();
        // No planned C compile: the defaulted MSVC `cc` is never
        // used, so the C++-only GNU toolchain validates.
        validate_toolchain_standards(&toolchain, &report, &requested_defaults(false)).unwrap();
        // A C compile is planned: the mixed C/C++ dialect is rejected.
        let err = validate_toolchain_standards(&toolchain, &report, &requested_defaults(true))
            .unwrap_err();
        assert!(
            matches!(err, BuildError::MixedToolchainDialects { .. }),
            "expected mixed-dialect rejection once C compiles are planned, got: {err}"
        );
    }

    #[test]
    fn rejects_unknown_compiler_clearly() {
        let toolchain = make_toolchain("custom-cxx", "ar");
        let report = report_for(
            CompilerIdentity::unknown("???"),
            ArchiverIdentity {
                kind: ArchiverKind::Ar,
                version: None,
                raw_version_line: "GNU ar".into(),
            },
        );
        let err = validate_toolchain_for_backend(&toolchain, &report).unwrap_err();
        let message = err.to_string();
        assert!(
            message.contains("could not be identified"),
            "expected unknown-compiler error, got: {message}"
        );
    }

    #[test]
    fn c_source_detection_is_scoped_to_the_selected_closure() {
        use cabin_core::{Package, PackageName, Target, TargetKind, TargetName};
        use cabin_workspace::{PackageKind, WorkspacePackage};

        fn pkg(name: &str, source: &str) -> WorkspacePackage {
            let target = Target {
                name: TargetName::new("lib").unwrap(),
                kind: TargetKind::Library,
                sources: vec![Utf8PathBuf::from(source)],
                include_dirs: Vec::new(),
                defines: Vec::new(),
                deps: Vec::new(),
                language: Default::default(),
            };
            let package = Package::new(
                PackageName::new(name).unwrap(),
                semver::Version::parse("0.1.0").unwrap(),
                vec![target],
                Vec::new(),
            )
            .unwrap();
            let dir = std::path::PathBuf::from("/tmp").join(name);
            WorkspacePackage {
                package,
                manifest_path: dir.join("cabin.toml"),
                manifest_dir: dir,
                deps: Vec::new(),
                kind: PackageKind::Local,
                is_port: false,
            }
        }

        // Package 0 carries a C source; package 1 is C++-only.
        let graph = PackageGraph {
            root_manifest_path: std::path::PathBuf::from("/tmp/cabin.toml"),
            root_dir: std::path::PathBuf::from("/tmp"),
            is_workspace_root: true,
            root_package: None,
            root_settings: Default::default(),
            primary_packages: vec![0, 1],
            default_members: vec![0, 1],
            excluded_members: Vec::new(),
            packages: vec![pkg("with_c", "a.c"), pkg("cpp_only", "b.cc")],
        };

        // Selecting only the C++-only package must not observe the
        // sibling's `.c` source — the over-broad case Codex reported.
        let standards = HashMap::new();
        let no_dev = BTreeSet::new();
        let cpp_only =
            collect_requested_standards(&graph, &BTreeSet::from([1usize]), &standards, &no_dev);
        assert!(!cpp_only.has_c_sources());
        assert_eq!(
            cpp_only.cxx,
            BTreeSet::from([cabin_core::DEFAULT_CXX_STANDARD])
        );
        // Selecting the C package, or the whole workspace, does.
        let with_c =
            collect_requested_standards(&graph, &BTreeSet::from([0usize]), &standards, &no_dev);
        assert!(with_c.has_c_sources());
        assert_eq!(with_c.c, BTreeSet::from([cabin_core::DEFAULT_C_STANDARD]));
        let both = collect_requested_standards(
            &graph,
            &BTreeSet::from([0usize, 1usize]),
            &standards,
            &no_dev,
        );
        assert!(both.has_c_sources());
    }

    #[test]
    fn requested_standards_skip_dev_only_targets_unless_activated() {
        use cabin_core::{
            CxxStandard, LanguageStandardSettings, Package, PackageName, Target, TargetKind,
            TargetName,
        };
        use cabin_workspace::{PackageKind, WorkspacePackage};

        let test_target = Target {
            name: TargetName::new("t").unwrap(),
            kind: TargetKind::Test,
            sources: vec![Utf8PathBuf::from("t.cc")],
            include_dirs: Vec::new(),
            defines: Vec::new(),
            deps: Vec::new(),
            language: LanguageStandardSettings {
                cxx_standard: Some(StandardDeclaration::Declared(CxxStandard::Cxx20)),
                ..Default::default()
            },
        };
        let package = Package::new(
            PackageName::new("demo").unwrap(),
            semver::Version::parse("0.1.0").unwrap(),
            vec![test_target],
            Vec::new(),
        )
        .unwrap();
        let dir = std::path::PathBuf::from("/tmp/demo");
        let graph = PackageGraph {
            root_manifest_path: dir.join("cabin.toml"),
            root_dir: dir.clone(),
            is_workspace_root: false,
            root_package: Some(0),
            root_settings: Default::default(),
            primary_packages: vec![0],
            default_members: vec![0],
            excluded_members: Vec::new(),
            packages: vec![WorkspacePackage {
                package,
                manifest_path: dir.join("cabin.toml"),
                manifest_dir: dir,
                deps: Vec::new(),
                kind: PackageKind::Local,
                is_port: false,
            }],
        };
        let standards = HashMap::new();
        // `cabin build` (no dev activation): the test target's c++20
        // does not gate the build.
        let plain = collect_requested_standards(
            &graph,
            &BTreeSet::from([0usize]),
            &standards,
            &BTreeSet::new(),
        );
        assert!(plain.cxx.is_empty());
        // `cabin test` activates the package's dev targets.
        let dev = collect_requested_standards(
            &graph,
            &BTreeSet::from([0usize]),
            &standards,
            &BTreeSet::from(["demo".to_owned()]),
        );
        assert_eq!(dev.cxx, BTreeSet::from([CxxStandard::Cxx20]));
    }

    #[test]
    fn standards_validation_rejects_unsupported_requested_standard() {
        use cabin_core::CxxStandard;
        let toolchain = make_toolchain("cl.exe", "lib.exe");
        let report = report_for(
            CompilerIdentity {
                kind: CompilerKind::Msvc,
                version: CompilerVersion::parse("19.39.33523"),
                target: None,
                raw_version_line: "Microsoft Optimizing Compiler 19.39".into(),
            },
            ArchiverIdentity {
                kind: ArchiverKind::Lib,
                version: None,
                raw_version_line: "Microsoft Library Manager".into(),
            },
        );
        // The backend shape is fine, and the default request passes...
        validate_toolchain_for_backend(&toolchain, &report).unwrap();
        validate_toolchain_standards(&toolchain, &report, &requested_defaults(false)).unwrap();
        // ...but a planned c++23 compile is rejected: cl has no
        // stable flag for it.
        let err = validate_toolchain_standards(
            &toolchain,
            &report,
            &requested(&[], &[CxxStandard::Cxx23]),
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("c++23"),
            "expected the offending standard to be named, got: {err}"
        );
    }
}