Architecture
This document describes the Cabin workspace, the responsibilities of each crate, the data flow for the currently implemented behavior, and the planned shape of deferred layers. The codebase is organized as small crates with narrow ownership boundaries; the notes below describe which crate owns each implemented surface and where deferred work should land.
The currently implemented surface, layered briefly: first-class
dependency kinds (normal / dev), advanced workspace
semantics, the local C / C++ / mixed-language build, the
Cabin-owned resolver layered on PubGrub, the lockfile, the
content-addressed source-archive cache, the local file
registry, the read-only sparse-HTTP index client,
features with a cross-package feature resolver and the documented
foundation limits,
target / platform-specific
dependencies, build profiles, typed toolchain selection with
capability detection, ccache / sccache wrapper integration,
the typed .cabin/config.toml system, patch / override and
source replacement, the dev / test / example target kinds
plus cabin test, vendoring + --offline,
cabin metadata / cabin tree / cabin explain, the
Cargo-inspired interface foundation (cabin run, the
cabin-env crate), cabin fmt / cabin tidy,
pkg-config-driven `system = true deps,CPPFLAGS/CFLAGS/CXXFLAGS/LDFLAGSingestion,-j/--jobsbuild / run / tidy parallelism,cabin new --bin/--libscaffold parity,cabin versionpluscabin --list, and the curated
foundation-port layer with the
[zlib](https://github.com/cabinpkg/cabin/tree/main/ports/zlib/) port as its first external C library
milestone (see [foundation-ports.md`](foundation-ports.md)).
See
dependency-kinds.md for the
dependency-kind protocol and command behavior,
registry-design.md for the registry
direction (including the file-registry layout that the sparse
HTTP client consumes),
artifacts.md for the source-archive layout,
package-format.md for the package archive +
canonical metadata schema,
distribution.md for the shell-completion and
man-page surfaces.
Repository shape today
crates/
cabin-core/ stable internal data model
cabin-manifest/ cabin.toml parsing
cabin-config/ typed `.cabin/config.toml` discovery + merge
cabin-toolchain/ C/C++ compiler / archiver / Ninja detection + wrappers
cabin-workspace/ local + registry package graph loader, patches, selection
cabin-feature/ cross-package feature resolver
cabin-build/ backend-independent build graph planner
cabin-ninja/ build.ninja + compile_commands.json writers
cabin-index/ local JSON package index loader
cabin-resolver/ dependency resolver (PubGrub-backed) with lockfile-aware modes
cabin-lockfile/ cabin.lock reader / writer / validator
cabin-artifact/ source-archive cache, checksum verifier, extractor
cabin-package/ deterministic source-archive + canonical metadata writer
cabin-port/ foundation-port recipe parser + preparation pipeline
cabin-publish/ publish-workflow orchestration
cabin-registry-file/ local file-registry layout, atomic writes, lock
cabin-index-http/ sparse HTTP index client (read-only)
cabin-vendor/ typed VendorPlan + file-registry materialiser
cabin-test/ test-target plan + sequential runner
cabin-explain/ typed model for `cabin tree` / `cabin explain`
cabin-fs/ shared low-level filesystem helpers
cabin-diagnostics/ user-facing diagnostic presentation + annotate-snippets boundary
cabin-env/ CABIN_* env-var names + run/test env builder
cabin-source-discovery/ shared C / C++ source walker for fmt / tidy
cabin-fmt/ clang-format runner used by `cabin fmt`
cabin-tidy/ run-clang-tidy runner used by `cabin tidy`
cabin-system-deps/ pkg-config runner used by ``system = true` deps`
cabin/ `cabin` binary, command dispatch
docs/
architecture.md this file
manifest.md cabin.toml schema reference
index.md local JSON index format
lockfile.md cabin.lock format reference
artifacts.md source archive + cache layout
package-format.md package archive + canonical metadata schema
distribution.md shell completions + man pages
registry-design.md local registry interface boundary
features.md features foundation
workspaces.md workspace root discovery, member selection, inheritance
metadata-tree-explain.md `cabin metadata` / `cabin tree` / `cabin explain`
cargo-inspired-interface.md Cabin-vs-Cargo audit / classification
environment-variables.md CABIN_* read-side / run / test env vars
fmt.md `cabin fmt` (clang-format)
tidy.md `cabin tidy` (run-clang-tidy)
system-dependencies.md ``system = true` deps` and pkg-config
new-and-init.md scaffold semantics for `cabin new` / `cabin init`
testing.md `cabin test` runner and portability rules
targets.md target kinds, `test` / `example`
toolchains.md typed toolchain selection, capability detection
config.md `.cabin/config.toml` schema, discovery, precedence
profiles.md build profile model, inheritance, fingerprint inputs
compiler-cache.md `ccache` / `sccache` integration
vendoring-offline.md `cabin vendor` and `--offline` semantics
dependency-kinds.md two dependency kinds (normal/dev)
target-dependencies.md `[target.'cfg(...)'.<kind>]` predicates
patch-overrides.md patch / override / source replacement
package-index.md package index schema
foundation-ports.md curated foundation-port recipes (zlib milestone)
ports/
README.md foundation-port policy + retirement plan
zlib/ first foundation port: pinned upstream zlib 1.3.1
Crate responsibilities and rules
The split is by responsibility, not by feature. Each crate has a narrow public surface; future work adds new crates rather than widening existing ones.
cabin-core
Stable, format-agnostic types: Package, Target, TargetKind,
PackageName, TargetName, Dependency,
DependencySource::{Path, Version}, plus the build-configuration
model — Features, SelectionRequest, and BuildConfiguration
(with a deterministic SHA-256 fingerprint that now also includes
the selected profile's relevant fields). The cfg / target-condition
AST also lives here as Condition, ConditionKey, and
TargetPlatform, and the build-profile model lives here as
ProfileName, OptLevel, BuiltinProfile, ProfileDefinition,
ProfileSelection, ResolvedProfile, ProfileSource, and
resolve_profile. Manifest, index, lockfile, resolver, build,
and feature crates all share these typed values without
depending on each other.
The crate must:
- not depend on
clap; - not parse TOML or any other on-disk format;
- not know about Ninja, the build graph, the resolver, the lockfile, or any registry / index transport;
- not invoke processes;
- stay reusable by client / server / shared tooling alike.
Generic filesystem helper policy lives in cabin-fs; cabin-core
stays focused on typed domain models and pure logic.
cabin-manifest
Owns cabin.toml parsing. Raw serde structs are private to the crate
and converted to cabin-core domain types at the boundary. The crate
must:
- not load workspaces or follow path dependencies;
- not run dependency resolution;
- not write Ninja;
- not read or write
cabin.lock.
cabin-workspace
Owns local package and workspace loading: workspace member globbing,
recursive local path-dep traversal, dedup-by-canonical-path, duplicate
name detection, package cycle detection, and topological ordering.
Versioned dependencies are preserved on each Package for the
resolver but are intentionally not traversed here. Current
invariants:
- Workspace discovery walks upward from the start path looking
for a
cabin.tomlwhose root declares a[workspace]table. With zero or one such manifest the walk returns it (orNone); with two or more stacked roots the walk errors with anested workspace detecteddiagnostic so the caller is forced to disambiguate via--manifest-path. [workspace]expansion supportsmembers,exclude, anddefault-members, plus workspace dependency inheritance viadep = { workspace = true }.- A
PackageSelectionmodel turns CLI flags into a deterministic list of selected packages.ResolvedSelection::closure(graph)andcollect_closure_versioned_deps(graph, closure)extend that selection over local path-dep edges so commands scoped to one member can still see the registry deps of the path-deps they pull in. - Workspace loading exposes two registry-aware entry points:
load_workspace_with_registry(strict — every versioned dep in the workspace must be resolved) andload_workspace_with_registry_for_selection(manifest, registry, strict_packages). The selection-aware variant is what the CLI calls when the user has scoped a command to a subset of the workspace: registry entries are required only for packages reachable from the selected closure, so unrelated workspace members' versioned deps are silently skipped during loading rather than being materialized into the package graph. cabin_core::is_path_safe_package_nameis the single authoritativePackageNamegrammar: ASCII alphanumerics plus_-., non-empty, not./.., no leading dot. It covers filesystem path components, sparse-HTTP URL path segments, and Windows-reserved filename characters in one rule, and is enforced byPackageName::newso URL-reserved characters cannot reachUrl::jointhrough any code path. The diagnostic emitted on rejection echoes the offending name and describes the grammar.
The crate must:
- not run the resolver or any other resolver algorithm;
- not write Ninja;
- not fetch artifacts;
- not parse CLI flags (the CLI builds
PackageSelectionvalues); - own every workspace graph algorithm (closure walks,
versioned-dep aggregation, nested-workspace detection) — none
of these may live in
cabin.
cabin-index
Owns the local-filesystem JSON package index format and its loader. The crate must:
- not run the resolver;
- not fetch artifacts;
- not read or write
cabin.lock.
The sparse HTTP read path lives in cabin-index-http; cabin-index
holds the local filesystem loader. Both feed the same typed index
model, so downstream crates consume one shape regardless of transport.
cabin-resolver
Owns dependency resolution. Cabin's resolver uses PubGrub internally,
while exposing Cabin-owned resolver inputs, outputs, and diagnostics
(ResolveInput, ResolveOutput, ResolvedPackage, ResolvedSource,
LockedVersion, ResolveMode, ResolveError, ResolverConstraint);
the PubGrub crate is an implementation detail and never appears in the
crate's public types. A private adapter translates semver::VersionReq
into PubGrub's Ranges<semver::Version>, implements
DependencyProvider against cabin_index::PackageIndex, and handles
yanked filtering, locked-mode preferences, optional / conditional
edges, and candidate ordering.
ResolveError implements miette::Diagnostic directly so dependency
resolution failures are rendered through Cabin's miette-based
diagnostics layer. Lockfile errors stay specific — the resolver
preserves LockfileMissingPackage, LockedVersionMissing,
LockedVersionYanked, LockedVersionViolatesConstraint, and
LockedChecksumMismatch so users can tell whether to update the
lockfile, fix constraints, or investigate a checksum mismatch.
Conflict cases collapse PubGrub's derivation tree into a
human-readable explanation embedded in
ResolveError::Conflict { package, detail }. The stable diagnostic
code [cabin_diagnostics::code::RESOLVER_ERROR] is attached to every
variant.
The crate must:
- not expose PubGrub types in its public API;
- not read or write
cabin.lockdirectly (the CLI bridgescabin-lockfileandcabin-resolver); - not fetch artifacts;
- not render diagnostics itself (rendering lives in
cabin-diagnostics).
cabin-lockfile
Owns the cabin.lock model and I/O: TOML serialization, deterministic
ordering, schema validation. The crate must:
- not run the resolver;
- not load indexes;
- not parse
cabin.toml; - not fetch artifacts;
- not write Ninja.
cabin-artifact
Owns the source-archive cache. Given a checksum-and-path-bearing
fetch plan, it copies archives into a checksum-addressed cache,
verifies SHA-256 along the way, safely extracts .tar.gz archives
into the same cache, and validates that each extracted package's
cabin.toml matches the resolved name and version. The crate must:
- not run the resolver;
- not write Ninja;
- not invoke C/C++ compilers;
- not implement networking;
- not implement publishing;
- reject every tar entry that is not a regular file or directory,
every entry with
..components or absolute paths, and every entry whose joined destination escapes the cache target.
The lexical path-safety predicates that back the rejection above
come from cabin-fs. Archive-specific extraction policy — allowed
tar entry types, GNU/PAX metadata handling, declared strip_prefix
matching, decompressed-size caps, and partial-file cleanup — stays
in this crate.
cabin-package
Owns deterministic source-archive creation and canonical per-version
metadata generation. Given a single-package manifest, it validates
the source tree, walks it under a fixed include / exclude policy,
writes a byte-deterministic .tar.gz, hashes it with SHA-256, and
emits a JSON metadata document shaped like a future registry's
<package>.json version entry. The crate must:
- not mutate any registry;
- not run the resolver;
- not fetch artifacts;
- not invoke C/C++ compilers;
- not implement networking;
- reject path dependencies (path deps are not publishable);
- refuse to overwrite an on-disk archive whose bytes differ from what the current run would produce — identical bytes succeed silently.
cabin-index-http
Owns the read-only sparse HTTP index client. Wraps ureq::Agent
for blocking GET requests; its public surface is intentionally
small:
- [
cabin_index_http::HttpClient] —get_bytesanddownloadhelpers that map HTTP statuses (404,5xx) and transport errors toIndexHttpErrorvariants; - [
cabin_index_http::HttpIndex] — opens a registry by fetching<base>/config.json, validates it, exposesfetch_package(name) -> IndexEntryand a transitive walkerload_package_index(roots) -> PackageIndexthat returns the same shape as the local file loader.
The crate must:
- not POST, PUT, or otherwise mutate a remote registry;
- not implement authentication, auth headers, or alternate-server redirect handling;
- reject HTTP artifact URLs that resolve outside the package
metadata origin or contain
userinfocredentials; - not persist a metadata cache (
--frozenwith an effective HTTP index URL therefore fails with a documented error message — there is no offline HTTP path); - never reach into HTTP from the artifact layer — downloaded
archive bytes are handed to
cabin-artifactvia [FetchSource::InMemoryArchive] so checksum verification + safe extraction stay HTTP-free.
cabin-port
Owns the foundation-port recipe layer: parsing port.toml,
the checksum-addressed port cache, and the source-preparation
pipeline that turns a pinned upstream archive plus an overlay
manifest into a directory the workspace loader treats as a
normal path dependency. The crate must:
- never reach into HTTP — like
cabin-artifact, it accepts archive bytes via a typedPortFetchSource(LocalArchive / InMemoryArchive); the HTTP path lives incabin's orchestration layer; - never reimplement extraction safety. Decompression-bomb
caps, symlink rejection, and path-traversal protection
belong to
cabin-artifact::safe_extract_tar_gz;cabin-portcalls into it with the declaredstrip_prefixbut does not duplicate the security rules.
Foundation ports are local development policy, not published
metadata: cabin-package rejects port deps in its validator
and cabin-publish never archives them. See
foundation-ports.md for the policy,
the schema, and the zlib milestone.
cabin-publish
Owns publish-workflow orchestration. Combines cabin-package's
[stage] entry point with cabin-registry-file's atomic writers to
publish a single-package source tree into a local file registry.
The crate must:
- not implement HTTP / sparse / OCI publish;
- not implement server-side functionality;
- keep registry mutation in
cabin-registry-file; - keep dry-run distinct from actual mutation — the dry-run path is a no-op against any registry;
- return a clear error when invoked without
--dry-runand without--registry-dir.
cabin-registry-file
Owns the local file-registry layout and the atomic writes that
keep partially-written state from sticking around. Given a
[cabin_package::StagedPackage] plus a registry root, it:
- creates the registry layout (
config.json,packages/,artifacts/) on first publish; - validates
config.json(schema = 1,kind = "file-registry", no..inpackages/artifacts); - detects duplicate versions and orphaned artifacts before any bytes are written;
- places the artifact and updates the per-package index file via atomic write + rename, rolling back the artifact if the index update fails;
- guards concurrent runs with a simple
<registry>/.cabin-registry.locklock file (best-effort — recovery from a crashed publisher is out of scope today); - never parses arbitrary
cabin.tomls, runs the resolver, builds packages, or implements networking.
cabin-toolchain
Owns toolchain resolution, subprocess-based compiler / archiver detection, compiler-cache wrapper resolution, and Ninja lookup. The crate must:
- not parse TOML;
- not run dependency resolution;
- not read or write
cabin.lock; - not compile probe sources or write build plans.
cabin-build
Owns backend-independent build planning: PackageGraph plus
ResolvedToolchain, per-package build flags, and build settings
become a BuildGraph of compile / archive / link actions. The
crate must:
- not write Ninja syntax or any other backend's syntax;
- not invoke Ninja;
- not parse TOML directly.
cabin-test
Owns the test plan and the sequential test runner used by
cabin test. Given a finished
[cabin_build::BuildGraph] and the originating
[cabin_workspace::PackageGraph], it:
- builds a deterministic [
cabin_test::TestPlan] of everytesttarget whose linked executable appears in the graph's default outputs; - runs each executable sequentially via [
cabin_test::run_tests], capturing stdout / stderr through a [cabin_test::TestOutputSink] trait; - returns a typed [
cabin_test::TestSummary] (totals, per-test status) plus stable rendering helpers (render_summary_line,render_result_line,render_running_line).
The crate must:
- not parse manifests, plan builds, or resolve dependencies;
- not generate Ninja or invoke
ninja; - not know about config / patches / source replacement;
- not introduce parallel test execution, in-binary test discovery, or test-framework output parsing — those are documented limitations of the current model.
cabin/src/test_glue.rs orchestrates cabin test by
driving the existing build pipeline and handing the resulting
BuildGraph to this crate.
cabin-ninja
Owns Ninja file generation and Clang-compatible
compile_commands.json generation. The crate must:
- not parse TOML;
- not resolve packages;
- not know about the resolver or the lockfile.
cabin-explain
Typed model for cabin tree and cabin explain. Consumes the
already-loaded PackageGraph, optional Lockfile, optional
ActivePatchSet, and the merged SourceReplacementSettings,
and produces:
TreeNodeforests (withSourceProvenance-tagged nodes, edge-kind labels, deduplicated repeats, deterministic sorting), rendered either as a Unicode-drawing tree or a structured JSON document;Explanationvalues (Package,Target,Source,Feature) plus a typed entry point forBuildConfigthat reusesBuildConfiguration::as_jsonso metadata and explain agree on the same shape.
The crate must:
- not run the resolver, parse manifests, or plan builds;
- not perform I/O — the orchestration layer hands it typed inputs;
- not invent new identity for packages: provenance comes from
PackageKind, the lockfile, the active patch set, and the source-replacement table.
cabin's tree_glue.rs and explain_glue.rs modules are
the orchestration layer that loads workspace + lockfile +
patches + source-replacements + (for build-config) the full
profile / toolchain / build-flags preamble, then hands the
typed values to cabin-explain. Domain logic lives in
cabin-explain; CLI glue stays thin.
cabin-env
Single home for every CABIN_* environment variable name. Read-
side names are pub const &str; the run/test side provides one
typed builder, package_env, returning the deterministic six-
key overlay (CABIN_MANIFEST_DIR, CABIN_MANIFEST_PATH,
CABIN_PACKAGE_NAME, CABIN_PACKAGE_VERSION, CABIN_PROFILE,
CABIN_BUILD_DIR) that cabin run and cabin test apply on
top of the user's environment, plus parse_bool for read-side
boolean env vars.
The crate must:
- not run processes;
- not read configuration files or touch the filesystem;
- not depend on
cabin,cabin-build, or other higher- level crates that would create cyclic dependencies.
Adding a new CABIN_* env var requires extending this crate's
constants list (and the doc page) so every consumer of the
name agrees byte-for-byte.
cabin-source-discovery
Shared C / C++ source / header walker used by cabin fmt
and cabin tidy. Consumes a typed
SourceDiscoveryRequest (roots, excluded paths, excluded
directories, VCS-ignore policy), honors .gitignore /
.ignore via the ignore crate, skips a fixed built-in
exclude list (.git, build / cache / vendor directories), and
returns DiscoveredSourceFile values sorted by absolute path
so output is byte-stable across platforms.
The crate must:
- not own command construction or executable resolution — that belongs to the matching tool runner crate;
- not read Cabin's configuration files — the orchestration layer threads any config-derived inputs through the typed request;
- not classify Cabin's notion of "compilable source" beyond the per-extension grammar documented in the module head.
cabin-fmt
clang-format runner consumed by cabin fmt. Owns formatter
executable resolution (CABIN_FMT override, otherwise
clang-format on PATH), the clang-format command-line
shape, and the typed FormatRequest / FormatReport boundary.
Modes: Write (-i in place) and Check (--dry-run -Werror,
no rewrites).
The crate must:
- not walk the filesystem looking for sources — that is
cabin-source-discovery's job; - not read Cabin's configuration files — the orchestration
layer threads any config-derived inputs through the typed
FormatRequest.
cabin-tidy
run-clang-tidy runner consumed by cabin tidy. Owns tidy
executable resolution (CABIN_TIDY), the run-clang-tidy
command-line shape, typed jobs forwarding (-j from
cabin-core::BuildJobs), and the fix-mode safety clamp
(--fix forces jobs to 1 to avoid concurrent rewrites). The
compile database the tool consumes is produced by cabin build
through cabin-ninja::compile_commands; this crate never
generates one.
The crate must:
- not walk the filesystem —
cabin-source-discoverydoes that; - not plan builds or generate compile databases — those are
cabin-build's andcabin-ninja's jobs; - not read Cabin's configuration files —
.clang-tidydiscovery remains clang-tidy's responsibility.
cabin-system-deps
pkg-config runner consumed when a workspace declares
`system = true deps. Owns executable resolution
(CABIN_PKG_CONFIG),pkg-configcommand-line construction,
and the typedSystemDependencyProbeRequest/SystemDependencyProbeReportboundary. Probes only fire when at
least one selected primary package declares an active system
dependency; dependency manifests outside that primary set
preserve declarations but do not spawnpkg-config. The
orchestration layer merges the resolved cflags / libs intoResolvedProfileFlagsso they reachbuild.ninjaandcompile_commands.json` in deterministic order.
The crate must:
- not read Cabin's configuration files;
- not walk the filesystem or generate the build graph;
- not assume any specific
pkg-configimplementation (the POSIX-style command-line surface is the contract).
cabin-fs
Small filesystem helpers shared by Cabin's production crates. Currently provides atomic file replacement and lexical path-safety predicates; intentionally narrow rather than a broad filesystem abstraction.
- Atomic replacement stages bytes in a sibling temporary file and commits with a rename only after the write succeeds, so an interrupted run leaves any previous contents of the destination intact.
- The lexical path-safety predicates reason over path components
only — they reject absolute paths,
..traversal, root components, and Windows path prefixes — and are safe to call on paths that do not yet exist. - The helpers do not canonicalize, follow symlinks, read the filesystem, create parent directories, or enforce archive-, registry-, or config-specific policy. Callers own parent-directory creation.
- Domain-specific error mapping stays with each consumer so the
destination path and user-facing context remain in the
surfaced diagnostic.
cabin-lockfilemaps write failures toLockfileError,cabin-ninjatoNinjaError,cabin-packagescaffold writes toScaffoldError,cabin-artifactextraction and path-safety failures toArtifactError, andcabin-portunsafe recipe paths toPortError.
The crate must not own:
- manifest parsing;
- config-file discovery;
- XDG base-directory resolution;
- registry layout;
- the package archive format;
- archive extraction policy (that lives in
cabin-artifact); - resolver behavior;
- CLI behavior;
- diagnostics rendering;
- shell or Ninja escaping;
- recursive copy / sync abstractions unless a future focused change justifies one.
cabin-diagnostics
User-facing diagnostic presentation for Cabin's typed domain
errors. Owns the stable diagnostic-code registry
(cabin::workspace::manifest_not_found,
cabin::manifest::parse_error, …), the deterministic
formatter, and the annotate-snippets boundary used to draw
source-annotated snippets for parse / validation errors.
Depends only on miette, annotate-snippets, and
thiserror. Domain crates that own source spans (today:
cabin-manifest's ManifestParseError) depend on miette
for the Diagnostic derive and pass the typed value up; the
CLI orchestrator (cabin) routes it through
cabin_diagnostics::render so the user sees a stable
error[code]: message block plus optional help: text and
snippet.
The crate must:
- not depend on
cabin,cabin-build, or other higher- level crates that would create cyclic dependencies; - not run processes, read configuration files, or touch the filesystem — the renderer takes typed inputs and produces a string;
- emit byte-stable output (no terminal color, no Unicode-only flourishes that vary with terminal capabilities).
Adding a new diagnostic-bearing error is a three-step pattern:
- derive
miette::Diagnosticon the error type, attach a#[diagnostic(code(cabin::<area>::<symbol>))]attribute, addhelp(...)when there is an actionable next step; - for source-annotated cases, expose
#[source_code]/#[label]socabin-diagnostics::render_with_snippetpicks the values up automatically; - extend
cabin/src/main.rs::downcast_diagnosticso the typed error participates in the renderer.
cabin
Owns CLI flags and user-facing command orchestration. May call any other crate. Should keep clap-driven argument parsing separate from command execution where practical, and must not contain business logic that belongs in a reusable crate.
cabin/src/cli.rs must not grow further with new business
logic. When new behavior lands, the implementation belongs in
the owning crate (e.g.
cabin-workspace for workspace algorithms, cabin-resolver for
resolution, cabin-build for build planning, cabin-publish
for publish orchestration), exposed through a typed API; the CLI
layer should only translate clap inputs into that API and render
the result. This invariant is enforced socially through review:
PRs that add non-trivial command logic, helpers, or types to
cli.rs must move them into either the owning crate or a new
per-command module under cabin/src/cli/ (one file per
top-level subcommand) before they can land. A small,
behavior-preserving split of view structs or dispatch helpers
into a private module is acceptable inside a routine PR; a broad
rewrite of cli.rs is not in scope for a routine change.
Data flow — implemented today
Dependency kinds end-to-end
Every Cabin dependency is classified into one of two kinds via
cabin_core::DependencyKind:
Normal -> Dev (Cabin package dependency kinds)
Any entry in those tables can additionally set system = true to
mark it as externally provided; system-flagged entries route to a
separate system_dependencies collection and never enter the
resolver / fetcher / build pipeline.
The kind information flows through the system at each layer:
[dependencies] ----+
[dev-dependencies] ----+--> cabin_manifest (typed BTreeMaps + system_dependencies
-> ManifestError vec; entries with `system = true`
route to the system vec, others to the
per-kind dep map. Both also fold
[target.'cfg(...)'.<kind>] in with an
optional Condition predicate.)
|
v
cabin_core::Package
dependencies: Vec<Dependency> // kind on every entry
system_dependencies: Vec<SystemDependency>
|
v
cabin_workspace::Package
deps: Vec<DependencyEdge { index, kind }>
|
v
+------------------+--+--+----------------------+
| | |
v v v
collect_closure cabin-build target cabin-package
_versioned_deps dep resolution canonical metadata
(Normal-only) (Normal-only edges) (per-kind tables +
-> ResolveInput for `target.<X>.deps`) system-dependencies)
|
v
cabin_resolver (resolver never sees Dev or System)
|
v
cabin.lock + artifact cache
(kind metadata is intentionally not duplicated here —
the resolver re-decides included kinds on each run)
System dependencies branch off at the manifest layer and never
enter the resolver / fetcher / cache. Dev dependencies flow
through Package::dependencies for metadata round-tripping but
are filtered out at the collect_closure_versioned_deps
boundary and at the workspace path-dep traversal so they do
not affect ordinary builds.
Workspace inheritance is kind-specific: a member's
{ workspace = true } opt-in under [<kind>-dependencies] looks
up the matching [workspace.<kind>-dependencies] table only —
there is no cross-kind fallback. workspace = true is also
rejected inside [target.'cfg(...)'.<kind>] tables so a single
workspace key cannot silently mean different things on different
hosts.
Conditional dependencies declared via [target.'cfg(...)'.<kind>]
travel the same path. Each Dependency / SystemDependency /
DependencyEdge carries an optional
condition: Option<Condition> field. The host TargetPlatform
filters out non-matching declarations at three boundaries: the
workspace loader skips them when building path-dep edges, the
closure walker skips them in
collect_closure_versioned_deps_filtered, and the feature
resolver skips them when expanding dep: and per-edge feature
requests. The resolver also skips conditional IndexPackageDependency
entries on registry packages. The condition itself is
preserved on Package::dependencies and round-trips through
PackageMetadata and the index loaders, so cabin metadata
can surface inactive declarations without losing the predicate
text. Full protocol in
target-dependencies.md.
Manifest parsing
cabin.toml --> cabin_manifest::parse_manifest_str / load_manifest
|
v
ParsedManifest (private serde structs already shed)
|
v
cabin_core::Package + WorkspaceTable
Workspace loading
cwd or --manifest-path --> cabin_workspace::discover_workspace_root
| upward walk for cabin.toml with [workspace]
| (no walk when --manifest-path is explicit)
v
workspace root manifest
|
v
cabin_workspace::load_workspace
| member globbing
| exclude filtering
| default-members validation
| workspace.dependencies inheritance
| nested-workspace rejection
| recursive local path-dep traversal
| dedup, cycle, name-collision checks
v
PackageGraph (topologically sorted)
- root_package: Option<usize>
- primary_packages: Vec<usize>
- default_members: Vec<usize>
- excluded_members: Vec<PathBuf>
- packages: Vec<WorkspacePackage { package, manifest_path }>
Versioned dependencies are kept on each Package but are not
traversed here. CLI workspace flags (--workspace, -p / --package,
--exclude, --default-members) flow through
cabin_workspace::resolve_package_selection, which validates the
request against the loaded PackageGraph and returns the
deterministic ordered list of selected primary-package indices the
downstream commands (build / metadata / package / publish / fetch)
operate on.
Local build planning + Ninja generation
PackageGraph + ResolvedToolchain --> cabin_build::plan(PlanRequest)
+ build flags / settings | cycle detection
| cross-package target resolution
| language-specific compile dispatch
v
BuildGraph (Vec<Action>, CompileCommand[])
|
v
cabin_ninja::write_build_ninja --> build.ninja
cabin_ninja::write_compile_commands --> compile_commands.json
|
v
`ninja -C <build_dir>` (cabin)
Local index resolution
<index>/<package>.json files --> cabin_index::load_index
| per-file schema validation
| filename / name agreement
| SemVer of every version
v
PackageIndex
|
ResolveInput (root package + versioned deps + locked map + mode)
|
v
cabin_resolver::resolve
|
v
ResolveOutput { packages: [Root, Index, ...] }
Lockfile-aware resolution
cabin.toml --> PackageGraph
|
cabin.lock --> cabin_lockfile::read_lockfile --> Lockfile
|
v
LockedVersion entries
|
v
ResolveInput { mode, locked }
|
v
cabin_resolver::resolve
|
v
ResolveOutput
|
v PackageIndex meta
\ /
||
Lockfile (rebuilt)
|
+-- write to <manifest_dir>/cabin.lock
| if the mode permits writing
v
human / json output (cabin)
The resolver receives LockedVersion values constructed by the CLI
from a Lockfile. The resolver never reads the lockfile itself; the
lockfile crate never runs the solver. They meet only inside
cabin.
| Mode | Locked map effect | Writes lockfile |
|---|---|---|
PreferLocked (default cabin resolve) |
Tries the locked version first; falls back to newest compatible if locked no longer satisfies constraints. | yes |
Locked (cabin resolve --locked / --frozen) |
Restricts each candidate set to [locked.version]; surfaces precise errors when missing / yanked / constraint-violating / checksum-mismatched. |
no |
UpdateAll (cabin update) |
Ignores the locked map entirely. | yes |
UpdatePackage(name) (cabin update --package <name>) |
Drops just one entry from the locked map. | yes |
Once the artifact cache is involved, --frozen becomes
operationally distinct from --locked: both forbid writing the
lockfile, but --frozen additionally forbids the artifact cache
from being populated. Already-cached and already-extracted
artifacts may still be reused.
Artifact fetch + registry-aware build
ResolveOutput + PackageIndex
|
| cabin builds a FetchPlan: per resolved registry package,
| pull `source.path` + `checksum` straight off the index entry.
|
v
cabin_artifact::fetch
| for each entry:
| - hash the cached archive; reuse if it already matches;
| - else (and not --frozen) copy from source.path while
| hashing, fail on checksum mismatch;
| - extract safely into <cache>/sources/sha256/<hex>/;
| - validate <source>/cabin.toml name + version.
v
FetchResult { packages: [FetchedPackage { name, version, archive_path,
source_dir, checksum }] }
|
v
cabin_workspace::load_workspace_with_registry(manifest, fetched)
| walk root + every extracted source manifest;
| versioned dependencies resolve via the registry map by name;
| return a unified PackageGraph (Local + Registry packages).
v
cabin_build::plan + cabin_ninja::write_* --> build.ninja + ninja
The artifact crate never runs the resolver or invokes the C/C++ toolchain. The workspace crate never verifies checksums. The CLI is the only place where these layers meet.
Package archive + canonical metadata
cabin.toml
|
| cabin_manifest::load_manifest
v
ParsedManifest -> Package
|
| cabin_package::validate (no path deps, no escaping sources)
v
ValidatedPackage
|
| cabin_package::archive::collect_package_files
| - sorted, fixed include / exclude policy
| - regular files and directories only
v
[PackageFile, ...]
|
| cabin_package::archive::build_tar_gz
| - tar entries: mtime/uid/gid/uname/gname zeroed, mode 0o644
| - gzip header: mtime = 0, OS = 0xff (unknown)
v
archive bytes (Vec<u8>) ---> sha256_hex ---> sha256:<hex>
|
| cabin_package::canonical_metadata
v
PackageMetadata { schema, name, version, dependencies,
yanked, checksum, source }
|
| cabin_package::package writes both files into --output-dir
v
dist/<name>-<version>.tar.gz
dist/<name>-<version>.json
cabin-publish::dry_run calls into the same pipeline and returns a
DryRunReport whose registry_modified flag is always false. No
registry, no network, no server is involved in the dry-run flow.
The canonical metadata's source block matches the existing
index source shape (type = "archive", format = "tar.gz",
path = "../artifacts/<name>/<name>-<version>.tar.gz").
Local file-registry publish
cabin.toml
|
| cabin_package::stage (no disk write)
v
StagedPackage { name, version, archive_bytes, checksum, metadata }
|
| cabin_publish::publish_to_file_registry
v
cabin_registry_file::publish_to_registry
|
| RegistryLock::acquire(<registry>/.cabin-registry.lock)
| FileRegistry::open_or_initialize (writes config.json on first run)
|
| Read the existing packages/<name>.json (if any), validate name,
| reject duplicate versions and orphaned artifacts.
|
| Phase 1: write artifact through `atomic-write-file` (sibling
| temp + rename)
| Phase 2: write packages/<name>.json the same way; on failure,
| delete the just-placed artifact so the registry
| never carries an orphan.
|
| RegistryLock::drop (lock file removed)
v
RegistryPublishReport
{
registry_dir, package_index_path, artifact_path,
checksum, source_path, registry_modified, registry_initialized
}
cabin_publish::dry_run_against_file_registry runs the same
validation (FileRegistry::inspect + the duplicate / orphan
checks) without acquiring a lock or writing anything; the
registry_modified flag in the returned report is always false.
The registry written by this flow lands at:
<registry>/
config.json
packages/<name>.json
artifacts/<name>/<name>-<version>.tar.gz
cabin-index::load_index detects config.json and reads packages
out of the configured packages/ subdirectory, so the same path
that publish wrote to is consumable by cabin resolve,
cabin fetch, and cabin build --index-path without any
repackaging step.
Sparse HTTP index read path
--index-url http://host/registry
|
| cabin_index_http::HttpIndex::open
| GET <base>/config.json -> RegistryConfig
v
HttpIndex { base, config, packages_base, client }
|
| cabin_index_http::HttpIndex::load_package_index(roots)
| BFS over (root deps + transitive):
| GET <base>/<config.packages>/<name>.json
| Each `<name>.json` is parsed via
| `cabin_index::parse_package_entry` with a `SourceContext::HttpUrl`
| closure, so `source.path` resolves to an absolute URL using
| RFC 3986 relative resolution against the package metadata URL,
| then must remain on that package metadata origin.
v
PackageIndex { packages: BTreeMap<PackageName, IndexEntry> } (same shape as the local file loader)
|
v
cabin_resolver::resolve
|
v
ResolveOutput
|
| cabin::build_fetch_plan(output, index, IndexAccess::Http(client))
| For each registry-source package:
| - LocalPath → FetchSource::LocalArchive(path) (file index)
| - HttpUrl → http_client.download(url) → FetchSource::InMemoryArchive(bytes)
v
cabin_artifact::fetch
| Same checksum + cache + extraction as the local-file path:
| bytes are hashed against the index's sha256, written into
| <cache>/archives/sha256/<hex>.tar.gz, and extracted into
| <cache>/sources/sha256/<hex>/.
v
FetchedPackage { archive_path, source_dir, checksum }
The HTTP path is read-only. There is no persistent metadata
cache, so --frozen with an effective HTTP index URL fails with
a documented error message. --locked --index-url works because
the lockfile is on disk locally and the resolver can validate
fetched metadata against it.
Architectural seams to preserve
- Raw TOML serde structs stay private to
cabin-manifest. claponly appears incabin.- The stable domain model lives in
cabin-core. - Workspace loading and resolver are independent: the workspace loader emits unresolved versioned dependencies; the resolver consumes them.
- Build graph IR is backend-independent. Ninja serialization lives in a separate crate.
- Index format and resolver are independent: the index crate produces data; the resolver consumes it.
- Lockfile I/O and the resolver are independent:
cabin-resolverreceivesLockedVersionvalues, notLockfileitself. - The underlying solver type is never exposed from
cabin-resolver.
C++ semantic invariants
Cabin's resolver and lockfile are Cargo-shaped on purpose, but the build graph the resolver feeds into is not. The list below states the C++-specific invariants Cabin's build planning maintains today, so future contributors do not silently regress them by porting more Cargo-like assumptions:
-
Public vs. private include directories. Header reachability is part of a
librarytarget's interface, not a free-floating workspace property. A target'sinclude_dirsare public: every consumer of the target inherits them transitively. Sources that exist only to compile the library must live undersources/ internal subdirectories that the public include path does not expose. There is noprivate_include_dirsfield today; adding one is a deliberate language change, not a build-graph fix-up. -
Link interface propagation. A
librarytarget propagates its public link interface (the link line consumers must add) to every direct and transitive dependent automatically. Build-time link-only deps (linker libraries that are not Cabin packages) are still represented assystem-dependencies; active declarations are probed throughpkg-config, and the resulting flags are wired into consumers that link the producing target. Cabin does not model CMake'sINTERFACE/PUBLIC/PRIVATEdistinction at the package boundary, and the resolver intentionally does not re-implement the C++ link-order rules — Ninja + the linker do. -
Header-only is its own kind. Header-only libraries are modeled as the dedicated
header_onlykind: they declareinclude_dirsand nosources, so the build graph emits no compile or archive actions and the link interface stays purely include-dir + system deps. Declaringsourceson aheader_onlytarget is rejected at manifest-load time. -
Patch/override targets a name, not a target inside it.
[patch] foo = { path = "../foo" }replaces the entire package namedfoo. There is no per-target patch surface. Consumers resolve targets the same way they would for the registry version offoo; the patched manifest must keep target names stable for consumers to keep building. -
Dev-only targets are scoped to dev commands.
testandexamplelink as ordinary executables but are excluded from the defaultcabin buildenumeration.testtargets are built and run bycabin test, which selects every test target in the selected packages.exampletargets reach the build graph only as transitive deps of a selected target — Cabin does not yet expose a single-example selector flag, because the historic--targetoverload has been removed and the flag name is reserved for the future platform/toolchain target. A future explicit-kind selector (--example <name>) may land later under a distinct flag name. Dependencies of dev-only targets follow the sametarget.<X>.depsrules as anexecutable: include and link interfaces propagate from the libraries they pull in, but the dev-only targets never contribute include or link interface back to ordinary production targets. -
[dev-dependencies]activate per-package, not transitively.cabin testactivates the[dev-dependencies]of the selected primary packages so test executables can link against test-only packages. The activation does not propagate: a transitive dep's own dev-deps stay declaration-only even undercabin test.cabin buildcontinues to ignore every dev-dep, so production builds are unaffected.
These invariants are normative: a change that breaks one of them is a language / build-system change and needs an explicit design update, not an implementation tweak.
Implemented layers — quick reference
The crate boundaries above stay aligned with the responsibilities listed here. Each item names the crate that owns the layer today; future transports / modes should be added to the named crate rather than carved out into ad-hoc places.
Artifact layer
A content-addressed cache and source / archive fetcher that turns
a locked package set into actual on-disk source trees, verifying
checksums recorded in cabin.lock. Implemented as
cabin-artifact for local filesystem .tar.gz archives. Future
transports (OCI / Git) may be added without changing the cache
shape.
Package / archive layer
Source-archive creation for publishable packages. Pure local
operation: take a package directory, produce a deterministic
archive plus a per-version metadata digest. Implemented as
cabin-package. The archive contract matches the extractor: a
.tar.gz whose root contains cabin.toml, regular files and
directories only.
File registry publish layer
Local file-registry publish path that drops a freshly created
package archive plus updated <package>.json index entries into
a directory. No network, no auth, no server. Implemented as
cabin-registry-file with atomic rename writes via
atomic-write-file and a simple .cabin-registry.lock lock file.
Sparse HTTP index / artifact client
Read-path client for fetching <package>.json and tarballs over
HTTP from a static layout. Implemented as cabin-index-http. The
on-disk index format and the transport stay separate by design:
local file reading lives in cabin-index, HTTP reading in
cabin-index-http, and they emit the same
cabin_index::PackageIndex / IndexEntry shape so the resolver
and lockfile layers stay HTTP-free.
Features — implemented (foundation)
Public additive named-boolean capabilities the user (or a downstream consumer) selects at build time.
What ships today: manifest declarations ([features]),
cabin-core's BuildConfiguration value with a deterministic
SHA-256 fingerprint, CLI selection flags
(--features / --all-features / --no-default-features), and
round-trip preservation through cabin package and cabin publish
--registry-dir. Older index entries that omit the field continue
to load. Full protocol in features.md.
Optional dependencies and per-edge feature requests,
target-cfg dependencies, and build profiles all layer on top of
the same surface; toolchain conditional flags are documented in
toolchains.md. Target / platform-specific
dependencies are documented in
target-dependencies.md; build
profiles are documented in profiles.md.
Build profiles — implemented
Named build-configuration presets (dev, release, plus any
custom [profile.<name>] declarations the manifest adds).
Resolution lives entirely in cabin-core::profile:
ProfileSelection (the user's pick) plus a typed definition
table go through resolve_profile, which walks inherits
chains, detects cycles, applies built-in defaults under
manifest overrides, and returns a fully-typed
ResolvedProfile { name, debug, opt_level, assertions, source,
inherits_chain }.
[profile.<name>] ----> cabin_manifest (typed ProfileDefinition;
rejects unsupported fields)
|
v
ProfileSelection ----> cabin_core::resolve_profile
| (cycle / unknown-parent / built-in
v `inherits` rejection)
ResolvedProfile
|
+------------------+----------------------+----------------------+
v v v
cabin_build (compile flags, BuildConfiguration cabin_metadata
per-profile output dir) fingerprint JSON view
Profile selection does not affect dependency resolution, the
lockfile, the package archive, the index, or registry behavior;
those remain profile-independent by design. Output paths are
profile-segregated (<build-dir>/<profile>/...) so dev / release
/ custom builds never overwrite each other; the build-
configuration fingerprint includes the resolved profile so a
cache layer can key on it. Full protocol in
profiles.md.
Toolchain selection and build flags — implemented
Explicit, typed C/C++ toolchain selection plus a small set of
semantic [profile] flags. cabin-core::toolchain owns the data
model (ToolKind, ToolSpec, ToolSource, ToolSelection,
ResolvedTool, ResolvedToolchain, ToolchainSettings);
cabin-core::build_flags owns the parallel flag model
(ProfileFlags, ConditionalProfileFlags,
ProfileSettings, ResolvedProfileFlags, the
resolve_build_flags merge); cabin-toolchain::resolve walks
precedence (CLI ▶ env ▶ matching
[target.'cfg(...)'.toolchain] ▶ [toolchain] ▶ default
fallback list) per kind, searches PATH, and rejects
unsupported MSVC executables. The build planner consumes a
ResolvedToolchain directly for compile / link / archive
commands and a per-package ResolvedProfileFlags map to layer
-D / -I / extra args onto each target.
[toolchain] ----+
+--> cabin_manifest (typed ToolchainDecl /
[target.'cfg'.toolchain] ProfileFlags;
unknown fields rejected)
[profile] |
[target.'cfg'.profile] --------------+
|
v
ToolchainSelection ----> cabin_toolchain::resolve_toolchain
(CLI > env > [target.'cfg(...)'.toolchain]
> [toolchain] > defaults; PATH search;
cl.exe / link.exe rejection)
|
v
ResolvedToolchain + ResolvedProfileFlags
| (per-package)
v
+------------------+----------------------+----------------------+
v v v
cabin_build (compile flags, BuildConfiguration cabin_metadata
per-package -D/-I, fingerprint JSON view
extra-{compile,link}-args, (toolchain + flags)
archive command)
Manifest-declared [toolchain] and [profile] tables round-trip
through PackageMetadata and the index loaders; environment- or
CLI-derived selections are never published. Registry resolution
remains toolchain- and build-flag-independent. Full protocol in
toolchains.md.
Compiler / tool capability detection — implemented
After cabin-toolchain::resolve_toolchain returns a
ResolvedToolchain, cabin-toolchain::detect_toolchain runs
each picked tool with --version, captures the output, and
hands it to the pure parsers in cabin-core::compiler. The
result is a typed ToolchainDetectionReport carrying a
CompilerIdentity / ArchiverIdentity (kind, version, target
where reported) and a CompilerCapabilities /
ArchiverCapabilities set per tool. Capability decisions
record their source (version, assumed-default,
unsupported) so consumers can audit the inference chain.
ResolvedToolchain ----+
+----> cabin_toolchain::detect_toolchain
(ToolRunner trait;
ProcessRunner spawns
`tool --version`;
parsers in cabin_core::compiler)
|
v
ToolchainDetectionReport
|
+------------------------------------+
v v
cabin_build::validate_toolchain_for_backend cabin MetadataView
(rejects MSVC cl.exe, lib.exe, (toolchain.detected)
unknown compilers without GCC-style
flags, archivers without ar crs)
Recognized compiler families: clang, apple-clang, gcc.
MSVC (cl.exe) and the lib.exe archiver are detected —
cabin metadata reports their kind and version — but
cabin build rejects them with a clear unsupported-backend
error rather than emitting GCC-style commands they cannot run.
Unknown compilers are conservative: capabilities default to
unsupported, and the build flow rejects them when the planner
needs GCC-style flags.
Detection results are deliberately not serialized into package or index metadata. They are local-environment state and would create reproducibility problems if they leaked into a published archive. The build configuration fingerprint is unaffected because the planner still emits the same fixed command shapes whether the detected compiler is Clang 17 or GCC 13. Cabin does not yet emit JSON or SARIF diagnostics; capabilities for those formats are detected only so a future diagnostics layer can read them without re-parsing version output.
Why probing is deferred: a probe-based capability layer would
require staging temporary translation units, deciding their
content, and interpreting non-zero exit codes — a much larger
change than the parser-only path Cabin uses today. Adding it is
straightforward when needed: CapabilitySource::Probe is
already part of the typed model, and ToolRunner has a
single-method surface, so a future probe runner can plug in
without rewriting consumers.
Full protocol in toolchains.md.
Patch / override / source replacement
A typed local-policy layer that swaps registry-resolved package candidates for local working copies (patches) and redirects one supported index source to another (source replacement).
[patch] manifest [patch] table (workspace root only)
fmt = ... ┌───────────────────────────────────────────┐
▼ │
cabin_workspace::resolve_active_patches │
├── walks user → workspace → project → │
│ explicit config patches and overlays │
│ the manifest patch with config wins │
├── validates path exists / cabin.toml / │
│ name match / version satisfies every │
│ active dep requirement │
├── canonicalizes paths │
▼ │
[patch] ActivePatchSet (sorted by name) ──────┘
fmt = ...
in EffectiveConfig
│
│ cabin_workspace::load_workspace_with_registry_and_patches
▼ stitches each patched manifest as kind = Local
[PackageGraph augmented with patched packages]
│
│ cabin filters patched names from the
│ versioned-dep closure / artifact pipeline so
│ patched packages never re-fetch from a registry
▼
Build / metadata / lockfile / publish
Source replacement is config-only and lives next to patches in
EffectiveConfig:
[source-replacement] cabin_core::SourceReplacementSettings::resolve()
"https://example.com/index" ┌────────────────────────────────────────────────┐
= { index-path = "../mirror" } ▼ │
walk one hop at a time, detect cycles, │
reject credentials in URLs, only swap │
between existing IndexPath / IndexUrl kinds │
│
│
resolved index source ── feeds cabin's artifact pipeline ──────────────────┘
and the lockfile's [[source-replacement]] array
cabin's patch_glue module owns the orchestration glue:
typed inputs in, typed values out, no business logic in
cli.rs. The lockfile gains optional [[patch]] and
[[source-replacement]] arrays (default-empty so old lockfiles
remain valid), and --locked errors if the recorded arrays
differ from the active policy. cabin metadata adds two
top-level arrays (patches, source_replacements) for
deterministic auditability.
Local override policy never enters published artifacts:
cabin-package rejects manifests with a non-empty [patch]
table; .cabin/config.toml (which carries config patches +
source replacement) is already in EXCLUDED_DIR_NAMES. Git
sources, vendoring, registry authentication, HTTP publish, new
registry protocols, and registry-server work all remain
deferred.
Full protocol in patch-overrides.md.
Configuration files
Cabin reads typed TOML configuration files for local policy — defaults the user, the workspace, or a single project want to apply across many invocations. Config sits between the manifest (which is package source spec) and the CLI / environment so existing per-command flags keep their highest precedence.
cabin-config (a new crate) owns the entire surface:
[user, workspace/project, explicit]
config files
|
v
cabin_config::discover_config_files
├── env-driven discovery: CABIN_NO_CONFIG / CABIN_CONFIG /
│ CABIN_CONFIG_HOME, falling back to the
│ xdg-resolved user config home with the
│ `cabin` application prefix
├── deny_unknown_fields parsing of [registry] / [paths] /
│ [profile] / [profile.cache] / [toolchain] (private serde shape)
├── reject [target.'cfg(...)'.<...>] tables, auth/token/
│ credentials/registries tables, registry index-path/url
│ conflicts, empty / invalid values
v
cabin_config::merge_loaded_files
├── lower-priority files first, higher overrides per field
├── attaches every effective value to its ConfigSource
v
EffectiveConfig
├── registry source (with file provenance)
├── paths.cache_dir/build_dir (resolved relative to the config file)
├── build.profile (string, validated against the
│ project's profile definitions)
├── compiler_wrapper (CompilerWrapperRequest)
└── toolchain.cc/cxx/ar (ToolSpec)
cabin orchestrates only — cabin/src/config_glue.rs
maps EffectiveConfig into the typed layers the existing
resolvers consume:
cabin_toolchain::ConfigToolchainLayerslots between the env variable and the manifest incabin_toolchain::resolve_toolchain.cabin_toolchain::ConfigWrapperLayerslots between theCABIN_COMPILER_WRAPPERenv variable and the manifest incabin_toolchain::resolve_compiler_wrapper.- The build-side helpers (
profile_selection_for_build,resolve_index_source,resolve_cache_dir,resolve_build_dir) consult the sameEffectiveConfigand return a typed value plus itsConfigValueSource.
The metadata view emits a top-level config block with the
loaded files plus every effective config-derived setting, paired
with its value_source so consumers can audit provenance without
re-running discovery. BuildConfiguration::fingerprint already
covers the per-tool spec + wrapper kind / version, so a config
that picks clang++ or ccache produces a different fingerprint
than one that does not — without the config layer needing to
emit anything new into the hash.
Local config never enters package archives, the canonical per-version metadata, the lockfile, or the registry index:
cabin-package::archive::EXCLUDED_DIR_NAMESalready filters the.cabin/directory out of deterministic source archives.cabin-package::metadataandcabin-indexconsumePackagevalues fromcabin-core, which never contain config-derived fields.cabin-publishdoes not read config for authentication.
Auth tokens and new registry protocols are deliberately out of
scope; cabin-config's parser rejects auth/credential/token
keys with a dedicated error message so a typo cannot smuggle a
secret into a published archive. Source replacement and
vendoring are implemented local-policy/read-path features; they
remain excluded from package archives, canonical package
metadata, and registry authentication. Full protocol in
config.md.
Compiler-cache wrappers
Cabin can prefix C++ compile commands with ccache or sccache.
The wrapper sits on top of the resolved toolchain — it does not
replace the compiler driver, and it never wraps link / archive
commands.
cabin-core::compiler_wrapper owns the typed model (CompilerWrapperKind,
CompilerWrapperRequest, CompilerWrapperManifestSettings,
ConditionalCompilerWrapperDecl, CompilerWrapperSource,
CompilerWrapperIdentity, ResolvedCompilerWrapper,
CompilerWrapperSummary) plus a pure parser for none /
ccache / sccache. cabin-toolchain::wrapper::resolve_compiler_wrapper
walks the precedence ladder and returns an Option<ResolvedCompilerWrapper>,
reusing the same EnvLookup / ExecutableProbe / ToolRunner
abstractions as the rest of the toolchain layer:
[ToolchainSelectionArgs]
--compiler-wrapper / --no-compiler-wrapper
|
v
CompilerWrapperRequest -+
|
CABIN_COMPILER_WRAPPER -+--> cabin_toolchain::resolve_compiler_wrapper
| (CLI > env > config [build.cache]
[target.'cfg'.profile.cache]+ > [target.'cfg(...)'.profile.cache]
[profile.cache] ----+ > manifest [profile.cache]; PATH search;
| optional `--version` probe)
|
v
Option<ResolvedCompilerWrapper>
|
v
cabin_build::PlanRequest.compiler_wrapper
|
+-----------------+-----------------+
v v
build.ninja: prefixed `ccache cxx ...` compile_commands.json:
(Ninja invokes the wrapped command) unchanged `cxx ...` (clangd
keeps seeing the underlying
compiler)
cabin metadata surfaces the resolved wrapper under
toolchain.compiler_wrapper. The build-configuration fingerprint
folds in the wrapper kind / spec / version, so a cache layer keys
on whether ccache is present and which version is in use.
Workspace member manifests that declare any cache settings are
rejected with MemberDeclaresCompilerWrapper, mirroring the
existing profile / toolchain rules so a single cabin build
invocation cannot silently apply different wrapper choices to
different packages. Manifest-declared cache settings round-trip
through cabin package; CLI / env-derived selections never do.
Full protocol in compiler-cache.md.
Non-Local Registry Control Planes
This repository implements the local registry interface: local file
registries, package archives, and a read-only sparse HTTP client for a
static layout. Account systems, hosted write paths, ownership workflows,
package yanking, signing policy, and administrative control planes are
outside this local-core boundary. See
registry-design.md for the concrete read-path
and file-registry shape this repository supports.
Scope and limitations
Cabin is pre-1.0 and intentionally focused on the local OSS package-manager-and-build-system core. The following are not part of this repository today:
- No Git dependencies. A Git-backed registry index is
intentionally never planned; see
registry-design.md. Source registries are local file directories or sparse HTTP today. - No non-local registry control plane. Every command that needs
an index expects
--index-path <dir>or--index-url <url>. There is no default remote registry, nocabin login, and no package upload over the network. - No account / ownership workflows. Ownership, signing, package yanking, and restricted package access are out of scope.
- No administrative policy surfaces.
- No remote / binary build cache. The artifact cache stores source archives only.
- No compile-server wrapper integration.
ccacheandsccacheare the supported compiler-cache wrappers; distcc, icecc, and other distributed compile-server wrappers are out of scope. - No full Windows / MSVC support. CI runs on Linux and macOS; Windows is best-effort. C/C++ on Windows works as far as Ninja and the configured toolchain allow but is not a supported configuration.
- No workspace-level profile or toolchain overrides beyond the documented root-owned settings. Member manifests cannot carry root-only build policy, and workspace-level profile/toolchain expansion beyond the current model is out of scope.
- Not a CMake / Meson drop-in replacement. Cabin does not
consume
CMakeLists.txtormeson.buildfiles. Existing CMake / Meson projects cannot be migrated without rewriting the build description ascabin.toml. - No shared-library linkage model. The current build model is based on executables, static archives, header-only libraries, and system-library link flags; broad shared library generation / ABI policy is out of scope.
- No lockfile capture of resolved build configurations. The lockfile records dependency and local-override state, not profile / toolchain / environment-derived build configuration fingerprints.
- No C++ modules, no generated-source bindings. Header-generation
tools (
cxx,autocxx,bindgen) and the C++ modules build flow are out of scope. - No cross-compilation.
--target <triple>is reserved for the future cross-compilation flow.
Per-feature limitations live with each feature page (for example
targets.md, profiles.md).
Contributor-facing architecture guardrails
The architecture document is the canonical source for crate
boundaries, ownership rules, and scope limits. CONTRIBUTING.md
points here rather than restating those rules. If code moves across
crate boundaries, update this document and AGENTS.md in the same
change.
Architecture-sensitive behavior changes should add focused unit
coverage in the owning crate and CLI integration coverage when the
behavior is user-facing. Observable output used by tooling or tests
must stay deterministic: workspace selections, generated Ninja,
compile_commands.json, metadata / tree / explain JSON, package
archives, lockfiles, and registry files should sort or normalize
their output explicitly.
Tests must not require external network access. Network protocol
tests boot an in-process server on 127.0.0.1:0 and point Cabin at
that server. CLI integration tests use the shared cabin() helper
to scrub process environment variables Cabin reads; tests that
exercise config discovery opt back in through the documented
cabin_with_config() helper. The full portability rules live in
testing.md.
Why a separate lockfile crate?
cabin-lockfile and cabin-resolver solve unrelated problems:
- Lockfile I/O: TOML serialization, deterministic ordering, schema validation. Pure data, no algorithms.
- Resolution: constraint satisfaction over an index. Algorithmic.
Keeping them apart means the artifact layer can hash into
cabin.lock without churning the resolver, and a future resolver
algorithm change can land in cabin-resolver without touching the
lockfile crate.
Why a separate build graph IR?
Same reasoning as the lockfile split: the build graph in cabin-build
is a small, dumb data structure on purpose so future backends (a
direct in-process executor, a remote-cache hook, a Bazel-style
exporter) can consume the same shape without reaching into Ninja
specifics.