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    pub fn with_cli(mut self, kind: ToolKind, spec: ToolSpec) -> Self {
196        let slot = match kind {
197            ToolKind::CCompiler => &mut self.cc,
198            ToolKind::CxxCompiler => &mut self.cxx,
199            ToolKind::Archiver => &mut self.ar,
200        };
201        slot.cli = Some(spec);
202        self
203    }
204}
205
206/// Manifest-shape declaration for tool selection.
207///
208/// Used by `cabin-manifest` to expose `[toolchain]` and
209/// `[target.'cfg(...)'.toolchain]` parse output as typed values.
210/// Every field is optional so omission means "no preference at
211/// this layer".
212#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
213pub struct ToolchainDecl {
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub cc: Option<ToolSpec>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub cxx: Option<ToolSpec>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub ar: Option<ToolSpec>,
220}
221
222impl ToolchainDecl {
223    /// Whether the declaration carries no fields. Used to skip
224    /// emitting empty tables in serialized metadata.
225    pub fn is_empty(&self) -> bool {
226        self.cc.is_none() && self.cxx.is_none() && self.ar.is_none()
227    }
228
229    /// Look up the preference for one tool kind.
230    pub fn get(&self, kind: ToolKind) -> Option<&ToolSpec> {
231        match kind {
232            ToolKind::CCompiler => self.cc.as_ref(),
233            ToolKind::CxxCompiler => self.cxx.as_ref(),
234            ToolKind::Archiver => self.ar.as_ref(),
235        }
236    }
237}
238
239/// Conditional `[target.'cfg(...)'.toolchain]` block.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct ConditionalToolchainDecl {
242    pub condition: Condition,
243    #[serde(default, skip_serializing_if = "ToolchainDecl::is_empty", flatten)]
244    pub toolchain: ToolchainDecl,
245}
246
247/// Workspace-root toolchain settings derived from the manifest.
248/// Holds both the unconditional `[toolchain]` table and any
249/// `[target.'cfg(...)'.toolchain]` overrides.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub struct ToolchainSettings {
252    #[serde(default, skip_serializing_if = "ToolchainDecl::is_empty")]
253    pub general: ToolchainDecl,
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub conditional: Vec<ConditionalToolchainDecl>,
256}
257
258impl ToolchainSettings {
259    /// Whether the settings carry no fields at all.
260    pub fn is_empty(&self) -> bool {
261        self.general.is_empty() && self.conditional.is_empty()
262    }
263}
264
265/// One concrete tool, ready to be invoked.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ResolvedTool {
268    pub kind: ToolKind,
269    /// Absolute filesystem path the tool was resolved to. Always
270    /// pointed at an existing file by the time a `ResolvedTool`
271    /// is built.
272    pub path: Utf8PathBuf,
273    /// What the user (or default) asked for. Stored separately
274    /// from `path` so metadata can show the original spelling
275    /// (`clang++`) without leaking the absolute resolved path.
276    pub spec: ToolSpec,
277    /// Where the selection ultimately came from.
278    pub source: ToolSource,
279}
280
281impl ResolvedTool {
282    /// Path the build planner uses when constructing compile / link
283    /// / archive commands.
284    pub fn path(&self) -> &Utf8Path {
285        &self.path
286    }
287
288    /// Compact JSON view used by `cabin metadata`. Reports the
289    /// requested spec and the source; omits the absolute resolved
290    /// path because that is machine-specific.
291    pub fn as_json(&self) -> serde_json::Value {
292        serde_json::json!({
293            "kind": self.kind.as_key(),
294            "spec": self.spec.display(),
295            "source": tool_source_label(self.source),
296        })
297    }
298}
299
300/// Stable lower-case label for a [`ToolSource`]. Used by the
301/// `cabin metadata` JSON view and the build-configuration
302/// fingerprint summary so callers do not have to redefine the
303/// label in two places.
304pub(crate) fn tool_source_label(source: ToolSource) -> &'static str {
305    match source {
306        ToolSource::Cli => "cli",
307        ToolSource::Env => "env",
308        ToolSource::UserConfig => "user-config",
309        ToolSource::WorkspaceConfig => "workspace-config",
310        ToolSource::PackageConfig => "package-config",
311        ToolSource::ExplicitConfig => "explicit-config",
312        ToolSource::ManifestConditional => "manifest-conditional",
313        ToolSource::Manifest => "manifest",
314        ToolSource::Default => "default",
315    }
316}
317
318/// Fully-resolved C/C++ toolchain.
319///
320/// The build planner reads `cxx`, `cc`, and `ar` directly. Build
321/// scripts get every entry exposed through `CABIN_*` environment
322/// variables. `cabin metadata` reports the same struct serialized
323/// to JSON.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct ResolvedToolchain {
326    /// C++ compiler. Always populated. Used for `.cc` / `.cpp` /
327    /// `.cxx` / `.c++` / `.C` compiles and for linking any target
328    /// whose object set contains a C++ translation unit.
329    pub cxx: ResolvedTool,
330    /// Static-library archiver. Always populated.
331    pub ar: ResolvedTool,
332    /// C compiler. Used for `.c` compiles and as the link driver
333    /// for targets whose objects are pure C. Optional: the resolver
334    /// also probes the documented fallback list (`cc`, `clang`,
335    /// `gcc`) so any standard system populates this without an
336    /// explicit selection. Only `None` when no candidate exists on
337    /// `PATH`; the planner then errors with `MissingCCompiler` if a
338    /// `.c` source is encountered.
339    pub cc: Option<ResolvedTool>,
340}
341
342impl ResolvedToolchain {
343    /// Iterator over every populated tool, sorted by [`ToolKind`].
344    pub fn iter(&self) -> impl Iterator<Item = &ResolvedTool> {
345        let mut entries: Vec<&ResolvedTool> = Vec::with_capacity(3);
346        if let Some(cc) = &self.cc {
347            entries.push(cc);
348        }
349        entries.push(&self.cxx);
350        entries.push(&self.ar);
351        entries.sort_by_key(|t| t.kind);
352        entries.into_iter()
353    }
354
355    /// Compact JSON view used by `cabin metadata` and
356    /// `CABIN_BUILD_CONFIGURATION_JSON`.
357    pub fn as_json(&self) -> serde_json::Value {
358        let entries: BTreeMap<String, serde_json::Value> = self
359            .iter()
360            .map(|t| (t.kind.as_key().to_owned(), t.as_json()))
361            .collect();
362        serde_json::Value::Object(entries.into_iter().collect())
363    }
364}
365
366/// Errors produced while resolving a toolchain.
367#[derive(Debug, Error, Clone, PartialEq, Eq)]
368pub enum ToolchainResolutionError {
369    /// The user asked for a specific tool but Cabin could not find
370    /// an executable that matches.
371    #[error(
372        "{label} `{spec}` was requested by {source_label} but could not be found",
373        label = kind.human_label(),
374        source_label = source_label(*selected_from)
375    )]
376    ToolNotFound {
377        kind: ToolKind,
378        spec: String,
379        selected_from: ToolSource,
380    },
381    /// No tool was specified and the built-in fallback list also
382    /// failed.
383    #[error("no usable {label} found on PATH; set {env_var} or add `{key} = ...` under [toolchain]",
384        label = kind.human_label(),
385        env_var = env_var_for(*kind),
386        key = kind.as_key()
387    )]
388    NoDefault { kind: ToolKind },
389    /// Selected compiler is recognizably unsupported (e.g. MSVC
390    /// `cl.exe`).
391    #[error(
392        "selected {label} `{spec}` is not supported by the current C++ backend; use a GCC- or Clang-like compiler driver",
393        label = kind.human_label()
394    )]
395    UnsupportedCompiler { kind: ToolKind, spec: String },
396    /// A tool was located on `PATH` but the resolved path is not
397    /// valid UTF-8. Cabin's toolchain model assumes UTF-8 paths, so
398    /// an executable under a non-UTF-8 directory is surfaced here
399    /// rather than aborting the process.
400    #[error(
401        "resolved {label} path `{path}` is not valid UTF-8",
402        label = kind.human_label(),
403        path = path.display(),
404    )]
405    NonUtf8Path { kind: ToolKind, path: PathBuf },
406}
407
408fn env_var_for(kind: ToolKind) -> &'static str {
409    match kind {
410        ToolKind::CCompiler => "CC",
411        ToolKind::CxxCompiler => "CXX",
412        ToolKind::Archiver => "AR",
413    }
414}
415
416fn source_label(source: ToolSource) -> &'static str {
417    match source {
418        ToolSource::Cli => "--cli",
419        ToolSource::Env => "an environment variable",
420        ToolSource::UserConfig => "the user `[toolchain]` config table",
421        ToolSource::WorkspaceConfig => "the workspace `[toolchain]` config table",
422        ToolSource::PackageConfig => "the package `[toolchain]` config table",
423        ToolSource::ExplicitConfig => "the `CABIN_CONFIG` `[toolchain]` table",
424        ToolSource::ManifestConditional => "[target.'cfg(...)'.toolchain]",
425        ToolSource::Manifest => "[toolchain]",
426        ToolSource::Default => "the built-in default list",
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn tool_kind_keys_are_stable() {
436        assert_eq!(ToolKind::CCompiler.as_key(), "cc");
437        assert_eq!(ToolKind::CxxCompiler.as_key(), "cxx");
438        assert_eq!(ToolKind::Archiver.as_key(), "ar");
439    }
440
441    #[test]
442    fn tool_spec_parse_distinguishes_paths_and_names() {
443        match ToolSpec::parse("clang++") {
444            ToolSpec::Name(n) => assert_eq!(n, "clang++"),
445            ToolSpec::Path(p) => panic!("expected name, got {p:?}"),
446        }
447        match ToolSpec::parse("/usr/bin/clang++") {
448            ToolSpec::Path(p) => assert_eq!(p, Utf8PathBuf::from("/usr/bin/clang++")),
449            ToolSpec::Name(n) => panic!("expected path, got {n:?}"),
450        }
451        match ToolSpec::parse("./bin/clang++") {
452            ToolSpec::Path(p) => assert_eq!(p, Utf8PathBuf::from("./bin/clang++")),
453            ToolSpec::Name(n) => panic!("expected path, got {n:?}"),
454        }
455    }
456
457    #[test]
458    fn toolchain_decl_is_empty_when_unset() {
459        assert!(ToolchainDecl::default().is_empty());
460        let d = ToolchainDecl {
461            cxx: Some(ToolSpec::Name("clang++".into())),
462            ..Default::default()
463        };
464        assert!(!d.is_empty());
465        assert_eq!(
466            d.get(ToolKind::CxxCompiler).map(ToolSpec::display),
467            Some("clang++".to_owned())
468        );
469        assert!(d.get(ToolKind::CCompiler).is_none());
470    }
471
472    #[test]
473    fn resolved_toolchain_iter_is_sorted_and_skips_missing_cc() {
474        let cxx = ResolvedTool {
475            kind: ToolKind::CxxCompiler,
476            path: Utf8PathBuf::from("/usr/bin/c++"),
477            spec: ToolSpec::Name("c++".into()),
478            source: ToolSource::Default,
479        };
480        let ar = ResolvedTool {
481            kind: ToolKind::Archiver,
482            path: Utf8PathBuf::from("/usr/bin/ar"),
483            spec: ToolSpec::Name("ar".into()),
484            source: ToolSource::Default,
485        };
486        let resolved = ResolvedToolchain { cxx, ar, cc: None };
487        let kinds: Vec<ToolKind> = resolved.iter().map(|t| t.kind).collect();
488        assert_eq!(kinds, vec![ToolKind::CxxCompiler, ToolKind::Archiver]);
489    }
490}