Skip to main content

cabin_core/
compiler_wrapper.rs

1//! Typed compiler-wrapper model.
2//!
3//! Cabin can prefix C and C++ compile drivers with an executable such
4//! as `ccache` or `sccache`. The wrapper is separate from the compiler:
5//! it applies only to compile commands (never link or archive) and is
6//! selected through the same precedence ladder as the rest of the
7//! toolchain.
8//!
9//! This module owns *data only*: the typed enums, the manifest
10//! declaration types, the resolved value, and the JSON helpers that
11//! `cabin metadata` consumes.  PATH lookup, env reading, and
12//! subprocess version probing live in `cabin-toolchain`.  CLI flag
13//! handling lives in `cabin`.  Manifest parsing lives in
14//! `cabin-manifest`.
15
16use std::fmt;
17
18use camino::Utf8PathBuf;
19
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23use crate::compiler::CompilerVersion;
24use crate::toolchain::ToolSpec;
25
26/// Executable-family label reported in metadata and fingerprints.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct CompilerWrapperKind(String);
30
31impl CompilerWrapperKind {
32    /// Derive the wrapper family from an executable name or path.
33    pub fn from_spec(spec: &ToolSpec) -> Self {
34        let display = spec.display();
35        let basename = camino::Utf8Path::new(&display)
36            .file_name()
37            .unwrap_or(&display);
38        let kind = basename.strip_suffix(".exe").unwrap_or(basename);
39        Self(kind.to_owned())
40    }
41
42    /// Stable identifier used in metadata, fingerprints, and errors.
43    pub fn as_key(&self) -> &str {
44        &self.0
45    }
46}
47
48impl fmt::Display for CompilerWrapperKind {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(self.as_key())
51    }
52}
53
54/// What the user (or a manifest layer) asked for, structurally.
55///
56/// `Disabled` is *explicit* opt-out: a higher-precedence layer can
57/// no longer turn a wrapper back on.  `Use(_)` selects a specific
58/// wrapper kind.  Layers that did not express any preference are
59/// represented as `Option::None` at the call site, not as a variant
60/// here.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case", tag = "kind")]
63pub enum CompilerWrapperRequest {
64    /// "No wrapper at all".  Equivalent to the manifest value
65    /// `compiler-wrapper = "none"` and the CLI flag
66    /// `--no-compiler-wrapper` / `--compiler-wrapper none`.
67    Disabled,
68    /// Use an executable name searched on `PATH`, or an explicit path.
69    Use { wrapper: ToolSpec },
70}
71
72impl CompilerWrapperRequest {
73    /// Parse a manifest / CLI / env value.  Accepts:
74    ///
75    /// - `"none"` (case-insensitive) → [`Self::Disabled`].
76    /// - Any other non-empty value selects that executable name or path.
77    ///
78    /// # Errors
79    /// Returns [`CompilerWrapperParseError::Empty`] when `raw` is empty after
80    /// trimming.
81    pub fn parse(raw: &str) -> Result<Self, CompilerWrapperParseError> {
82        let trimmed = raw.trim();
83        if trimmed.is_empty() {
84            return Err(CompilerWrapperParseError::Empty);
85        }
86        match trimmed.to_ascii_lowercase().as_str() {
87            "none" | "off" | "disabled" => Ok(Self::Disabled),
88            _ => Ok(Self::Use {
89                wrapper: ToolSpec::parse(trimmed.to_owned()),
90            }),
91        }
92    }
93
94    /// Stable display string.  Round-trips with [`Self::parse`].
95    pub fn as_key(&self) -> String {
96        match self {
97            CompilerWrapperRequest::Disabled => "none".to_owned(),
98            CompilerWrapperRequest::Use { wrapper } => wrapper.display(),
99        }
100    }
101}
102
103/// Errors produced by [`CompilerWrapperRequest::parse`].
104#[derive(Debug, Clone, PartialEq, Eq, Error)]
105pub enum CompilerWrapperParseError {
106    #[error("compiler-wrapper value must not be empty")]
107    Empty,
108}
109
110/// Where a resolved wrapper selection ultimately came from.
111/// Recorded alongside the resolved wrapper so `cabin metadata` can
112/// show the precedence without re-deriving it.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum CompilerWrapperSource {
116    /// Set by the `--compiler-wrapper` / `--no-compiler-wrapper`
117    /// CLI flag.
118    Cli,
119    /// Set by the `CABIN_COMPILER_WRAPPER` environment variable.
120    Env,
121    /// Set by `[build]` in the user-level config file.
122    UserConfig,
123    /// Set by `[build]` in the workspace-level config file.
124    WorkspaceConfig,
125    /// Set by `[build]` in the package-local config file
126    /// (non-workspace single-package projects).
127    PackageConfig,
128    /// Set by `[build]` in a config file pointed at by the
129    /// `CABIN_CONFIG` environment variable.
130    ExplicitConfig,
131    /// Set by the workspace-root `[build]` table.
132    Manifest,
133}
134
135impl CompilerWrapperSource {
136    /// Stable lower-case label used in JSON output and error
137    /// messages.
138    pub const fn as_key(self) -> &'static str {
139        match self {
140            CompilerWrapperSource::Cli => "cli",
141            CompilerWrapperSource::Env => "env",
142            CompilerWrapperSource::UserConfig => "user-config",
143            CompilerWrapperSource::WorkspaceConfig => "workspace-config",
144            CompilerWrapperSource::PackageConfig => "package-config",
145            CompilerWrapperSource::ExplicitConfig => "explicit-config",
146            CompilerWrapperSource::Manifest => "manifest",
147        }
148    }
149}
150
151impl fmt::Display for CompilerWrapperSource {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.write_str(self.as_key())
154    }
155}
156
157/// Identity captured from a wrapper executable's `--version`
158/// output.  Populated by `cabin-toolchain::detect_compiler_wrapper`
159/// and surfaced through metadata.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct CompilerWrapperIdentity {
162    pub kind: CompilerWrapperKind,
163    /// Parsed numeric version (`Some` when the wrapper printed a
164    /// recognizable version string).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub version: Option<CompilerVersion>,
167    /// First non-empty line of the captured `--version` output,
168    /// preserved verbatim so users can see exactly what the
169    /// wrapper reported.
170    pub raw_version_line: String,
171}
172
173impl CompilerWrapperIdentity {
174    /// Convenience constructor for an identity whose version could
175    /// not be parsed.
176    pub fn unknown_version(kind: CompilerWrapperKind, raw_version_line: impl Into<String>) -> Self {
177        Self {
178            kind,
179            version: None,
180            raw_version_line: raw_version_line.into(),
181        }
182    }
183}
184
185/// Fully resolved compiler wrapper, ready to prefix C and C++ compile
186/// commands.
187///
188/// `path` is the absolute filesystem path the resolver settled on.
189/// `spec` records the original spelling so metadata can show the
190/// requested executable without leaking the resolved machine-specific
191/// path.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct ResolvedCompilerWrapper {
194    pub kind: CompilerWrapperKind,
195    pub path: Utf8PathBuf,
196    /// User-visible executable name or path from the selected layer.
197    pub spec: String,
198    pub source: CompilerWrapperSource,
199    /// Detected identity (`Some` when version probing succeeded).
200    /// Always emitted by `cabin metadata` even when `None`, so
201    /// callers do not have to special-case the absence.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub identity: Option<CompilerWrapperIdentity>,
204}
205
206impl ResolvedCompilerWrapper {
207    /// Compact JSON view used by `cabin metadata`.  Mirrors the
208    /// shape of [`crate::ResolvedTool::as_json`] so consumers see a
209    /// consistent pattern.
210    pub fn as_json(&self) -> serde_json::Value {
211        let version = self
212            .identity
213            .as_ref()
214            .and_then(|id| id.version.as_ref())
215            .map_or(serde_json::Value::Null, |v| {
216                serde_json::Value::String(v.to_display_string())
217            });
218        let raw = self
219            .identity
220            .as_ref()
221            .map_or(serde_json::Value::Null, |id| {
222                serde_json::Value::String(id.raw_version_line.clone())
223            });
224        serde_json::json!({
225            "kind": self.kind.as_key(),
226            "spec": self.spec,
227            "source": self.source.as_key(),
228            "version": version,
229            "raw_version_line": raw,
230        })
231    }
232}
233
234/// Lightweight, non-machine-specific summary of a resolved wrapper.
235/// Carried inside [`crate::ToolchainSummary`] so the build
236/// configuration fingerprint reflects "which wrapper did this build
237/// use" without pinning the local absolute path.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct CompilerWrapperSummary {
240    /// Executable-family kind derived from the selected spec.
241    pub kind: CompilerWrapperKind,
242    /// User-visible spec spelling.
243    pub spec: String,
244    /// Where the selection came from; serializes to the same
245    /// kebab-case label as [`CompilerWrapperSource::as_key`].
246    pub source: CompilerWrapperSource,
247    /// Detected version, when probing succeeded.  Stored as a
248    /// display string so the summary stays portable across
249    /// `CompilerVersion` schema changes.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub version: Option<String>,
252}
253
254impl CompilerWrapperSummary {
255    /// Build a summary from a resolved wrapper.
256    pub fn from_resolved(resolved: &ResolvedCompilerWrapper) -> Self {
257        Self {
258            kind: resolved.kind.clone(),
259            spec: resolved.spec.clone(),
260            source: resolved.source,
261            version: resolved
262                .identity
263                .as_ref()
264                .and_then(|id| id.version.as_ref())
265                .map(super::compiler::CompilerVersion::to_display_string),
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn wrapper_kind(name: &str) -> CompilerWrapperKind {
275        CompilerWrapperKind::from_spec(&ToolSpec::parse(name))
276    }
277
278    #[test]
279    fn parse_accepts_documented_values() {
280        assert_eq!(
281            CompilerWrapperRequest::parse("none").unwrap(),
282            CompilerWrapperRequest::Disabled
283        );
284        assert_eq!(
285            CompilerWrapperRequest::parse("None").unwrap(),
286            CompilerWrapperRequest::Disabled
287        );
288        assert_eq!(
289            CompilerWrapperRequest::parse("ccache").unwrap(),
290            CompilerWrapperRequest::Use {
291                wrapper: ToolSpec::Name("ccache".into()),
292            }
293        );
294        assert_eq!(
295            CompilerWrapperRequest::parse("sccache").unwrap(),
296            CompilerWrapperRequest::Use {
297                wrapper: ToolSpec::Name("sccache".into()),
298            }
299        );
300    }
301
302    #[test]
303    fn parse_accepts_any_executable_name() {
304        let request = CompilerWrapperRequest::parse("icecc");
305        assert!(
306            request.is_ok(),
307            "expected arbitrary executable name: {request:?}"
308        );
309    }
310
311    #[test]
312    fn parse_does_not_shell_split_executable_value() {
313        assert_eq!(
314            CompilerWrapperRequest::parse("wrapper with spaces").unwrap(),
315            CompilerWrapperRequest::Use {
316                wrapper: ToolSpec::Name("wrapper with spaces".into()),
317            }
318        );
319    }
320
321    #[test]
322    fn parse_accepts_executable_paths() {
323        let request = CompilerWrapperRequest::parse("/usr/local/bin/icecc");
324        assert!(request.is_ok(), "expected executable path: {request:?}");
325    }
326
327    #[test]
328    fn parse_rejects_empty() {
329        assert_eq!(
330            CompilerWrapperRequest::parse("").unwrap_err(),
331            CompilerWrapperParseError::Empty
332        );
333        assert_eq!(
334            CompilerWrapperRequest::parse("   ").unwrap_err(),
335            CompilerWrapperParseError::Empty
336        );
337    }
338
339    #[test]
340    fn as_key_round_trips_through_parse() {
341        for value in ["none", "ccache", "sccache"] {
342            let parsed = CompilerWrapperRequest::parse(value).unwrap();
343            assert_eq!(parsed.as_key(), value);
344        }
345    }
346
347    #[test]
348    fn kind_is_derived_from_executable_basename() {
349        assert_eq!(wrapper_kind("/opt/bin/icecc").as_key(), "icecc");
350        assert_eq!(wrapper_kind("sccache.exe").as_key(), "sccache");
351    }
352
353    #[test]
354    fn source_keys_are_stable() {
355        for (source, key) in [
356            (CompilerWrapperSource::Cli, "cli"),
357            (CompilerWrapperSource::Env, "env"),
358            (CompilerWrapperSource::Manifest, "manifest"),
359        ] {
360            assert_eq!(source.as_key(), key);
361        }
362    }
363
364    #[test]
365    fn resolved_as_json_includes_kind_spec_source_and_optional_version() {
366        let resolved = ResolvedCompilerWrapper {
367            kind: wrapper_kind("ccache"),
368            path: Utf8PathBuf::from("/usr/local/bin/ccache"),
369            spec: "ccache".into(),
370            source: CompilerWrapperSource::Cli,
371            identity: Some(CompilerWrapperIdentity {
372                kind: wrapper_kind("ccache"),
373                version: CompilerVersion::parse("4.10.2"),
374                raw_version_line: "ccache version 4.10.2".into(),
375            }),
376        };
377        let json = resolved.as_json();
378        assert_eq!(json["kind"], "ccache");
379        assert_eq!(json["spec"], "ccache");
380        assert_eq!(json["source"], "cli");
381        assert_eq!(json["version"], "4.10.2");
382        assert!(json["raw_version_line"].is_string());
383    }
384
385    #[test]
386    fn resolved_as_json_emits_null_version_when_missing() {
387        let resolved = ResolvedCompilerWrapper {
388            kind: wrapper_kind("sccache"),
389            path: Utf8PathBuf::from("/usr/local/bin/sccache"),
390            spec: "sccache".into(),
391            source: CompilerWrapperSource::Manifest,
392            identity: None,
393        };
394        let json = resolved.as_json();
395        assert_eq!(json["version"], serde_json::Value::Null);
396        assert_eq!(json["raw_version_line"], serde_json::Value::Null);
397    }
398
399    #[test]
400    fn summary_from_resolved_keeps_display_version() {
401        let resolved = ResolvedCompilerWrapper {
402            kind: wrapper_kind("ccache"),
403            path: Utf8PathBuf::from("/usr/local/bin/ccache"),
404            spec: "ccache".into(),
405            source: CompilerWrapperSource::Env,
406            identity: Some(CompilerWrapperIdentity {
407                kind: wrapper_kind("ccache"),
408                version: CompilerVersion::parse("4.10.2"),
409                raw_version_line: "ccache version 4.10.2".into(),
410            }),
411        };
412        let summary = CompilerWrapperSummary::from_resolved(&resolved);
413        assert_eq!(summary.kind.as_key(), "ccache");
414        assert_eq!(summary.source, CompilerWrapperSource::Env);
415        assert_eq!(summary.version.as_deref(), Some("4.10.2"));
416    }
417}