Skip to main content

cabin_core/
compiler.rs

1//! Typed compiler / tool identity and capability model.
2//!
3//! Cabin's build planner emits GCC/Clang-style commands. The
4//! `ResolvedToolchain` (see [`crate::toolchain`]) says *which*
5//! tools the user picked; this module says *what those tools are*
6//! and *what they can do*. The
7//! resolver in `cabin-toolchain::detect` runs harmless `--version`
8//! invocations against each resolved tool, hands the output to the
9//! pure parsers in this module, and assembles a typed
10//! [`ToolchainDetectionReport`].
11//!
12//! This module is data and pure logic only. Process spawning,
13//! filesystem traversal, and CLI dispatch live elsewhere.
14
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20/// Recognized C/C++ compiler family.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum CompilerKind {
24    /// LLVM Clang.
25    Clang,
26    /// Apple-shipped Clang (`Apple clang version …`). Treated as
27    /// Clang-compatible for capability purposes; tracked separately
28    /// for diagnostics.
29    AppleClang,
30    /// GNU GCC / `g++`.
31    Gcc,
32    /// Microsoft Visual C++ (`cl.exe`). Detected so Cabin can
33    /// produce a clear unsupported-backend error; the GCC/Clang
34    /// command pipeline cannot be used with this compiler.
35    Msvc,
36    /// Compiler whose `--version` output Cabin does not recognize.
37    /// Capability detection treats this conservatively.
38    Unknown,
39}
40
41impl CompilerKind {
42    /// Stable lower-case identifier used in metadata output.
43    pub fn as_key(self) -> &'static str {
44        match self {
45            CompilerKind::Clang => "clang",
46            CompilerKind::AppleClang => "apple-clang",
47            CompilerKind::Gcc => "gcc",
48            CompilerKind::Msvc => "msvc",
49            CompilerKind::Unknown => "unknown",
50        }
51    }
52
53    /// Whether this compiler is part of the Clang family.
54    pub fn is_clang_like(self) -> bool {
55        matches!(self, CompilerKind::Clang | CompilerKind::AppleClang)
56    }
57
58    /// Whether this compiler accepts the GCC-style command line
59    /// the current C++ backend emits (`-O<n>`, `-std=c++NN`,
60    /// `-MMD -MF`, `-DNAME`, `-Idir`, …).
61    pub fn supports_gcc_style_command_line(self) -> bool {
62        matches!(
63            self,
64            CompilerKind::Clang | CompilerKind::AppleClang | CompilerKind::Gcc
65        )
66    }
67}
68
69impl fmt::Display for CompilerKind {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(self.as_key())
72    }
73}
74
75/// Recognized static-library archiver family.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum ArchiverKind {
79    /// GNU `ar` / BSD `ar`. Accepts the `crs` mode flags Cabin
80    /// emits today.
81    Ar,
82    /// LLVM `llvm-ar`. Accepts the same `crs` mode flags.
83    LlvmAr,
84    /// Microsoft `lib.exe`. Detected so Cabin can produce a clear
85    /// unsupported-backend error.
86    Lib,
87    /// Archiver whose `--version` output Cabin does not recognize.
88    Unknown,
89}
90
91impl ArchiverKind {
92    pub fn as_key(self) -> &'static str {
93        match self {
94            ArchiverKind::Ar => "ar",
95            ArchiverKind::LlvmAr => "llvm-ar",
96            ArchiverKind::Lib => "lib",
97            ArchiverKind::Unknown => "unknown",
98        }
99    }
100
101    /// Whether this archiver accepts the `crs` mode flags Cabin
102    /// emits today.
103    pub fn supports_ar_crs(self) -> bool {
104        matches!(self, ArchiverKind::Ar | ArchiverKind::LlvmAr)
105    }
106}
107
108impl fmt::Display for ArchiverKind {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str(self.as_key())
111    }
112}
113
114/// Decomposed compiler / archiver version (`major.minor.patch`).
115///
116/// `major` is required; `minor` and `patch` are optional because
117/// some versions only report two components. `raw` keeps the
118/// original substring for diagnostics.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct CompilerVersion {
121    pub major: u32,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub minor: Option<u32>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub patch: Option<u32>,
126    pub raw: String,
127}
128
129impl CompilerVersion {
130    /// Parse a `major[.minor[.patch]]` substring into a typed
131    /// [`CompilerVersion`]. Returns `None` when the leading
132    /// component is not a valid `u32`.
133    pub fn parse(raw: &str) -> Option<Self> {
134        let mut parts = raw.split('.');
135        let major: u32 = parts.next()?.parse().ok()?;
136        let minor = parts.next().and_then(|s| s.parse().ok());
137        let patch = parts.next().and_then(|s| s.parse().ok());
138        Some(Self {
139            major,
140            minor,
141            patch,
142            raw: raw.to_owned(),
143        })
144    }
145
146    /// Formatted `major.minor.patch` view, omitting unset
147    /// components. Used in metadata JSON and `CABIN_*` env vars.
148    pub fn to_display_string(&self) -> String {
149        match (self.minor, self.patch) {
150            (Some(min), Some(pat)) => format!("{}.{}.{}", self.major, min, pat),
151            (Some(min), None) => format!("{}.{}", self.major, min),
152            _ => self.major.to_string(),
153        }
154    }
155}
156
157impl fmt::Display for CompilerVersion {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.write_str(&self.to_display_string())
160    }
161}
162
163/// Detected identity of one C/C++ compiler.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct CompilerIdentity {
166    pub kind: CompilerKind,
167    /// Parsed version, when the version-output line was
168    /// recognized. `None` when the compiler emitted output Cabin
169    /// could not parse.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub version: Option<CompilerVersion>,
172    /// Optional default target triple as the compiler reported it
173    /// (the "Target: …" line from Clang, or an analogous GCC line).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub target: Option<String>,
176    /// First non-empty line of combined `--version` output, kept
177    /// for diagnostics. Truncated to a sensible length.
178    pub raw_version_line: String,
179}
180
181impl CompilerIdentity {
182    /// Convenience: identity for an unknown / unparsable compiler.
183    pub fn unknown(raw_version_line: impl Into<String>) -> Self {
184        Self {
185            kind: CompilerKind::Unknown,
186            version: None,
187            target: None,
188            raw_version_line: raw_version_line.into(),
189        }
190    }
191
192    /// Compact JSON view used by `cabin metadata`.
193    pub fn as_json(&self) -> serde_json::Value {
194        let mut obj = serde_json::Map::new();
195        obj.insert(
196            "kind".to_owned(),
197            serde_json::Value::String(self.kind.as_key().to_owned()),
198        );
199        if let Some(v) = &self.version {
200            obj.insert(
201                "version".to_owned(),
202                serde_json::Value::String(v.to_display_string()),
203            );
204        }
205        if let Some(t) = &self.target {
206            obj.insert("target".to_owned(), serde_json::Value::String(t.clone()));
207        }
208        obj.insert(
209            "raw_version_line".to_owned(),
210            serde_json::Value::String(self.raw_version_line.clone()),
211        );
212        serde_json::Value::Object(obj)
213    }
214}
215
216/// Detected identity of a static-library archiver.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ArchiverIdentity {
219    pub kind: ArchiverKind,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub version: Option<CompilerVersion>,
222    pub raw_version_line: String,
223}
224
225impl ArchiverIdentity {
226    pub fn unknown(raw_version_line: impl Into<String>) -> Self {
227        Self {
228            kind: ArchiverKind::Unknown,
229            version: None,
230            raw_version_line: raw_version_line.into(),
231        }
232    }
233
234    pub fn as_json(&self) -> serde_json::Value {
235        let mut obj = serde_json::Map::new();
236        obj.insert(
237            "kind".to_owned(),
238            serde_json::Value::String(self.kind.as_key().to_owned()),
239        );
240        if let Some(v) = &self.version {
241            obj.insert(
242                "version".to_owned(),
243                serde_json::Value::String(v.to_display_string()),
244            );
245        }
246        obj.insert(
247            "raw_version_line".to_owned(),
248            serde_json::Value::String(self.raw_version_line.clone()),
249        );
250        serde_json::Value::Object(obj)
251    }
252}
253
254/// Where one capability decision came from. Recorded so
255/// `cabin metadata` can show whether Cabin trusted the version
256/// alone, ran a probe, or fell back to a conservative default.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "kebab-case")]
259pub enum CapabilitySource {
260    /// Inferred from a recognized compiler kind/version.
261    Version,
262    /// Established by running a tightly-scoped probe command. Not
263    /// currently used; reserved for a future probe-based source
264    /// without changing the data model.
265    Probe,
266    /// Conservative default applied when the compiler kind is
267    /// `Unknown` or detection failed.
268    AssumedDefault,
269    /// The selected tool is recognizably unable to provide this
270    /// capability (e.g. MSVC asked for GCC-style flags).
271    Unsupported,
272}
273
274impl CapabilitySource {
275    pub fn as_key(self) -> &'static str {
276        match self {
277            CapabilitySource::Version => "version",
278            CapabilitySource::Probe => "probe",
279            CapabilitySource::AssumedDefault => "assumed-default",
280            CapabilitySource::Unsupported => "unsupported",
281        }
282    }
283}
284
285/// One typed capability decision: whether the tool supports it,
286/// and where the answer came from.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Capability {
289    pub supported: bool,
290    pub source: CapabilitySource,
291}
292
293impl Capability {
294    pub fn supported_from(source: CapabilitySource) -> Self {
295        Self {
296            supported: true,
297            source,
298        }
299    }
300    pub fn unsupported_from(source: CapabilitySource) -> Self {
301        Self {
302            supported: false,
303            source,
304        }
305    }
306}
307
308/// Capability set for a C/C++ compiler. Every field is decided
309/// during detection so the planner can compare its required set
310/// against the resolved set without re-running parsing logic.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct CompilerCapabilities {
313    /// Accepts GCC-style `-O<n>`, `-DNAME`, `-Idir`, `-c`, `-o`.
314    pub gcc_style_flags: Capability,
315    /// Accepts MSVC-style `/O<n>`, `/DNAME`, `/I dir`. Detection-
316    /// only; the current backend never emits these.
317    pub msvc_style_flags: Capability,
318    /// Accepts `-MMD -MF <file>` to write a make-style depfile.
319    pub depfile_mmd_mf: Capability,
320    /// Accepts `-std=c++NN`.
321    pub std_flag: Capability,
322    /// Accepts `-std=c++17` specifically (the planner's current
323    /// fixed C++ standard).
324    pub cxx_standard_17: Capability,
325    /// Accepts a color-diagnostics flag (e.g.
326    /// `-fdiagnostics-color=always`). Detection-only today.
327    pub color_diagnostics_flag: Capability,
328    /// Accepts response-file argv (`@file`). Detection-only today.
329    pub response_files: Capability,
330    /// Accepts a JSON diagnostics flag (`-fdiagnostics-format=json`
331    /// or equivalent). Detection-only; Cabin does not yet ask for
332    /// JSON diagnostics.
333    pub json_diagnostics: Capability,
334    /// Accepts a SARIF diagnostics flag. Detection-only.
335    pub sarif_diagnostics: Capability,
336}
337
338/// Capability set for a static-library archiver.
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct ArchiverCapabilities {
341    /// Accepts the `crs` mode flags (the planner's archive form).
342    pub ar_crs: Capability,
343    /// Produces a `.a` static library archive.
344    pub static_library_output: Capability,
345}
346
347/// Whole-toolchain detection report. The CLI builds one per
348/// invocation that needs detection (build / metadata) and threads
349/// it into the planner and the metadata view.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct ToolchainDetectionReport {
352    pub cxx: ToolDetection<CompilerIdentity, CompilerCapabilities>,
353    /// Optional because `ResolvedToolchain.cc` is itself optional.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub cc: Option<ToolDetection<CompilerIdentity, CompilerCapabilities>>,
356    pub ar: ToolDetection<ArchiverIdentity, ArchiverCapabilities>,
357}
358
359impl ToolchainDetectionReport {
360    /// Compact, deterministic JSON view used by `cabin metadata`
361    /// and any tooling that wants to inspect detection results
362    /// without re-deriving them. Each tool block carries
363    /// `path` / `identity` / `capabilities`; absent tools (a
364    /// missing C compiler) are omitted entirely so the JSON
365    /// shape stays stable.
366    pub fn as_json(&self) -> serde_json::Value {
367        let mut obj = serde_json::Map::new();
368        obj.insert(
369            "cxx".to_owned(),
370            serde_json::json!({
371                "path": self.cxx.path.display().to_string(),
372                "identity": self.cxx.identity.as_json(),
373                "capabilities": cxx_capabilities_as_json(&self.cxx.capabilities),
374            }),
375        );
376        if let Some(cc) = &self.cc {
377            obj.insert(
378                "cc".to_owned(),
379                serde_json::json!({
380                    "path": cc.path.display().to_string(),
381                    "identity": cc.identity.as_json(),
382                    "capabilities": cxx_capabilities_as_json(&cc.capabilities),
383                }),
384            );
385        }
386        obj.insert(
387            "ar".to_owned(),
388            serde_json::json!({
389                "path": self.ar.path.display().to_string(),
390                "identity": self.ar.identity.as_json(),
391                "capabilities": ar_capabilities_as_json(&self.ar.capabilities),
392            }),
393        );
394        serde_json::Value::Object(obj)
395    }
396}
397
398/// One tool's detection outcome plus the path it was invoked at.
399/// `path` is the resolved absolute path from
400/// [`crate::ResolvedToolchain`]; it is preserved here so error
401/// messages can mention the exact executable.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct ToolDetection<I, C> {
404    pub path: std::path::PathBuf,
405    pub identity: I,
406    pub capabilities: C,
407}
408
409/// Pure parser for compiler `--version` output.
410///
411/// Recognizes the canonical first-line shapes Cabin cares about:
412///
413/// - `clang version 17.0.6 (...)`
414/// - `Apple clang version 14.0.3 (clang-1403.0.22.14.1)`
415/// - `g++ (Ubuntu 11.4.0-1ubuntu1) 11.4.0`
416/// - `Microsoft (R) C/C++ Optimizing Compiler Version 19.39.x`
417/// - any other first non-empty line → [`CompilerKind::Unknown`].
418///
419/// Also picks up the `Target: aarch64-apple-darwin` / similar
420/// follow-up line when present so metadata can show the
421/// compiler-reported target without running additional probes.
422pub fn parse_cxx_version_output(text: &str) -> CompilerIdentity {
423    let lines: Vec<&str> = text
424        .lines()
425        .map(str::trim_end)
426        .filter(|l| !l.is_empty())
427        .collect();
428    let first_line = lines.first().copied().unwrap_or("").to_owned();
429
430    let kind = detect_cxx_kind(&lines);
431    let version = match kind {
432        CompilerKind::Clang | CompilerKind::AppleClang => parse_clang_version(&lines),
433        CompilerKind::Gcc => parse_gcc_version(&lines),
434        CompilerKind::Msvc => parse_msvc_version(&lines),
435        CompilerKind::Unknown => None,
436    };
437    let target = parse_target_line(&lines);
438
439    CompilerIdentity {
440        kind,
441        version,
442        target,
443        raw_version_line: truncate(&first_line, 256),
444    }
445}
446
447fn detect_cxx_kind(lines: &[&str]) -> CompilerKind {
448    let joined = lines.join("\n");
449    let lower = joined.to_ascii_lowercase();
450    if lower.contains("apple clang") {
451        return CompilerKind::AppleClang;
452    }
453    if lower.contains("clang version")
454        || lower.contains("clang++")
455        || lower.contains("openbsd clang")
456    {
457        return CompilerKind::Clang;
458    }
459    if lower.contains("microsoft (r)") || lower.contains("microsoft c/c++") {
460        return CompilerKind::Msvc;
461    }
462    if lower.contains("free software foundation")
463        || lower.starts_with("g++")
464        || lower.starts_with("gcc")
465        || lower.contains("gnu c++")
466    {
467        return CompilerKind::Gcc;
468    }
469    CompilerKind::Unknown
470}
471
472fn parse_clang_version(lines: &[&str]) -> Option<CompilerVersion> {
473    let first = lines.first()?;
474    let lower = first.to_ascii_lowercase();
475    let needle = if lower.starts_with("apple clang") {
476        "apple clang version "
477    } else {
478        "clang version "
479    };
480    let idx = lower.find(needle)?;
481    let after = &first[idx + needle.len()..];
482    let token = after
483        .split_whitespace()
484        .next()
485        .unwrap_or("")
486        .trim_end_matches(',');
487    CompilerVersion::parse(token)
488}
489
490fn parse_gcc_version(lines: &[&str]) -> Option<CompilerVersion> {
491    // GCC's first line typically looks like
492    //   "g++ (Ubuntu 11.4.0-1ubuntu1) 11.4.0"
493    // The version we care about is the last whitespace-delimited
494    // token; some distros add a trailing copyright suffix on the
495    // same line, so we accept the *last* dotted-numeric token.
496    let first = lines.first()?;
497    first
498        .split_whitespace()
499        .filter_map(|tok| {
500            let trimmed = tok.trim_end_matches(',');
501            CompilerVersion::parse(trimmed)
502        })
503        .next_back()
504}
505
506fn parse_msvc_version(lines: &[&str]) -> Option<CompilerVersion> {
507    let joined = lines.join(" ");
508    let lower = joined.to_ascii_lowercase();
509    let idx = lower.find("version ")?;
510    let after = &joined[idx + "version ".len()..];
511    let token = after.split_whitespace().next().unwrap_or("");
512    CompilerVersion::parse(token)
513}
514
515fn parse_target_line(lines: &[&str]) -> Option<String> {
516    for line in lines {
517        let trimmed = line.trim();
518        if let Some(rest) = trimmed.strip_prefix("Target:") {
519            let v = rest.trim();
520            if !v.is_empty() {
521                return Some(v.to_owned());
522            }
523        }
524    }
525    None
526}
527
528/// Pure parser for archiver `--version` output. The recognized
529/// families (`ar` and `llvm-ar`) print one line that includes the
530/// family name. Anything else is classified as
531/// [`ArchiverKind::Unknown`]; archivers that exit non-zero on
532/// `--version` are left to the subprocess layer to surface as
533/// `Unknown`.
534pub fn parse_ar_version_output(text: &str) -> ArchiverIdentity {
535    let lines: Vec<&str> = text
536        .lines()
537        .map(str::trim_end)
538        .filter(|l| !l.is_empty())
539        .collect();
540    let first_line = lines.first().copied().unwrap_or("").to_owned();
541    let lower = lines.join("\n").to_ascii_lowercase();
542
543    let kind = if lower.contains("llvm version") || lower.contains("llvm-ar") {
544        ArchiverKind::LlvmAr
545    } else if lower.contains("gnu ar") || lower.contains("gnu binutils") || lower.starts_with("ar ")
546    {
547        ArchiverKind::Ar
548    } else if lower.contains("microsoft (r) library manager") || lower.contains("lib.exe") {
549        ArchiverKind::Lib
550    } else {
551        ArchiverKind::Unknown
552    };
553
554    let version = match kind {
555        ArchiverKind::LlvmAr => parse_llvm_ar_version(&lines),
556        ArchiverKind::Ar => parse_gnu_ar_version(&lines),
557        ArchiverKind::Lib => parse_msvc_version(&lines),
558        ArchiverKind::Unknown => None,
559    };
560
561    ArchiverIdentity {
562        kind,
563        version,
564        raw_version_line: truncate(&first_line, 256),
565    }
566}
567
568fn parse_gnu_ar_version(lines: &[&str]) -> Option<CompilerVersion> {
569    // GNU ar prints e.g.
570    //   "GNU ar (GNU Binutils for Debian) 2.40"
571    let first = lines.first()?;
572    first
573        .split_whitespace()
574        .filter_map(|tok| CompilerVersion::parse(tok.trim_end_matches(',')))
575        .next_back()
576}
577
578fn parse_llvm_ar_version(lines: &[&str]) -> Option<CompilerVersion> {
579    // llvm-ar emits multi-line output; somewhere is e.g.
580    //   "LLVM version 17.0.6"
581    for line in lines {
582        let lower = line.to_ascii_lowercase();
583        if let Some(idx) = lower.find("llvm version ") {
584            let after = &line[idx + "llvm version ".len()..];
585            if let Some(token) = after.split_whitespace().next()
586                && let Some(v) = CompilerVersion::parse(token)
587            {
588                return Some(v);
589            }
590        }
591    }
592    None
593}
594
595fn truncate(s: &str, max: usize) -> String {
596    if s.len() <= max {
597        return s.to_owned();
598    }
599    let mut end = max;
600    while !s.is_char_boundary(end) && end > 0 {
601        end -= 1;
602    }
603    s[..end].to_owned()
604}
605
606/// Derive a [`CompilerCapabilities`] set from the detected
607/// identity. Decisions are made from the recognized compiler
608/// kind, with conservative defaults for [`CompilerKind::Unknown`].
609/// No probe commands are run from this function — the caller's
610/// detection layer already gathered everything we need.
611pub fn derive_cxx_capabilities(identity: &CompilerIdentity) -> CompilerCapabilities {
612    let gcc_style = if identity.kind.supports_gcc_style_command_line() {
613        Capability::supported_from(CapabilitySource::Version)
614    } else if identity.kind == CompilerKind::Msvc {
615        Capability::unsupported_from(CapabilitySource::Unsupported)
616    } else {
617        Capability::unsupported_from(CapabilitySource::AssumedDefault)
618    };
619    let msvc_style = if identity.kind == CompilerKind::Msvc {
620        Capability::supported_from(CapabilitySource::Version)
621    } else {
622        Capability::unsupported_from(CapabilitySource::AssumedDefault)
623    };
624    let depfile_mmd_mf = if identity.kind.supports_gcc_style_command_line() {
625        Capability::supported_from(CapabilitySource::Version)
626    } else {
627        Capability::unsupported_from(match identity.kind {
628            CompilerKind::Msvc => CapabilitySource::Unsupported,
629            _ => CapabilitySource::AssumedDefault,
630        })
631    };
632    let std_flag = if identity.kind.supports_gcc_style_command_line() {
633        Capability::supported_from(CapabilitySource::Version)
634    } else {
635        Capability::unsupported_from(match identity.kind {
636            CompilerKind::Msvc => CapabilitySource::Unsupported,
637            _ => CapabilitySource::AssumedDefault,
638        })
639    };
640    // Every Clang we recognize (the version output starts with
641    // `clang version` or `Apple clang version`) supports
642    // `-std=c++17`. Same for any GCC modern enough to print a
643    // major version: `g++ -std=c++17` was added in GCC 5.
644    let cxx_standard_17 = match identity.kind {
645        CompilerKind::Clang | CompilerKind::AppleClang => {
646            Capability::supported_from(CapabilitySource::Version)
647        }
648        CompilerKind::Gcc => match identity.version.as_ref().map(|v| v.major) {
649            Some(m) if m >= 5 => Capability::supported_from(CapabilitySource::Version),
650            Some(_) => Capability::unsupported_from(CapabilitySource::Version),
651            None => Capability::supported_from(CapabilitySource::AssumedDefault),
652        },
653        CompilerKind::Msvc => Capability::unsupported_from(CapabilitySource::Unsupported),
654        CompilerKind::Unknown => Capability::unsupported_from(CapabilitySource::AssumedDefault),
655    };
656    let color = if identity.kind.is_clang_like() || identity.kind == CompilerKind::Gcc {
657        Capability::supported_from(CapabilitySource::Version)
658    } else {
659        Capability::unsupported_from(CapabilitySource::AssumedDefault)
660    };
661    let response_files = if identity.kind.is_clang_like() || identity.kind == CompilerKind::Gcc {
662        Capability::supported_from(CapabilitySource::Version)
663    } else {
664        Capability::unsupported_from(CapabilitySource::AssumedDefault)
665    };
666    let json_diagnostics = if identity.kind.is_clang_like() {
667        Capability::supported_from(CapabilitySource::Version)
668    } else {
669        Capability::unsupported_from(CapabilitySource::AssumedDefault)
670    };
671    // Cabin does not emit SARIF; report the capability as
672    // unsupported regardless of detection so downstream tooling
673    // never relies on a version-only inference here.
674    let sarif_diagnostics = Capability::unsupported_from(CapabilitySource::AssumedDefault);
675
676    CompilerCapabilities {
677        gcc_style_flags: gcc_style,
678        msvc_style_flags: msvc_style,
679        depfile_mmd_mf,
680        std_flag,
681        cxx_standard_17,
682        color_diagnostics_flag: color,
683        response_files,
684        json_diagnostics,
685        sarif_diagnostics,
686    }
687}
688
689/// Derive an [`ArchiverCapabilities`] set from the detected
690/// identity.
691pub fn derive_ar_capabilities(identity: &ArchiverIdentity) -> ArchiverCapabilities {
692    let ar_crs = if identity.kind.supports_ar_crs() {
693        Capability::supported_from(CapabilitySource::Version)
694    } else if identity.kind == ArchiverKind::Lib {
695        Capability::unsupported_from(CapabilitySource::Unsupported)
696    } else {
697        Capability::unsupported_from(CapabilitySource::AssumedDefault)
698    };
699    let static_library_output = if identity.kind.supports_ar_crs() {
700        Capability::supported_from(CapabilitySource::Version)
701    } else if identity.kind == ArchiverKind::Lib {
702        // `lib.exe` produces `.lib`, not `.a`; the current backend
703        // emits the latter, so treat this as unsupported.
704        Capability::unsupported_from(CapabilitySource::Unsupported)
705    } else {
706        Capability::unsupported_from(CapabilitySource::AssumedDefault)
707    };
708    ArchiverCapabilities {
709        ar_crs,
710        static_library_output,
711    }
712}
713
714/// Errors produced while validating a detection report against
715/// the current C++ backend's required capability set.
716#[derive(Debug, Error, Clone, PartialEq, Eq)]
717pub enum ToolDetectionError {
718    #[error(
719        "selected C++ compiler `{spec}` is MSVC, but the current C++ backend requires a GCC- or Clang-like compiler"
720    )]
721    UnsupportedCxxBackend { spec: String },
722
723    #[error(
724        "selected C++ compiler `{spec}` could not be identified and the current backend requires GCC-style flags"
725    )]
726    UnknownCxxRequiresGccStyle { spec: String },
727
728    #[error(
729        "selected C++ compiler `{spec}` ({kind}) does not support the required C++17 standard flag"
730    )]
731    CxxLacksStdCxx17 { spec: String, kind: CompilerKind },
732
733    #[error(
734        "selected C++ compiler `{spec}` ({kind}) does not support the depfile flags required by the Ninja backend"
735    )]
736    CxxLacksDepfile { spec: String, kind: CompilerKind },
737
738    #[error(
739        "selected C compiler `{spec}` is MSVC, but the current C backend requires a GCC- or Clang-like compiler"
740    )]
741    UnsupportedCBackend { spec: String },
742
743    #[error(
744        "selected C compiler `{spec}` could not be identified and the current backend requires GCC-style flags"
745    )]
746    UnknownCRequiresGccStyle { spec: String },
747
748    #[error(
749        "selected C compiler `{spec}` ({kind}) does not support the depfile flags required by the Ninja backend"
750    )]
751    CLacksDepfile { spec: String, kind: CompilerKind },
752
753    #[error(
754        "selected archiver `{spec}` is not supported by the current static-library backend; use an ar-compatible archiver"
755    )]
756    UnsupportedArchiver { spec: String },
757
758    #[error(
759        "selected archiver `{spec}` could not be identified and the current backend requires `ar crs`-compatible behavior"
760    )]
761    UnknownArchiverRequiresArCompatible { spec: String },
762}
763
764/// The capability set the current C++ backend requires.
765///
766/// Cabin's planner currently emits `-std=c++17`, `-MMD -MF`, and
767/// GCC-style `-D` / `-I` / `-c` / `-o`. Any of those missing from
768/// the resolved compiler is a hard error.
769///
770/// # Errors
771/// Returns [`ToolDetectionError::UnsupportedCxxBackend`] when the compiler is
772/// MSVC or lacks GCC-style flags, [`ToolDetectionError::UnknownCxxRequiresGccStyle`]
773/// when an unidentified compiler lacks GCC-style flags,
774/// [`ToolDetectionError::CxxLacksDepfile`] when `-MMD -MF` is unsupported, and
775/// [`ToolDetectionError::CxxLacksStdCxx17`] when `-std=c++17` is unsupported.
776pub fn validate_cxx_for_backend(
777    spec_display: &str,
778    identity: &CompilerIdentity,
779    capabilities: &CompilerCapabilities,
780) -> Result<(), ToolDetectionError> {
781    if identity.kind == CompilerKind::Msvc {
782        return Err(ToolDetectionError::UnsupportedCxxBackend {
783            spec: spec_display.to_owned(),
784        });
785    }
786    if !capabilities.gcc_style_flags.supported {
787        if identity.kind == CompilerKind::Unknown {
788            return Err(ToolDetectionError::UnknownCxxRequiresGccStyle {
789                spec: spec_display.to_owned(),
790            });
791        }
792        return Err(ToolDetectionError::UnsupportedCxxBackend {
793            spec: spec_display.to_owned(),
794        });
795    }
796    if !capabilities.depfile_mmd_mf.supported {
797        return Err(ToolDetectionError::CxxLacksDepfile {
798            spec: spec_display.to_owned(),
799            kind: identity.kind,
800        });
801    }
802    if !capabilities.cxx_standard_17.supported {
803        return Err(ToolDetectionError::CxxLacksStdCxx17 {
804            spec: spec_display.to_owned(),
805            kind: identity.kind,
806        });
807    }
808    Ok(())
809}
810
811/// Validate that the resolved C compiler supports the C-side
812/// command shape the planner emits: GCC-style flags plus
813/// `-MMD -MF` depfile generation. Unlike
814/// [`validate_cxx_for_backend`], this validator does **not**
815/// require `-std=c++17` support — a pure-C driver that lacks
816/// C++ mode is acceptable when the target only carries C
817/// translation units.
818///
819/// # Errors
820/// Returns [`ToolDetectionError::UnsupportedCBackend`] when the compiler is
821/// MSVC or lacks GCC-style flags, [`ToolDetectionError::UnknownCRequiresGccStyle`]
822/// when an unidentified compiler lacks GCC-style flags, and
823/// [`ToolDetectionError::CLacksDepfile`] when `-MMD -MF` is unsupported.
824pub fn validate_cc_for_backend(
825    spec_display: &str,
826    identity: &CompilerIdentity,
827    capabilities: &CompilerCapabilities,
828) -> Result<(), ToolDetectionError> {
829    if identity.kind == CompilerKind::Msvc {
830        return Err(ToolDetectionError::UnsupportedCBackend {
831            spec: spec_display.to_owned(),
832        });
833    }
834    if !capabilities.gcc_style_flags.supported {
835        if identity.kind == CompilerKind::Unknown {
836            return Err(ToolDetectionError::UnknownCRequiresGccStyle {
837                spec: spec_display.to_owned(),
838            });
839        }
840        return Err(ToolDetectionError::UnsupportedCBackend {
841            spec: spec_display.to_owned(),
842        });
843    }
844    if !capabilities.depfile_mmd_mf.supported {
845        return Err(ToolDetectionError::CLacksDepfile {
846            spec: spec_display.to_owned(),
847            kind: identity.kind,
848        });
849    }
850    Ok(())
851}
852
853/// Validate that the resolved archiver can handle the planner's
854/// `ar crs <lib> <objs>` invocation.
855///
856/// # Errors
857/// Returns [`ToolDetectionError::UnsupportedArchiver`] when the archiver is
858/// `lib` (MSVC) or a known archiver lacking `ar crs` support, and
859/// [`ToolDetectionError::UnknownArchiverRequiresArCompatible`] when an
860/// unidentified archiver lacks `ar crs` support.
861pub fn validate_ar_for_backend(
862    spec_display: &str,
863    identity: &ArchiverIdentity,
864    capabilities: &ArchiverCapabilities,
865) -> Result<(), ToolDetectionError> {
866    if identity.kind == ArchiverKind::Lib {
867        return Err(ToolDetectionError::UnsupportedArchiver {
868            spec: spec_display.to_owned(),
869        });
870    }
871    if !capabilities.ar_crs.supported {
872        if identity.kind == ArchiverKind::Unknown {
873            return Err(ToolDetectionError::UnknownArchiverRequiresArCompatible {
874                spec: spec_display.to_owned(),
875            });
876        }
877        return Err(ToolDetectionError::UnsupportedArchiver {
878            spec: spec_display.to_owned(),
879        });
880    }
881    Ok(())
882}
883
884/// Render a [`CompilerCapabilities`] as a deterministic JSON map
885/// keyed by the public capability name, in alphabetical order.
886pub(crate) fn cxx_capabilities_as_json(caps: &CompilerCapabilities) -> serde_json::Value {
887    // Exhaustive destructure (no `..`) so adding a capability field
888    // is a compile error here until it is wired into the JSON, rather
889    // than being silently dropped from `cabin metadata`.
890    let CompilerCapabilities {
891        gcc_style_flags,
892        msvc_style_flags,
893        depfile_mmd_mf,
894        std_flag,
895        cxx_standard_17,
896        color_diagnostics_flag,
897        response_files,
898        json_diagnostics,
899        sarif_diagnostics,
900    } = caps;
901    let mut entries: [(&'static str, &Capability); 9] = [
902        ("gcc_style_flags", gcc_style_flags),
903        ("msvc_style_flags", msvc_style_flags),
904        ("depfile_mmd_mf", depfile_mmd_mf),
905        ("std_flag", std_flag),
906        ("cxx_standard_17", cxx_standard_17),
907        ("color_diagnostics_flag", color_diagnostics_flag),
908        ("response_files", response_files),
909        ("json_diagnostics", json_diagnostics),
910        ("sarif_diagnostics", sarif_diagnostics),
911    ];
912    capabilities_to_json(&mut entries)
913}
914
915pub(crate) fn ar_capabilities_as_json(caps: &ArchiverCapabilities) -> serde_json::Value {
916    let ArchiverCapabilities {
917        ar_crs,
918        static_library_output,
919    } = caps;
920    let mut entries: [(&'static str, &Capability); 2] = [
921        ("ar_crs", ar_crs),
922        ("static_library_output", static_library_output),
923    ];
924    capabilities_to_json(&mut entries)
925}
926
927/// Render `(key, capability)` pairs into an alphabetically-keyed JSON
928/// object — `{ "<key>": { "supported": <bool>, "source": <kebab> } }`.
929/// Sorting here keeps the output independent of the caller's field
930/// order, matching the historical BTreeSet-keyed rendering.
931fn capabilities_to_json(entries: &mut [(&'static str, &Capability)]) -> serde_json::Value {
932    entries.sort_by_key(|(key, _)| *key);
933    let mut obj = serde_json::Map::new();
934    for (key, cap) in entries {
935        obj.insert(
936            (*key).to_owned(),
937            serde_json::json!({
938                "supported": cap.supported,
939                "source": cap.source.as_key(),
940            }),
941        );
942    }
943    serde_json::Value::Object(obj)
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949
950    #[test]
951    fn parses_clang_first_line() {
952        let id = parse_cxx_version_output(
953            "clang version 17.0.6\nTarget: x86_64-unknown-linux-gnu\nThread model: posix\n",
954        );
955        assert_eq!(id.kind, CompilerKind::Clang);
956        let v = id.version.expect("version parsed");
957        assert_eq!(v.major, 17);
958        assert_eq!(v.minor, Some(0));
959        assert_eq!(v.patch, Some(6));
960        assert_eq!(id.target.as_deref(), Some("x86_64-unknown-linux-gnu"));
961    }
962
963    #[test]
964    fn parses_apple_clang() {
965        let id = parse_cxx_version_output(
966            "Apple clang version 14.0.3 (clang-1403.0.22.14.1)\nTarget: arm64-apple-darwin22.5.0\nThread model: posix\n",
967        );
968        assert_eq!(id.kind, CompilerKind::AppleClang);
969        let v = id.version.unwrap();
970        assert_eq!((v.major, v.minor, v.patch), (14, Some(0), Some(3)));
971    }
972
973    #[test]
974    fn parses_gcc_with_distro_prefix() {
975        let id = parse_cxx_version_output(
976            "g++ (Ubuntu 11.4.0-1ubuntu1) 11.4.0\nCopyright (C) 2021 Free Software Foundation, Inc.\n",
977        );
978        assert_eq!(id.kind, CompilerKind::Gcc);
979        let v = id.version.unwrap();
980        assert_eq!((v.major, v.minor, v.patch), (11, Some(4), Some(0)));
981    }
982
983    #[test]
984    fn parses_msvc_first_line() {
985        let id = parse_cxx_version_output(
986            "Microsoft (R) C/C++ Optimizing Compiler Version 19.39.33523 for x64\n",
987        );
988        assert_eq!(id.kind, CompilerKind::Msvc);
989        let v = id.version.unwrap();
990        assert_eq!(v.major, 19);
991    }
992
993    #[test]
994    fn unknown_when_unrecognized() {
995        let id = parse_cxx_version_output("My funky compiler 0.0\n");
996        assert_eq!(id.kind, CompilerKind::Unknown);
997        assert!(id.version.is_none());
998    }
999
1000    #[test]
1001    fn empty_output_is_unknown() {
1002        let id = parse_cxx_version_output("");
1003        assert_eq!(id.kind, CompilerKind::Unknown);
1004        assert!(id.raw_version_line.is_empty());
1005    }
1006
1007    #[test]
1008    fn parses_gnu_ar() {
1009        let id = parse_ar_version_output(
1010            "GNU ar (GNU Binutils for Debian) 2.40\nCopyright (C) 2023 Free Software Foundation, Inc.\n",
1011        );
1012        assert_eq!(id.kind, ArchiverKind::Ar);
1013        let v = id.version.unwrap();
1014        assert_eq!(v.major, 2);
1015    }
1016
1017    #[test]
1018    fn parses_llvm_ar_version() {
1019        let id = parse_ar_version_output(
1020            "LLVM (http://llvm.org/):\n  LLVM version 17.0.6\n  Optimized build.\n",
1021        );
1022        assert_eq!(id.kind, ArchiverKind::LlvmAr);
1023        let v = id.version.unwrap();
1024        assert_eq!(v.major, 17);
1025    }
1026
1027    #[test]
1028    fn detects_lib_exe_as_unsupported() {
1029        let id = parse_ar_version_output(
1030            "Microsoft (R) Library Manager Version 14.39.33523.0\nCopyright (C) Microsoft Corporation.\n",
1031        );
1032        assert_eq!(id.kind, ArchiverKind::Lib);
1033    }
1034
1035    #[test]
1036    fn unknown_archiver_classification() {
1037        let id = parse_ar_version_output("just-some-archiver 0.1\n");
1038        assert_eq!(id.kind, ArchiverKind::Unknown);
1039        assert!(id.version.is_none());
1040    }
1041
1042    #[test]
1043    fn clang_capabilities_include_gcc_style_and_cxx17() {
1044        let id = CompilerIdentity {
1045            kind: CompilerKind::Clang,
1046            version: CompilerVersion::parse("17.0.6"),
1047            target: None,
1048            raw_version_line: "clang version 17.0.6".into(),
1049        };
1050        let caps = derive_cxx_capabilities(&id);
1051        assert!(caps.gcc_style_flags.supported);
1052        assert!(caps.depfile_mmd_mf.supported);
1053        assert!(caps.std_flag.supported);
1054        assert!(caps.cxx_standard_17.supported);
1055    }
1056
1057    #[test]
1058    fn gcc_pre_5_does_not_claim_cxx17() {
1059        let id = CompilerIdentity {
1060            kind: CompilerKind::Gcc,
1061            version: CompilerVersion::parse("4.8.5"),
1062            target: None,
1063            raw_version_line: "g++ 4.8.5".into(),
1064        };
1065        let caps = derive_cxx_capabilities(&id);
1066        assert!(caps.gcc_style_flags.supported);
1067        assert!(!caps.cxx_standard_17.supported);
1068    }
1069
1070    #[test]
1071    fn msvc_capabilities_reject_gcc_style() {
1072        let id = CompilerIdentity {
1073            kind: CompilerKind::Msvc,
1074            version: CompilerVersion::parse("19.39.0"),
1075            target: None,
1076            raw_version_line: "Microsoft Optimizing Compiler".into(),
1077        };
1078        let caps = derive_cxx_capabilities(&id);
1079        assert!(!caps.gcc_style_flags.supported);
1080        assert_eq!(caps.gcc_style_flags.source, CapabilitySource::Unsupported);
1081        assert!(caps.msvc_style_flags.supported);
1082    }
1083
1084    #[test]
1085    fn unknown_compiler_capabilities_are_conservative() {
1086        let id = CompilerIdentity::unknown("strange compiler");
1087        let caps = derive_cxx_capabilities(&id);
1088        assert!(!caps.gcc_style_flags.supported);
1089        assert_eq!(
1090            caps.gcc_style_flags.source,
1091            CapabilitySource::AssumedDefault
1092        );
1093        assert!(!caps.depfile_mmd_mf.supported);
1094    }
1095
1096    #[test]
1097    fn ar_capabilities_recognize_gnu_ar() {
1098        let id = ArchiverIdentity {
1099            kind: ArchiverKind::Ar,
1100            version: CompilerVersion::parse("2.40"),
1101            raw_version_line: "GNU ar".into(),
1102        };
1103        let caps = derive_ar_capabilities(&id);
1104        assert!(caps.ar_crs.supported);
1105        assert!(caps.static_library_output.supported);
1106    }
1107
1108    #[test]
1109    fn ar_capabilities_reject_msvc_lib() {
1110        let id = ArchiverIdentity {
1111            kind: ArchiverKind::Lib,
1112            version: None,
1113            raw_version_line: "Microsoft Library Manager".into(),
1114        };
1115        let caps = derive_ar_capabilities(&id);
1116        assert!(!caps.ar_crs.supported);
1117        assert_eq!(caps.ar_crs.source, CapabilitySource::Unsupported);
1118    }
1119
1120    #[test]
1121    fn validate_rejects_msvc_cxx() {
1122        let id = CompilerIdentity {
1123            kind: CompilerKind::Msvc,
1124            version: None,
1125            target: None,
1126            raw_version_line: "MSVC".into(),
1127        };
1128        let caps = derive_cxx_capabilities(&id);
1129        let err = validate_cxx_for_backend("cl.exe", &id, &caps).unwrap_err();
1130        assert!(matches!(
1131            err,
1132            ToolDetectionError::UnsupportedCxxBackend { .. }
1133        ));
1134    }
1135
1136    #[test]
1137    fn validate_rejects_unknown_cxx() {
1138        let id = CompilerIdentity::unknown("???");
1139        let caps = derive_cxx_capabilities(&id);
1140        let err = validate_cxx_for_backend("custom-cxx", &id, &caps).unwrap_err();
1141        assert!(matches!(
1142            err,
1143            ToolDetectionError::UnknownCxxRequiresGccStyle { .. }
1144        ));
1145    }
1146
1147    #[test]
1148    fn validate_accepts_clang() {
1149        let id = CompilerIdentity {
1150            kind: CompilerKind::Clang,
1151            version: CompilerVersion::parse("17.0.6"),
1152            target: None,
1153            raw_version_line: "clang version 17.0.6".into(),
1154        };
1155        let caps = derive_cxx_capabilities(&id);
1156        assert!(validate_cxx_for_backend("clang++", &id, &caps).is_ok());
1157    }
1158
1159    #[test]
1160    fn validate_rejects_gcc_too_old_for_cxx17() {
1161        let id = CompilerIdentity {
1162            kind: CompilerKind::Gcc,
1163            version: CompilerVersion::parse("4.8.5"),
1164            target: None,
1165            raw_version_line: "g++ 4.8".into(),
1166        };
1167        let caps = derive_cxx_capabilities(&id);
1168        let err = validate_cxx_for_backend("g++", &id, &caps).unwrap_err();
1169        assert!(matches!(err, ToolDetectionError::CxxLacksStdCxx17 { .. }));
1170    }
1171
1172    #[test]
1173    fn validate_cc_accepts_pure_c_clang_without_cxx17_capability() {
1174        // The C-side validator must accept a compiler that
1175        // would *not* satisfy the C++ contract (no
1176        // `cxx_standard_17`). A bare `cc` driver on a system
1177        // that ships only C headers is a legitimate case; only
1178        // GCC-style flags + depfile are required for the C
1179        // backend.
1180        let id = CompilerIdentity {
1181            kind: CompilerKind::Clang,
1182            version: CompilerVersion::parse("17.0.6"),
1183            target: None,
1184            raw_version_line: "clang version 17.0.6".into(),
1185        };
1186        let mut caps = derive_cxx_capabilities(&id);
1187        // Force `cxx_standard_17` off so we can be certain the
1188        // C validator does not gate on it.
1189        caps.cxx_standard_17 = Capability {
1190            supported: false,
1191            source: CapabilitySource::Unsupported,
1192        };
1193        assert!(validate_cc_for_backend("cc", &id, &caps).is_ok());
1194        // Sanity: the equivalent CXX validation would now reject
1195        // the same compiler. Asserting both directions
1196        // documents the design constraint that C/C++
1197        // capability gating differ.
1198        assert!(matches!(
1199            validate_cxx_for_backend("cc", &id, &caps).unwrap_err(),
1200            ToolDetectionError::CxxLacksStdCxx17 { .. }
1201        ));
1202    }
1203
1204    #[test]
1205    fn validate_cc_rejects_msvc() {
1206        let id = CompilerIdentity {
1207            kind: CompilerKind::Msvc,
1208            version: None,
1209            target: None,
1210            raw_version_line: "MSVC".into(),
1211        };
1212        let caps = derive_cxx_capabilities(&id);
1213        let err = validate_cc_for_backend("cl.exe", &id, &caps).unwrap_err();
1214        assert!(matches!(
1215            err,
1216            ToolDetectionError::UnsupportedCBackend { .. }
1217        ));
1218    }
1219
1220    #[test]
1221    fn validate_cc_rejects_unknown_compiler_without_gcc_style() {
1222        // Unknown identity + missing `gcc_style_flags` capability
1223        // is the unrecoverable case: the planner cannot tell
1224        // whether the compiler accepts `-c -o` etc.
1225        let id = CompilerIdentity::unknown("???");
1226        let caps = derive_cxx_capabilities(&id);
1227        let err = validate_cc_for_backend("custom-cc", &id, &caps).unwrap_err();
1228        assert!(matches!(
1229            err,
1230            ToolDetectionError::UnknownCRequiresGccStyle { .. }
1231        ));
1232    }
1233
1234    #[test]
1235    fn validate_cc_rejects_gcc_without_depfile_support() {
1236        // GCC identity but without `-MMD -MF` support — Cabin
1237        // emits a depfile flag for every compile so the C
1238        // contract requires it, even though `cxx_standard_17`
1239        // is not relevant.
1240        let id = CompilerIdentity {
1241            kind: CompilerKind::Gcc,
1242            version: CompilerVersion::parse("9.4.0"),
1243            target: None,
1244            raw_version_line: "gcc 9.4".into(),
1245        };
1246        let mut caps = derive_cxx_capabilities(&id);
1247        caps.depfile_mmd_mf = Capability {
1248            supported: false,
1249            source: CapabilitySource::Unsupported,
1250        };
1251        let err = validate_cc_for_backend("cc", &id, &caps).unwrap_err();
1252        assert!(matches!(err, ToolDetectionError::CLacksDepfile { .. }));
1253    }
1254
1255    #[test]
1256    fn validate_rejects_msvc_archiver() {
1257        let id = ArchiverIdentity {
1258            kind: ArchiverKind::Lib,
1259            version: None,
1260            raw_version_line: "Microsoft Library Manager".into(),
1261        };
1262        let caps = derive_ar_capabilities(&id);
1263        let err = validate_ar_for_backend("lib.exe", &id, &caps).unwrap_err();
1264        assert!(matches!(
1265            err,
1266            ToolDetectionError::UnsupportedArchiver { .. }
1267        ));
1268    }
1269
1270    #[test]
1271    fn version_display_truncates_unset_components() {
1272        let v = CompilerVersion::parse("11").unwrap();
1273        assert_eq!(v.to_display_string(), "11");
1274        let v = CompilerVersion::parse("11.4").unwrap();
1275        assert_eq!(v.to_display_string(), "11.4");
1276        let v = CompilerVersion::parse("11.4.0").unwrap();
1277        assert_eq!(v.to_display_string(), "11.4.0");
1278    }
1279
1280    // --------------------------------------------------------------
1281    // Golden / fixture tests.
1282    //
1283    // These pin the JSON shape that downstream tooling
1284    // (`cabin metadata`, IDE integrations) reads out of a
1285    // `ToolchainDetectionReport`. Any accidental change to the
1286    // field names or serialization order here is user-visible
1287    // and should be deliberate.
1288    // --------------------------------------------------------------
1289
1290    fn pretty(value: &serde_json::Value) -> String {
1291        serde_json::to_string_pretty(value).unwrap()
1292    }
1293
1294    fn cxx_identity_and_capabilities_json(version_output: &str) -> String {
1295        let id = parse_cxx_version_output(version_output);
1296        let caps = derive_cxx_capabilities(&id);
1297        pretty(&serde_json::json!({
1298            "identity": id.as_json(),
1299            "capabilities": cxx_capabilities_as_json(&caps),
1300        }))
1301    }
1302
1303    fn ar_identity_and_capabilities_json(version_output: &str) -> String {
1304        let id = parse_ar_version_output(version_output);
1305        let caps = derive_ar_capabilities(&id);
1306        pretty(&serde_json::json!({
1307            "identity": id.as_json(),
1308            "capabilities": ar_capabilities_as_json(&caps),
1309        }))
1310    }
1311
1312    #[test]
1313    fn snapshot_clang_identity_and_capabilities() {
1314        let actual = cxx_identity_and_capabilities_json(
1315            "clang version 17.0.6\nTarget: x86_64-unknown-linux-gnu\nThread model: posix\n",
1316        );
1317        let expected = r#"{
1318  "identity": {
1319    "kind": "clang",
1320    "version": "17.0.6",
1321    "target": "x86_64-unknown-linux-gnu",
1322    "raw_version_line": "clang version 17.0.6"
1323  },
1324  "capabilities": {
1325    "color_diagnostics_flag": {
1326      "supported": true,
1327      "source": "version"
1328    },
1329    "cxx_standard_17": {
1330      "supported": true,
1331      "source": "version"
1332    },
1333    "depfile_mmd_mf": {
1334      "supported": true,
1335      "source": "version"
1336    },
1337    "gcc_style_flags": {
1338      "supported": true,
1339      "source": "version"
1340    },
1341    "json_diagnostics": {
1342      "supported": true,
1343      "source": "version"
1344    },
1345    "msvc_style_flags": {
1346      "supported": false,
1347      "source": "assumed-default"
1348    },
1349    "response_files": {
1350      "supported": true,
1351      "source": "version"
1352    },
1353    "sarif_diagnostics": {
1354      "supported": false,
1355      "source": "assumed-default"
1356    },
1357    "std_flag": {
1358      "supported": true,
1359      "source": "version"
1360    }
1361  }
1362}"#;
1363        assert_eq!(actual, expected);
1364    }
1365
1366    #[test]
1367    fn snapshot_apple_clang_identity_and_capabilities() {
1368        let actual = cxx_identity_and_capabilities_json(
1369            "Apple clang version 14.0.3 (clang-1403.0.22.14.1)\nTarget: arm64-apple-darwin22.5.0\nThread model: posix\n",
1370        );
1371        let expected = r#"{
1372  "identity": {
1373    "kind": "apple-clang",
1374    "version": "14.0.3",
1375    "target": "arm64-apple-darwin22.5.0",
1376    "raw_version_line": "Apple clang version 14.0.3 (clang-1403.0.22.14.1)"
1377  },
1378  "capabilities": {
1379    "color_diagnostics_flag": {
1380      "supported": true,
1381      "source": "version"
1382    },
1383    "cxx_standard_17": {
1384      "supported": true,
1385      "source": "version"
1386    },
1387    "depfile_mmd_mf": {
1388      "supported": true,
1389      "source": "version"
1390    },
1391    "gcc_style_flags": {
1392      "supported": true,
1393      "source": "version"
1394    },
1395    "json_diagnostics": {
1396      "supported": true,
1397      "source": "version"
1398    },
1399    "msvc_style_flags": {
1400      "supported": false,
1401      "source": "assumed-default"
1402    },
1403    "response_files": {
1404      "supported": true,
1405      "source": "version"
1406    },
1407    "sarif_diagnostics": {
1408      "supported": false,
1409      "source": "assumed-default"
1410    },
1411    "std_flag": {
1412      "supported": true,
1413      "source": "version"
1414    }
1415  }
1416}"#;
1417        assert_eq!(actual, expected);
1418    }
1419
1420    #[test]
1421    fn snapshot_gcc_identity_and_capabilities() {
1422        let actual = cxx_identity_and_capabilities_json(
1423            "g++ (Ubuntu 11.4.0-1ubuntu1) 11.4.0\nCopyright (C) 2021 Free Software Foundation, Inc.\n",
1424        );
1425        let expected = r#"{
1426  "identity": {
1427    "kind": "gcc",
1428    "version": "11.4.0",
1429    "raw_version_line": "g++ (Ubuntu 11.4.0-1ubuntu1) 11.4.0"
1430  },
1431  "capabilities": {
1432    "color_diagnostics_flag": {
1433      "supported": true,
1434      "source": "version"
1435    },
1436    "cxx_standard_17": {
1437      "supported": true,
1438      "source": "version"
1439    },
1440    "depfile_mmd_mf": {
1441      "supported": true,
1442      "source": "version"
1443    },
1444    "gcc_style_flags": {
1445      "supported": true,
1446      "source": "version"
1447    },
1448    "json_diagnostics": {
1449      "supported": false,
1450      "source": "assumed-default"
1451    },
1452    "msvc_style_flags": {
1453      "supported": false,
1454      "source": "assumed-default"
1455    },
1456    "response_files": {
1457      "supported": true,
1458      "source": "version"
1459    },
1460    "sarif_diagnostics": {
1461      "supported": false,
1462      "source": "assumed-default"
1463    },
1464    "std_flag": {
1465      "supported": true,
1466      "source": "version"
1467    }
1468  }
1469}"#;
1470        assert_eq!(actual, expected);
1471    }
1472
1473    #[test]
1474    fn snapshot_msvc_identity_and_capabilities() {
1475        let actual = cxx_identity_and_capabilities_json(
1476            "Microsoft (R) C/C++ Optimizing Compiler Version 19.39.33523 for x64\n",
1477        );
1478        // The fixture pins the *unsupported* shape so a future
1479        // change cannot silently flip MSVC to "supported by the
1480        // current backend".
1481        let expected = r#"{
1482  "identity": {
1483    "kind": "msvc",
1484    "version": "19.39.33523",
1485    "raw_version_line": "Microsoft (R) C/C++ Optimizing Compiler Version 19.39.33523 for x64"
1486  },
1487  "capabilities": {
1488    "color_diagnostics_flag": {
1489      "supported": false,
1490      "source": "assumed-default"
1491    },
1492    "cxx_standard_17": {
1493      "supported": false,
1494      "source": "unsupported"
1495    },
1496    "depfile_mmd_mf": {
1497      "supported": false,
1498      "source": "unsupported"
1499    },
1500    "gcc_style_flags": {
1501      "supported": false,
1502      "source": "unsupported"
1503    },
1504    "json_diagnostics": {
1505      "supported": false,
1506      "source": "assumed-default"
1507    },
1508    "msvc_style_flags": {
1509      "supported": true,
1510      "source": "version"
1511    },
1512    "response_files": {
1513      "supported": false,
1514      "source": "assumed-default"
1515    },
1516    "sarif_diagnostics": {
1517      "supported": false,
1518      "source": "assumed-default"
1519    },
1520    "std_flag": {
1521      "supported": false,
1522      "source": "unsupported"
1523    }
1524  }
1525}"#;
1526        assert_eq!(actual, expected);
1527    }
1528
1529    #[test]
1530    fn snapshot_unknown_compiler_capabilities_are_conservative() {
1531        let actual = cxx_identity_and_capabilities_json("My funky compiler 0.0\n");
1532        let expected = r#"{
1533  "identity": {
1534    "kind": "unknown",
1535    "raw_version_line": "My funky compiler 0.0"
1536  },
1537  "capabilities": {
1538    "color_diagnostics_flag": {
1539      "supported": false,
1540      "source": "assumed-default"
1541    },
1542    "cxx_standard_17": {
1543      "supported": false,
1544      "source": "assumed-default"
1545    },
1546    "depfile_mmd_mf": {
1547      "supported": false,
1548      "source": "assumed-default"
1549    },
1550    "gcc_style_flags": {
1551      "supported": false,
1552      "source": "assumed-default"
1553    },
1554    "json_diagnostics": {
1555      "supported": false,
1556      "source": "assumed-default"
1557    },
1558    "msvc_style_flags": {
1559      "supported": false,
1560      "source": "assumed-default"
1561    },
1562    "response_files": {
1563      "supported": false,
1564      "source": "assumed-default"
1565    },
1566    "sarif_diagnostics": {
1567      "supported": false,
1568      "source": "assumed-default"
1569    },
1570    "std_flag": {
1571      "supported": false,
1572      "source": "assumed-default"
1573    }
1574  }
1575}"#;
1576        assert_eq!(actual, expected);
1577    }
1578
1579    #[test]
1580    fn snapshot_gnu_ar_identity_and_capabilities() {
1581        let actual = ar_identity_and_capabilities_json(
1582            "GNU ar (GNU Binutils for Debian) 2.40\nCopyright (C) 2023 Free Software Foundation, Inc.\n",
1583        );
1584        let expected = r#"{
1585  "identity": {
1586    "kind": "ar",
1587    "version": "2.40",
1588    "raw_version_line": "GNU ar (GNU Binutils for Debian) 2.40"
1589  },
1590  "capabilities": {
1591    "ar_crs": {
1592      "supported": true,
1593      "source": "version"
1594    },
1595    "static_library_output": {
1596      "supported": true,
1597      "source": "version"
1598    }
1599  }
1600}"#;
1601        assert_eq!(actual, expected);
1602    }
1603
1604    #[test]
1605    fn snapshot_msvc_lib_archiver_is_marked_unsupported() {
1606        let actual = ar_identity_and_capabilities_json(
1607            "Microsoft (R) Library Manager Version 14.39.33523.0\nCopyright (C) Microsoft Corporation.\n",
1608        );
1609        let expected = r#"{
1610  "identity": {
1611    "kind": "lib",
1612    "version": "14.39.33523",
1613    "raw_version_line": "Microsoft (R) Library Manager Version 14.39.33523.0"
1614  },
1615  "capabilities": {
1616    "ar_crs": {
1617      "supported": false,
1618      "source": "unsupported"
1619    },
1620    "static_library_output": {
1621      "supported": false,
1622      "source": "unsupported"
1623    }
1624  }
1625}"#;
1626        assert_eq!(actual, expected);
1627    }
1628
1629    #[test]
1630    fn snapshot_full_detection_report_for_clang_plus_gnu_ar() {
1631        // End-to-end snapshot of `ToolchainDetectionReport::as_json`
1632        // for a typical Linux clang + GNU ar setup. Pins the
1633        // top-level shape `{ cxx, [cc,] ar }` plus all nested
1634        // fields in their insertion order.
1635        let cxx_id =
1636            parse_cxx_version_output("clang version 17.0.6\nTarget: x86_64-unknown-linux-gnu\n");
1637        let cxx_caps = derive_cxx_capabilities(&cxx_id);
1638        let ar_id = parse_ar_version_output("GNU ar (GNU Binutils) 2.40\n");
1639        let ar_caps = derive_ar_capabilities(&ar_id);
1640        let report = ToolchainDetectionReport {
1641            cxx: ToolDetection {
1642                path: std::path::PathBuf::from("/opt/llvm/bin/clang++"),
1643                identity: cxx_id,
1644                capabilities: cxx_caps,
1645            },
1646            cc: None,
1647            ar: ToolDetection {
1648                path: std::path::PathBuf::from("/usr/bin/ar"),
1649                identity: ar_id,
1650                capabilities: ar_caps,
1651            },
1652        };
1653        let actual = pretty(&report.as_json());
1654        let expected = r#"{
1655  "cxx": {
1656    "path": "/opt/llvm/bin/clang++",
1657    "identity": {
1658      "kind": "clang",
1659      "version": "17.0.6",
1660      "target": "x86_64-unknown-linux-gnu",
1661      "raw_version_line": "clang version 17.0.6"
1662    },
1663    "capabilities": {
1664      "color_diagnostics_flag": {
1665        "supported": true,
1666        "source": "version"
1667      },
1668      "cxx_standard_17": {
1669        "supported": true,
1670        "source": "version"
1671      },
1672      "depfile_mmd_mf": {
1673        "supported": true,
1674        "source": "version"
1675      },
1676      "gcc_style_flags": {
1677        "supported": true,
1678        "source": "version"
1679      },
1680      "json_diagnostics": {
1681        "supported": true,
1682        "source": "version"
1683      },
1684      "msvc_style_flags": {
1685        "supported": false,
1686        "source": "assumed-default"
1687      },
1688      "response_files": {
1689        "supported": true,
1690        "source": "version"
1691      },
1692      "sarif_diagnostics": {
1693        "supported": false,
1694        "source": "assumed-default"
1695      },
1696      "std_flag": {
1697        "supported": true,
1698        "source": "version"
1699      }
1700    }
1701  },
1702  "ar": {
1703    "path": "/usr/bin/ar",
1704    "identity": {
1705      "kind": "ar",
1706      "version": "2.40",
1707      "raw_version_line": "GNU ar (GNU Binutils) 2.40"
1708    },
1709    "capabilities": {
1710      "ar_crs": {
1711        "supported": true,
1712        "source": "version"
1713      },
1714      "static_library_output": {
1715        "supported": true,
1716        "source": "version"
1717      }
1718    }
1719  }
1720}"#;
1721        assert_eq!(actual, expected);
1722    }
1723}