Skip to main content

cabin_core/
compiler_wrapper.rs

1//! Typed compiler-cache wrapper model.
2//!
3//! Cabin can prefix the C++ compile driver with a *compiler cache*
4//! wrapper such as `ccache` or `sccache`. The wrapper is a separate
5//! concept from the compiler itself: it is layered on top, applies
6//! only to compile commands (never link or archive), and is selected
7//! through the same precedence ladder as the rest of the 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;
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22use crate::compiler::CompilerVersion;
23use crate::condition::Condition;
24
25/// Which compiler-cache wrapper Cabin should prefix the C++ compile
26/// driver with. The "no wrapper" case is represented as the absence
27/// of a [`ResolvedCompilerWrapper`] (i.e. an `Option::None` at the
28/// call site), so this enum stays small and total over the wrappers
29/// Cabin actually understands.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum CompilerWrapperKind {
33    /// `ccache` — local compiler cache.
34    Ccache,
35    /// `sccache` — local-or-remote compiler cache.
36    Sccache,
37}
38
39impl CompilerWrapperKind {
40    /// Stable lower-case identifier used in CLI flags, manifest
41    /// values, environment variables, JSON output, and error
42    /// messages.
43    pub const fn as_key(self) -> &'static str {
44        match self {
45            CompilerWrapperKind::Ccache => "ccache",
46            CompilerWrapperKind::Sccache => "sccache",
47        }
48    }
49
50    /// Bare command name searched on `PATH` when no explicit path
51    /// is given. Today this matches [`Self::as_key`] for both
52    /// supported wrappers; kept as a separate accessor so future
53    /// platform-specific binaries (`sccache-dist`, …) can diverge
54    /// from the manifest key without breaking existing manifests.
55    pub const fn default_command(self) -> &'static str {
56        match self {
57            CompilerWrapperKind::Ccache => "ccache",
58            CompilerWrapperKind::Sccache => "sccache",
59        }
60    }
61
62    /// Every supported wrapper, in stable declaration order. Used
63    /// in error messages so users see the full list of accepted
64    /// values.
65    pub const fn all() -> &'static [CompilerWrapperKind] {
66        &[CompilerWrapperKind::Ccache, CompilerWrapperKind::Sccache]
67    }
68}
69
70impl fmt::Display for CompilerWrapperKind {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(self.as_key())
73    }
74}
75
76/// What the user (or a manifest layer) asked for, structurally.
77///
78/// `Disabled` is *explicit* opt-out: a higher-precedence layer can
79/// no longer turn a wrapper back on. `Use(_)` selects a specific
80/// wrapper kind. Layers that did not express any preference are
81/// represented as `Option::None` at the call site, not as a variant
82/// here.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case", tag = "kind")]
85pub enum CompilerWrapperRequest {
86    /// "No wrapper at all". Equivalent to the manifest value
87    /// `compiler-wrapper = "none"` and the CLI flag
88    /// `--no-compiler-wrapper` / `--compiler-wrapper none`.
89    Disabled,
90    /// Use the named wrapper. The bare command (`ccache`,
91    /// `sccache`) is searched on `PATH`; missing executables are
92    /// rejected by the resolver.
93    Use { wrapper: CompilerWrapperKind },
94}
95
96impl CompilerWrapperRequest {
97    /// Parse a manifest / CLI / env value. Accepts:
98    ///
99    /// - `"none"` (case-insensitive) → [`Self::Disabled`].
100    /// - `"ccache"` → `Use(Ccache)`.
101    /// - `"sccache"` → `Use(Sccache)`.
102    ///
103    /// Anything else is rejected. Path-shaped inputs are
104    /// deliberately *not* accepted today: the resolver expects to
105    /// do its own `PATH` search so the resulting selection stays
106    /// machine-independent. A future revision may add a path
107    /// variant; until then the conservative "named-only" surface
108    /// is the documented contract.
109    ///
110    /// # Errors
111    /// Returns [`CompilerWrapperParseError::Empty`] when `raw` is empty after
112    /// trimming, and [`CompilerWrapperParseError::Unsupported`] for any value
113    /// other than `none`/`off`/`disabled`, `ccache`, or `sccache`.
114    pub fn parse(raw: &str) -> Result<Self, CompilerWrapperParseError> {
115        let trimmed = raw.trim();
116        if trimmed.is_empty() {
117            return Err(CompilerWrapperParseError::Empty);
118        }
119        match trimmed.to_ascii_lowercase().as_str() {
120            "none" | "off" | "disabled" => Ok(Self::Disabled),
121            "ccache" => Ok(Self::Use {
122                wrapper: CompilerWrapperKind::Ccache,
123            }),
124            "sccache" => Ok(Self::Use {
125                wrapper: CompilerWrapperKind::Sccache,
126            }),
127            _ => Err(CompilerWrapperParseError::Unsupported {
128                raw: trimmed.to_owned(),
129            }),
130        }
131    }
132
133    /// Stable display string. Round-trips with [`Self::parse`].
134    pub const fn as_key(&self) -> &'static str {
135        match self {
136            CompilerWrapperRequest::Disabled => "none",
137            CompilerWrapperRequest::Use {
138                wrapper: CompilerWrapperKind::Ccache,
139            } => "ccache",
140            CompilerWrapperRequest::Use {
141                wrapper: CompilerWrapperKind::Sccache,
142            } => "sccache",
143        }
144    }
145}
146
147/// Errors produced by [`CompilerWrapperRequest::parse`].
148#[derive(Debug, Clone, PartialEq, Eq, Error)]
149pub enum CompilerWrapperParseError {
150    #[error("compiler-wrapper value must not be empty")]
151    Empty,
152    #[error(
153        "compiler-wrapper value `{raw}` is not supported; expected one of: none, ccache, sccache"
154    )]
155    Unsupported { raw: String },
156}
157
158/// `[target.'cfg(...)'.profile.cache]` block. Same shape as the
159/// general `[profile.cache]` table but tagged with the predicate
160/// that gates it.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ConditionalCompilerWrapperDecl {
163    pub condition: Condition,
164    pub request: CompilerWrapperRequest,
165}
166
167/// Workspace-root manifest's compiler-wrapper declarations.
168///
169/// The wrapper is a single value per build invocation. To keep that
170/// invariant clear, only the workspace-root manifest's
171/// `[profile.cache]` / `[target.'cfg(...)'.profile.cache]`
172/// declarations matter; member manifests that try to declare any
173/// cache settings are rejected by the workspace loader before the
174/// resolver runs.
175#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
176pub struct CompilerWrapperManifestSettings {
177    /// Unconditional `[profile.cache].compiler-wrapper`. `None` means
178    /// the manifest did not declare a general value.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub general: Option<CompilerWrapperRequest>,
181    /// `[target.'cfg(...)'.profile.cache]` overlays. Empty when no
182    /// conditional wrapper declarations exist.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub conditional: Vec<ConditionalCompilerWrapperDecl>,
185}
186
187impl CompilerWrapperManifestSettings {
188    /// Whether the settings carry no fields at all. Used by the
189    /// workspace loader to decide whether a member manifest's
190    /// declaration should be rejected, and by the manifest
191    /// serializer to skip emitting empty tables.
192    pub fn is_empty(&self) -> bool {
193        self.general.is_none() && self.conditional.is_empty()
194    }
195}
196
197/// Where a resolved wrapper selection ultimately came from.
198/// Recorded alongside the resolved wrapper so `cabin metadata` can
199/// show the precedence without re-deriving it.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum CompilerWrapperSource {
203    /// Set by the `--compiler-wrapper` / `--no-compiler-wrapper`
204    /// CLI flag.
205    Cli,
206    /// Set by the `CABIN_COMPILER_WRAPPER` environment variable.
207    Env,
208    /// Set by `[profile.cache]` in the user-level config file.
209    UserConfig,
210    /// Set by `[profile.cache]` in the workspace-level config file.
211    WorkspaceConfig,
212    /// Set by `[profile.cache]` in the package-local config file
213    /// (non-workspace single-package projects).
214    PackageConfig,
215    /// Set by `[profile.cache]` in a config file pointed at by the
216    /// `CABIN_CONFIG` environment variable.
217    ExplicitConfig,
218    /// Set by a `[target.'cfg(...)'.profile.cache]` overlay matching
219    /// the host platform.
220    ManifestConditional,
221    /// Set by the workspace-root `[profile.cache]` table.
222    Manifest,
223}
224
225impl CompilerWrapperSource {
226    /// Stable lower-case label used in JSON output and error
227    /// messages.
228    pub const fn as_key(self) -> &'static str {
229        match self {
230            CompilerWrapperSource::Cli => "cli",
231            CompilerWrapperSource::Env => "env",
232            CompilerWrapperSource::UserConfig => "user-config",
233            CompilerWrapperSource::WorkspaceConfig => "workspace-config",
234            CompilerWrapperSource::PackageConfig => "package-config",
235            CompilerWrapperSource::ExplicitConfig => "explicit-config",
236            CompilerWrapperSource::ManifestConditional => "manifest-conditional",
237            CompilerWrapperSource::Manifest => "manifest",
238        }
239    }
240}
241
242impl fmt::Display for CompilerWrapperSource {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(self.as_key())
245    }
246}
247
248/// Identity captured from a wrapper executable's `--version`
249/// output. Populated by `cabin-toolchain::detect_compiler_wrapper`
250/// and surfaced through metadata.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct CompilerWrapperIdentity {
253    pub kind: CompilerWrapperKind,
254    /// Parsed numeric version (`Some` when the wrapper printed a
255    /// recognizable version string).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub version: Option<CompilerVersion>,
258    /// First non-empty line of the captured `--version` output,
259    /// preserved verbatim so users can see exactly what the
260    /// wrapper reported.
261    pub raw_version_line: String,
262}
263
264impl CompilerWrapperIdentity {
265    /// Convenience constructor for an identity whose version could
266    /// not be parsed.
267    pub fn unknown_version(kind: CompilerWrapperKind, raw_version_line: impl Into<String>) -> Self {
268        Self {
269            kind,
270            version: None,
271            raw_version_line: raw_version_line.into(),
272        }
273    }
274}
275
276/// Fully resolved compiler-cache wrapper, ready to prefix the C++
277/// compile command.
278///
279/// `path` is the absolute filesystem path the resolver settled on.
280/// `spec` records the original spelling (the wrapper's stable
281/// `as_key()`) so metadata can show the requested name without
282/// leaking machine-specific paths.
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284pub struct ResolvedCompilerWrapper {
285    pub kind: CompilerWrapperKind,
286    pub path: PathBuf,
287    /// User-visible spelling for metadata. Today this is always
288    /// the bare command name corresponding to `kind`.
289    pub spec: String,
290    pub source: CompilerWrapperSource,
291    /// Detected identity (`Some` when version probing succeeded).
292    /// Always emitted by `cabin metadata` even when `None`, so
293    /// callers do not have to special-case the absence.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub identity: Option<CompilerWrapperIdentity>,
296}
297
298impl ResolvedCompilerWrapper {
299    /// Compact JSON view used by `cabin metadata`. Mirrors the
300    /// shape of [`crate::ResolvedTool::as_json`] so consumers see a
301    /// consistent pattern.
302    pub fn as_json(&self) -> serde_json::Value {
303        let version = self
304            .identity
305            .as_ref()
306            .and_then(|id| id.version.as_ref())
307            .map_or(serde_json::Value::Null, |v| {
308                serde_json::Value::String(v.to_display_string())
309            });
310        let raw = self
311            .identity
312            .as_ref()
313            .map_or(serde_json::Value::Null, |id| {
314                serde_json::Value::String(id.raw_version_line.clone())
315            });
316        serde_json::json!({
317            "kind": self.kind.as_key(),
318            "spec": self.spec,
319            "source": self.source.as_key(),
320            "version": version,
321            "raw_version_line": raw,
322        })
323    }
324}
325
326/// Lightweight, non-machine-specific summary of a resolved wrapper.
327/// Carried inside [`crate::ToolchainSummary`] so the build
328/// configuration fingerprint reflects "which wrapper did this build
329/// use" without pinning the local absolute path.
330#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
331pub struct CompilerWrapperSummary {
332    /// Stable wrapper key (`ccache` / `sccache`).
333    pub kind: String,
334    /// User-visible spec spelling.
335    pub spec: String,
336    /// Source label (matches [`CompilerWrapperSource::as_key`]).
337    pub source: String,
338    /// Detected version, when probing succeeded. Stored as a
339    /// display string so the summary stays portable across
340    /// `CompilerVersion` schema changes.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub version: Option<String>,
343}
344
345impl CompilerWrapperSummary {
346    /// Build a summary from a resolved wrapper.
347    pub fn from_resolved(resolved: &ResolvedCompilerWrapper) -> Self {
348        Self {
349            kind: resolved.kind.as_key().to_owned(),
350            spec: resolved.spec.clone(),
351            source: resolved.source.as_key().to_owned(),
352            version: resolved
353                .identity
354                .as_ref()
355                .and_then(|id| id.version.as_ref())
356                .map(super::compiler::CompilerVersion::to_display_string),
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn parse_accepts_documented_values() {
367        assert_eq!(
368            CompilerWrapperRequest::parse("none").unwrap(),
369            CompilerWrapperRequest::Disabled
370        );
371        assert_eq!(
372            CompilerWrapperRequest::parse("None").unwrap(),
373            CompilerWrapperRequest::Disabled
374        );
375        assert_eq!(
376            CompilerWrapperRequest::parse("ccache").unwrap(),
377            CompilerWrapperRequest::Use {
378                wrapper: CompilerWrapperKind::Ccache,
379            }
380        );
381        assert_eq!(
382            CompilerWrapperRequest::parse("sccache").unwrap(),
383            CompilerWrapperRequest::Use {
384                wrapper: CompilerWrapperKind::Sccache,
385            }
386        );
387    }
388
389    #[test]
390    fn parse_rejects_unsupported_names_with_clear_error() {
391        let err = CompilerWrapperRequest::parse("fastcache").unwrap_err();
392        match err {
393            CompilerWrapperParseError::Unsupported { raw } => assert_eq!(raw, "fastcache"),
394            CompilerWrapperParseError::Empty => panic!("expected Unsupported, got Empty"),
395        }
396    }
397
398    #[test]
399    fn parse_rejects_paths_today() {
400        // The conservative initial surface accepts only named
401        // wrappers. Path-shaped inputs must error so users get a
402        // clear message rather than a surprise `PATH` search.
403        let err = CompilerWrapperRequest::parse("/usr/local/bin/ccache").unwrap_err();
404        assert!(matches!(err, CompilerWrapperParseError::Unsupported { .. }));
405    }
406
407    #[test]
408    fn parse_rejects_empty() {
409        assert_eq!(
410            CompilerWrapperRequest::parse("").unwrap_err(),
411            CompilerWrapperParseError::Empty
412        );
413        assert_eq!(
414            CompilerWrapperRequest::parse("   ").unwrap_err(),
415            CompilerWrapperParseError::Empty
416        );
417    }
418
419    #[test]
420    fn as_key_round_trips_through_parse() {
421        for value in ["none", "ccache", "sccache"] {
422            let parsed = CompilerWrapperRequest::parse(value).unwrap();
423            assert_eq!(parsed.as_key(), value);
424        }
425    }
426
427    #[test]
428    fn manifest_settings_is_empty_by_default() {
429        assert!(CompilerWrapperManifestSettings::default().is_empty());
430    }
431
432    #[test]
433    fn manifest_settings_reports_non_empty_when_general_set() {
434        let settings = CompilerWrapperManifestSettings {
435            general: Some(CompilerWrapperRequest::Use {
436                wrapper: CompilerWrapperKind::Ccache,
437            }),
438            ..Default::default()
439        };
440        assert!(!settings.is_empty());
441    }
442
443    #[test]
444    fn source_keys_are_stable() {
445        for (source, key) in [
446            (CompilerWrapperSource::Cli, "cli"),
447            (CompilerWrapperSource::Env, "env"),
448            (
449                CompilerWrapperSource::ManifestConditional,
450                "manifest-conditional",
451            ),
452            (CompilerWrapperSource::Manifest, "manifest"),
453        ] {
454            assert_eq!(source.as_key(), key);
455        }
456    }
457
458    #[test]
459    fn resolved_as_json_includes_kind_spec_source_and_optional_version() {
460        let resolved = ResolvedCompilerWrapper {
461            kind: CompilerWrapperKind::Ccache,
462            path: PathBuf::from("/usr/local/bin/ccache"),
463            spec: "ccache".into(),
464            source: CompilerWrapperSource::Cli,
465            identity: Some(CompilerWrapperIdentity {
466                kind: CompilerWrapperKind::Ccache,
467                version: CompilerVersion::parse("4.10.2"),
468                raw_version_line: "ccache version 4.10.2".into(),
469            }),
470        };
471        let json = resolved.as_json();
472        assert_eq!(json["kind"], "ccache");
473        assert_eq!(json["spec"], "ccache");
474        assert_eq!(json["source"], "cli");
475        assert_eq!(json["version"], "4.10.2");
476        assert!(json["raw_version_line"].is_string());
477    }
478
479    #[test]
480    fn resolved_as_json_emits_null_version_when_missing() {
481        let resolved = ResolvedCompilerWrapper {
482            kind: CompilerWrapperKind::Sccache,
483            path: PathBuf::from("/usr/local/bin/sccache"),
484            spec: "sccache".into(),
485            source: CompilerWrapperSource::Manifest,
486            identity: None,
487        };
488        let json = resolved.as_json();
489        assert_eq!(json["version"], serde_json::Value::Null);
490        assert_eq!(json["raw_version_line"], serde_json::Value::Null);
491    }
492
493    #[test]
494    fn summary_from_resolved_keeps_display_version() {
495        let resolved = ResolvedCompilerWrapper {
496            kind: CompilerWrapperKind::Ccache,
497            path: PathBuf::from("/usr/local/bin/ccache"),
498            spec: "ccache".into(),
499            source: CompilerWrapperSource::Env,
500            identity: Some(CompilerWrapperIdentity {
501                kind: CompilerWrapperKind::Ccache,
502                version: CompilerVersion::parse("4.10.2"),
503                raw_version_line: "ccache version 4.10.2".into(),
504            }),
505        };
506        let summary = CompilerWrapperSummary::from_resolved(&resolved);
507        assert_eq!(summary.kind, "ccache");
508        assert_eq!(summary.source, "env");
509        assert_eq!(summary.version.as_deref(), Some("4.10.2"));
510    }
511}