Skip to main content

cabin_core/
toolchain.rs

1//! Typed C/C++ toolchain selection model.
2//!
3//! Cabin builds C/C++ packages with three external tools - a C
4//! compiler, a C++ compiler, and a static-library archiver.  The
5//! selection is explicit, deterministic, and auditable: every
6//! component owns a typed model in this module, and the resolver
7//! in `cabin-toolchain` produces one [`ResolvedToolchain`] per
8//! build.
9//!
10//! This module owns *data only*.  PATH lookup, env reading, and
11//! filesystem checks live in `cabin-toolchain`.  Manifest parsing
12//! lives in `cabin-manifest`.  CLI flag handling lives in
13//! `cabin`.
14
15use std::collections::BTreeMap;
16use std::fmt;
17use std::path::PathBuf;
18
19use camino::{Utf8Path, Utf8PathBuf};
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::condition::Condition;
25
26/// Which kind of tool a selection refers to.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum ToolKind {
30    /// C compiler (driver, e.g. `cc`, `clang`, `gcc`).
31    CCompiler,
32    /// C++ compiler (driver, e.g. `c++`, `clang++`, `g++`).  Also
33    /// drives linking in the current backend.
34    CxxCompiler,
35    /// Static-library archiver (e.g. `ar`, `llvm-ar`).
36    Archiver,
37}
38
39impl ToolKind {
40    /// Stable, lower-case identifier used in CLI flags, manifest
41    /// keys, JSON serialization, and error messages.
42    pub fn as_key(self) -> &'static str {
43        match self {
44            ToolKind::CCompiler => "cc",
45            ToolKind::CxxCompiler => "cxx",
46            ToolKind::Archiver => "ar",
47        }
48    }
49
50    /// Human-readable label used in error messages so users can map
51    /// the failure back to the tool they were thinking about.
52    pub fn human_label(self) -> &'static str {
53        match self {
54            ToolKind::CCompiler => "C compiler",
55            ToolKind::CxxCompiler => "C++ compiler",
56            ToolKind::Archiver => "archiver",
57        }
58    }
59}
60
61impl fmt::Display for ToolKind {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(self.as_key())
64    }
65}
66
67/// Where a tool selection ultimately came from.  Recorded alongside
68/// the resolved tool so `cabin metadata` can show the precedence
69/// without re-deriving it.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum ToolSource {
73    /// Set by a CLI flag (`--cc`, `--cxx`, `--ar`).
74    Cli,
75    /// Set by an environment variable (`CC`, `CXX`, `AR`).
76    Env,
77    /// Set by `[toolchain]` in the user-level config file.
78    UserConfig,
79    /// Set by `[toolchain]` in the workspace-level config file.
80    WorkspaceConfig,
81    /// Set by `[toolchain]` in the package-local config file
82    /// (non-workspace single-package projects).
83    PackageConfig,
84    /// Set by `[toolchain]` in a config file pointed at by the
85    /// `CABIN_CONFIG` environment variable.
86    ExplicitConfig,
87    /// Set by a `[target.'cfg(...)'.toolchain]` table that matches
88    /// the host platform.
89    ManifestConditional,
90    /// Set by the workspace-root `[toolchain]` table.
91    Manifest,
92    /// Auto-detected from PATH using Cabin's documented fallback
93    /// list (`c++` / `clang++` / `g++` for the C++ compiler, `cc` /
94    /// `clang` / `gcc` for the C compiler, `ar` for the archiver).
95    Default,
96}
97
98/// Either a bare command name (resolved against `PATH`) or an
99/// explicit filesystem path.  The resolver turns either form into a
100/// concrete [`Utf8PathBuf`] when it builds a [`ResolvedTool`].
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(untagged)]
103pub enum ToolSpec {
104    /// Filesystem path.  Absolute paths are validated as-is;
105    /// relative paths are resolved against the current working
106    /// directory at build time.
107    Path(Utf8PathBuf),
108    /// Bare command name searched on `PATH`.
109    Name(String),
110}
111
112impl ToolSpec {
113    /// Parse a user-supplied string into the matching variant.
114    /// Anything that contains a path separator (`/`, or `\` on
115    /// Windows) is treated as a path; otherwise the value is a
116    /// bare name.
117    pub fn parse(raw: impl Into<String>) -> Self {
118        let raw = raw.into();
119        if looks_like_path(&raw) {
120            ToolSpec::Path(Utf8PathBuf::from(raw))
121        } else {
122            ToolSpec::Name(raw)
123        }
124    }
125
126    /// Parse a `[toolchain]` cc/cxx/ar value, treating an empty or
127    /// whitespace-only string as absent: returns `None` so each
128    /// caller can map that to its own "empty tool spec" diagnostic;
129    /// otherwise trims and delegates to [`ToolSpec::parse`].  Shared by
130    /// the manifest and config parsers.
131    pub fn parse_non_empty(raw: &str) -> Option<ToolSpec> {
132        let trimmed = raw.trim();
133        if trimmed.is_empty() {
134            None
135        } else {
136            Some(ToolSpec::parse(trimmed.to_owned()))
137        }
138    }
139
140    /// Human-readable form used in errors and metadata.
141    pub fn display(&self) -> String {
142        match self {
143            ToolSpec::Path(p) => p.as_str().to_owned(),
144            ToolSpec::Name(n) => n.clone(),
145        }
146    }
147
148    /// View as a borrowed `Utf8Path` regardless of variant.  Used by
149    /// the resolver when probing for executables.
150    pub fn as_path(&self) -> &Utf8Path {
151        match self {
152            ToolSpec::Path(p) => p.as_path(),
153            ToolSpec::Name(n) => Utf8Path::new(n),
154        }
155    }
156}
157
158fn looks_like_path(raw: &str) -> bool {
159    if raw.contains('/') {
160        return true;
161    }
162    if cfg!(windows) && raw.contains('\\') {
163        return true;
164    }
165    false
166}
167
168/// CLI / orchestration-supplied request for one tool.
169///
170/// `ToolSelection::default()` is "no preference"; the resolver
171/// then consults environment variables, manifest tables, and
172/// finally the built-in default list.
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct ToolSelection {
175    /// Set when the user passed a CLI flag for this tool.  Highest
176    /// precedence.
177    pub cli: Option<ToolSpec>,
178}
179
180/// Aggregate of [`ToolSelection`]s, one per [`ToolKind`].
181#[derive(Debug, Clone, Default, PartialEq, Eq)]
182pub struct ToolchainSelection {
183    pub cc: ToolSelection,
184    pub cxx: ToolSelection,
185    pub ar: ToolSelection,
186}
187
188impl ToolchainSelection {
189    /// Empty selection: every tool is "no preference".
190    pub fn empty() -> Self {
191        Self::default()
192    }
193
194    /// Helper for tests / programmatic construction.
195    #[must_use]
196    pub fn with_cli(mut self, kind: ToolKind, spec: ToolSpec) -> Self {
197        let slot = match kind {
198            ToolKind::CCompiler => &mut self.cc,
199            ToolKind::CxxCompiler => &mut self.cxx,
200            ToolKind::Archiver => &mut self.ar,
201        };
202        slot.cli = Some(spec);
203        self
204    }
205}
206
207/// Manifest-shape declaration for tool selection.
208///
209/// Used by `cabin-manifest` to expose `[toolchain]` and
210/// `[target.'cfg(...)'.toolchain]` parse output as typed values.
211/// Every field is optional so omission means "no preference at
212/// this layer".
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
214pub struct ToolchainDecl {
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub cc: Option<ToolSpec>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub cxx: Option<ToolSpec>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub ar: Option<ToolSpec>,
221}
222
223impl ToolchainDecl {
224    /// Whether the declaration carries no fields.  Used to skip
225    /// emitting empty tables in serialized metadata.
226    pub fn is_empty(&self) -> bool {
227        self.cc.is_none() && self.cxx.is_none() && self.ar.is_none()
228    }
229
230    /// Look up the preference for one tool kind.
231    pub fn get(&self, kind: ToolKind) -> Option<&ToolSpec> {
232        match kind {
233            ToolKind::CCompiler => self.cc.as_ref(),
234            ToolKind::CxxCompiler => self.cxx.as_ref(),
235            ToolKind::Archiver => self.ar.as_ref(),
236        }
237    }
238}
239
240/// Conditional `[target.'cfg(...)'.toolchain]` block.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct ConditionalToolchainDecl {
243    pub condition: Condition,
244    #[serde(default, skip_serializing_if = "ToolchainDecl::is_empty", flatten)]
245    pub toolchain: ToolchainDecl,
246}
247
248/// Workspace-root toolchain settings derived from the manifest.
249/// Holds both the unconditional `[toolchain]` table and any
250/// `[target.'cfg(...)'.toolchain]` overrides.
251#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
252pub struct ToolchainSettings {
253    #[serde(default, skip_serializing_if = "ToolchainDecl::is_empty")]
254    pub general: ToolchainDecl,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub conditional: Vec<ConditionalToolchainDecl>,
257}
258
259impl ToolchainSettings {
260    /// Whether the settings carry no fields at all.
261    pub fn is_empty(&self) -> bool {
262        self.general.is_empty() && self.conditional.is_empty()
263    }
264}
265
266/// One concrete tool, ready to be invoked.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct ResolvedTool {
269    pub kind: ToolKind,
270    /// Absolute filesystem path the tool was resolved to.  Always
271    /// pointed at an existing file by the time a `ResolvedTool`
272    /// is built.
273    pub path: Utf8PathBuf,
274    /// What the user (or default) asked for.  Stored separately
275    /// from `path` so metadata can show the original spelling
276    /// (`clang++`) without leaking the absolute resolved path.
277    pub spec: ToolSpec,
278    /// Where the selection ultimately came from.
279    pub source: ToolSource,
280}
281
282impl ResolvedTool {
283    /// Path the build planner uses when constructing compile / link
284    /// / archive commands.
285    pub fn path(&self) -> &Utf8Path {
286        &self.path
287    }
288
289    /// Compact JSON view used by `cabin metadata`.  Reports the
290    /// requested spec and the source; omits the absolute resolved
291    /// path because that is machine-specific.
292    pub fn as_json(&self) -> serde_json::Value {
293        serde_json::json!({
294            "kind": self.kind.as_key(),
295            "spec": self.spec.display(),
296            "source": tool_source_label(self.source),
297        })
298    }
299}
300
301/// Stable lower-case label for a [`ToolSource`].  Used by the
302/// `cabin metadata` JSON view and the build-configuration
303/// fingerprint summary so callers do not have to redefine the
304/// label in two places.
305pub(crate) fn tool_source_label(source: ToolSource) -> &'static str {
306    match source {
307        ToolSource::Cli => "cli",
308        ToolSource::Env => "env",
309        ToolSource::UserConfig => "user-config",
310        ToolSource::WorkspaceConfig => "workspace-config",
311        ToolSource::PackageConfig => "package-config",
312        ToolSource::ExplicitConfig => "explicit-config",
313        ToolSource::ManifestConditional => "manifest-conditional",
314        ToolSource::Manifest => "manifest",
315        ToolSource::Default => "default",
316    }
317}
318
319/// Fully-resolved C/C++ toolchain.
320///
321/// The build planner reads `cxx`, `cc`, and `ar` directly.  Build
322/// scripts get every entry exposed through `CABIN_*` environment
323/// variables. `cabin metadata` reports the same struct serialized
324/// to JSON.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct ResolvedToolchain {
327    /// C++ compiler.  Always populated.  Used for `.cc` / `.cpp` /
328    /// `.cxx` / `.c++` / `.C` compiles and for linking any target
329    /// whose object set contains a C++ translation unit.
330    pub cxx: ResolvedTool,
331    /// Static-library archiver.  Always populated.
332    pub ar: ResolvedTool,
333    /// C compiler.  Used for `.c` compiles and as the link driver
334    /// for targets whose objects are pure C.  Optional: the resolver
335    /// also probes the documented fallback list (`cc`, `clang`,
336    /// `gcc`) so any standard system populates this without an
337    /// explicit selection.  Only `None` when no candidate exists on
338    /// `PATH`; the planner then errors with `MissingCCompiler` if a
339    /// `.c` source is encountered.
340    pub cc: Option<ResolvedTool>,
341}
342
343impl ResolvedToolchain {
344    /// Iterator over every populated tool, sorted by [`ToolKind`].
345    pub fn iter(&self) -> impl Iterator<Item = &ResolvedTool> {
346        let mut entries: Vec<&ResolvedTool> = Vec::with_capacity(3);
347        if let Some(cc) = &self.cc {
348            entries.push(cc);
349        }
350        entries.push(&self.cxx);
351        entries.push(&self.ar);
352        entries.sort_by_key(|t| t.kind);
353        entries.into_iter()
354    }
355
356    /// Compact JSON view used by `cabin metadata` and
357    /// `CABIN_BUILD_CONFIGURATION_JSON`.
358    pub fn as_json(&self) -> serde_json::Value {
359        let entries: BTreeMap<String, serde_json::Value> = self
360            .iter()
361            .map(|t| (t.kind.as_key().to_owned(), t.as_json()))
362            .collect();
363        serde_json::Value::Object(entries.into_iter().collect())
364    }
365}
366
367/// Errors produced while resolving a toolchain.
368#[derive(Debug, Error, Clone, PartialEq, Eq)]
369pub enum ToolchainResolutionError {
370    /// The user asked for a specific tool but Cabin could not find
371    /// an executable that matches.
372    #[error(
373        "{label} `{spec}` was requested by {source_label} but could not be found",
374        label = kind.human_label(),
375        source_label = source_label(*selected_from)
376    )]
377    ToolNotFound {
378        kind: ToolKind,
379        spec: String,
380        selected_from: ToolSource,
381    },
382    /// No tool was specified and the built-in fallback list also
383    /// failed.
384    #[error("no usable {label} found on PATH; set {env_var} or add `{key} = ...` under [toolchain]",
385        label = kind.human_label(),
386        env_var = env_var_for(*kind),
387        key = kind.as_key()
388    )]
389    NoDefault { kind: ToolKind },
390    /// Selected compiler is recognizably unsupported (e.g.  MSVC
391    /// `cl.exe`).
392    #[error(
393        "selected {label} `{spec}` is not supported by the current C++ backend; use a GCC- or Clang-like compiler driver",
394        label = kind.human_label()
395    )]
396    UnsupportedCompiler { kind: ToolKind, spec: String },
397    /// A tool was located on `PATH` but the resolved path is not
398    /// valid UTF-8.  Cabin's toolchain model assumes UTF-8 paths, so
399    /// an executable under a non-UTF-8 directory is surfaced here
400    /// rather than aborting the process.
401    #[error(
402        "resolved {label} path `{path}` is not valid UTF-8",
403        label = kind.human_label(),
404        path = path.display(),
405    )]
406    NonUtf8Path { kind: ToolKind, path: PathBuf },
407}
408
409fn env_var_for(kind: ToolKind) -> &'static str {
410    match kind {
411        ToolKind::CCompiler => "CC",
412        ToolKind::CxxCompiler => "CXX",
413        ToolKind::Archiver => "AR",
414    }
415}
416
417fn source_label(source: ToolSource) -> &'static str {
418    match source {
419        ToolSource::Cli => "--cli",
420        ToolSource::Env => "an environment variable",
421        ToolSource::UserConfig => "the user `[toolchain]` config table",
422        ToolSource::WorkspaceConfig => "the workspace `[toolchain]` config table",
423        ToolSource::PackageConfig => "the package `[toolchain]` config table",
424        ToolSource::ExplicitConfig => "the `CABIN_CONFIG` `[toolchain]` table",
425        ToolSource::ManifestConditional => "[target.'cfg(...)'.toolchain]",
426        ToolSource::Manifest => "[toolchain]",
427        ToolSource::Default => "the built-in default list",
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn tool_kind_keys_are_stable() {
437        assert_eq!(ToolKind::CCompiler.as_key(), "cc");
438        assert_eq!(ToolKind::CxxCompiler.as_key(), "cxx");
439        assert_eq!(ToolKind::Archiver.as_key(), "ar");
440    }
441
442    #[test]
443    fn tool_spec_parse_distinguishes_paths_and_names() {
444        match ToolSpec::parse("clang++") {
445            ToolSpec::Name(n) => assert_eq!(n, "clang++"),
446            ToolSpec::Path(p) => panic!("expected name, got {p:?}"),
447        }
448        match ToolSpec::parse("/usr/bin/clang++") {
449            ToolSpec::Path(p) => assert_eq!(p, Utf8PathBuf::from("/usr/bin/clang++")),
450            ToolSpec::Name(n) => panic!("expected path, got {n:?}"),
451        }
452        match ToolSpec::parse("./bin/clang++") {
453            ToolSpec::Path(p) => assert_eq!(p, Utf8PathBuf::from("./bin/clang++")),
454            ToolSpec::Name(n) => panic!("expected path, got {n:?}"),
455        }
456    }
457
458    #[test]
459    fn toolchain_decl_is_empty_when_unset() {
460        assert!(ToolchainDecl::default().is_empty());
461        let d = ToolchainDecl {
462            cxx: Some(ToolSpec::Name("clang++".into())),
463            ..Default::default()
464        };
465        assert!(!d.is_empty());
466        assert_eq!(
467            d.get(ToolKind::CxxCompiler).map(ToolSpec::display),
468            Some("clang++".to_owned())
469        );
470        assert!(d.get(ToolKind::CCompiler).is_none());
471    }
472
473    #[test]
474    fn resolved_toolchain_iter_is_sorted_and_skips_missing_cc() {
475        let cxx = ResolvedTool {
476            kind: ToolKind::CxxCompiler,
477            path: Utf8PathBuf::from("/usr/bin/c++"),
478            spec: ToolSpec::Name("c++".into()),
479            source: ToolSource::Default,
480        };
481        let ar = ResolvedTool {
482            kind: ToolKind::Archiver,
483            path: Utf8PathBuf::from("/usr/bin/ar"),
484            spec: ToolSpec::Name("ar".into()),
485            source: ToolSource::Default,
486        };
487        let resolved = ResolvedToolchain { cxx, ar, cc: None };
488        let kinds: Vec<ToolKind> = resolved.iter().map(|t| t.kind).collect();
489        assert_eq!(kinds, vec![ToolKind::CxxCompiler, ToolKind::Archiver]);
490    }
491}