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