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