cabin/cli/mod.rs
1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use clap::{Args, Parser, Subcommand};
6
7use cabin_artifact::{ArtifactCache, FetchEntry, FetchOptions, FetchPlan, FetchedPackage};
8use cabin_build::{PlanRequest, plan};
9use cabin_core::PackageName;
10use cabin_index::PackageIndex;
11use cabin_lockfile::{LockedPackage, LockedSource, Lockfile};
12use cabin_package::scaffold;
13use cabin_resolver::{LockedVersion, ResolveInput, ResolveMode, ResolveOutput, ResolvedSource};
14use cabin_workspace::{PackageGraph, RegistryPackageSource, collect_patched_versioned_deps};
15
16use crate::completions::CompgenArgs;
17use crate::manpages::MangenArgs;
18
19pub(crate) mod add;
20pub(crate) mod build_prep;
21pub(crate) mod config;
22pub(crate) mod env_flags;
23pub(crate) mod explain;
24pub(crate) mod fetch_output;
25pub(crate) mod fmt;
26pub(crate) mod metadata;
27pub(crate) mod ninja;
28pub(crate) mod patch;
29pub(crate) mod port;
30pub(crate) mod remove;
31pub(crate) mod run;
32pub(crate) mod source_tooling;
33pub(crate) mod system_deps;
34pub(crate) mod term_color;
35pub(crate) mod term_verbosity;
36pub(crate) mod test;
37pub(crate) mod tidy;
38pub(crate) mod tree;
39pub(crate) mod vendor;
40pub(crate) mod version;
41
42mod build;
43mod clean;
44mod init;
45mod manifest_edit;
46mod package;
47mod resolve;
48
49use self::build::{BuildMode, build};
50use self::clean::clean;
51use self::init::{init, new};
52use self::package::{package, publish};
53use self::resolve::{fetch, resolve, update};
54
55use crate::cli::fetch_output::emit_fetch_output;
56use crate::cli::term_color::CliColorChoice;
57use crate::cli::term_verbosity::Reporter;
58
59/// Cargo-style color palette for clap's help / error
60/// rendering. Mirrors the ANSI sequences `cargo --help
61/// --color always` emits today: bold + bright green for the
62/// section headings and the `Usage:` line, bold + bright cyan
63/// for literal tokens (the binary name, flag and subcommand
64/// names), plain cyan for value placeholders such as
65/// `<NAME>` / `[OPTIONS]`, bold + bright red for `error:`
66/// labels, and bold + yellow for the highlighted-invalid
67/// token inside diagnostic messages.
68fn cli_styles() -> clap::builder::Styles {
69 use clap::builder::styling::{AnsiColor, Color, Style};
70
71 let header_usage = Style::new()
72 .bold()
73 .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)));
74 let literal = Style::new()
75 .bold()
76 .fg_color(Some(Color::Ansi(AnsiColor::BrightCyan)));
77 let placeholder = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
78 let error = Style::new()
79 .bold()
80 .fg_color(Some(Color::Ansi(AnsiColor::BrightRed)));
81 let invalid = Style::new()
82 .bold()
83 .fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
84 let valid = Style::new()
85 .bold()
86 .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)));
87
88 clap::builder::Styles::styled()
89 .usage(header_usage)
90 .header(header_usage)
91 .literal(literal)
92 .placeholder(placeholder)
93 .error(error)
94 .invalid(invalid)
95 .valid(valid)
96}
97
98/// Top-level help template — mirrors `cargo --help`:
99///
100/// - the `Options:` block comes before the `Commands:` block
101/// so the short list of global flags is on screen first;
102/// - the section headings (`Options:`, `Commands:`) carry the
103/// same bold + bright-green styling clap applies to
104/// `Usage:`. The embedded ANSI escapes are stripped by
105/// anstream when color is disabled (`--color never`,
106/// `NO_COLOR`, or a non-TTY stdout).
107///
108/// `{options}` renders the options block body only. The
109/// subcommand block is omitted because the default `[aliases:
110/// x]` rendering does not match cargo's `name, alias` style;
111/// the dispatcher in `lib.rs::run` rebuilds the subcommand
112/// rows manually and feeds them in via `after_help`.
113const HELP_TEMPLATE: &str = concat!(
114 "{about-with-newline}\n",
115 "{usage-heading} {usage}\n",
116 "\n",
117 // Bold + bright green, like clap's auto `Usage:` style.
118 "\x1b[1m\x1b[92mOptions:\x1b[0m\n",
119 "{options}",
120 "{after-help}",
121);
122
123/// Top-level Cabin CLI parser.
124#[derive(Debug, Parser)]
125#[command(
126 name = "cabin",
127 about = "A package manager and build system for C/C++",
128 disable_version_flag = true,
129 styles = cli_styles(),
130 help_template = HELP_TEMPLATE,
131 // Compact, cargo-style option rows: keep the description
132 // inline with the flag name rather than dropping it to
133 // its own line for every entry.
134 next_line_help = false,
135)]
136pub struct Cli {
137 /// Use verbose output (-vv very verbose output).
138 //
139 // `ArgAction::Count` collects repeated `-v` occurrences;
140 // counts of two or more clamp to `Verbosity::VeryVerbose`.
141 #[arg(
142 short = 'v',
143 long = "verbose",
144 global = true,
145 action = clap::ArgAction::Count,
146 conflicts_with = "quiet",
147 display_order = 1,
148 )]
149 pub(crate) verbose: u8,
150
151 /// Do not print cabin log messages.
152 #[arg(
153 short = 'q',
154 long = "quiet",
155 global = true,
156 conflicts_with = "verbose",
157 display_order = 2
158 )]
159 pub(crate) quiet: bool,
160
161 /// Coloring: auto, always, never [default: auto]
162 //
163 // Single-line rustdoc keeps `cabin --help` compact. The
164 // literal "[default: auto]" is part of the description
165 // because clap does not render a `default_value` for
166 // `Option<...>` enum flags.
167 //
168 // Precedence is `--color` > `CABIN_TERM_COLOR` >
169 // `[term] color` config > `auto`; see
170 // `docs/environment-variables.md` for the full table.
171 #[arg(
172 long,
173 value_name = "WHEN",
174 value_enum,
175 global = true,
176 hide_possible_values = true,
177 display_order = 3
178 )]
179 pub(crate) color: Option<CliColorChoice>,
180
181 /// List installed commands.
182 //
183 // The dispatcher short-circuits on this flag before
184 // touching `cli.command`, so combining it with a
185 // subcommand silently ignores the subcommand. The flag
186 // intentionally co-exists with global flags like
187 // `--color` so `cabin --color always --list` renders the
188 // listing with the requested color treatment.
189 #[arg(long, display_order = 4)]
190 pub(crate) list: bool,
191
192 /// Print version info and exit.
193 //
194 // Replaces clap's auto `--version` so the flag can route
195 // through `cabin version`'s dispatcher: `cabin --version`
196 // prints the concise line and `cabin --version --verbose`
197 // prints the same key/value block `cabin version -v`
198 // emits. Display order keeps the `-h, -V` pair adjacent.
199 #[arg(
200 short = 'V',
201 long = "version",
202 global = true,
203 action = clap::ArgAction::SetTrue,
204 display_order = 6,
205 )]
206 pub(crate) version: bool,
207
208 // The subcommand is `Option<...>` so `cabin --list` and
209 // `cabin --version` keep working without one. The
210 // dispatcher prints the curated help and exits cleanly when
211 // both `--list` is unset and `command` is `None`.
212 #[command(subcommand)]
213 pub(crate) command: Option<Command>,
214}
215
216// `cabin --help` is the curated, day-to-day surface and
217// closely mirrors `cargo --help`. Subcommands tagged
218// `#[command(hide = true)]` below stay fully functional but
219// surface only through `cabin --list`, `cabin <sub> --help`,
220// shell completions, and per-subcommand man pages.
221//
222// Curation pattern (matching cargo --help):
223// - hide inspection-only commands (`metadata`, `tree`,
224// `explain`) — useful for scripts / CI, rarely typed
225// day-to-day;
226// - hide low-level / scripting commands (`resolve`) —
227// `cabin metadata` and `cabin update` are the user-facing
228// paths;
229// - hide offline / networking helpers (`fetch`, `vendor`) —
230// triggered automatically when needed;
231// - hide pre-publish packaging (`package`) — `publish` is
232// the user-facing entry;
233// - hide distribution helpers (`compgen`, `mangen`) — aimed
234// at downstream packagers.
235//
236// `version` stays visible because it is a direct user-facing
237// command; `cabin --version` and `cabin version`
238// agree on the concise wording.
239// Each subcommand's rustdoc has two paragraphs: the first is
240// the short summary clap renders in `cabin --help` / `cabin
241// --list`, and the rest becomes the long help shown by `cabin
242// <sub> --help`. The split keeps the top-level surface
243// skimmable while preserving the existing detailed prose.
244#[derive(Debug, Subcommand)]
245pub(crate) enum Command {
246 /// Create a new cabin package in an existing directory.
247 Init(InitArgs),
248 /// Create a new cabin package.
249 ///
250 /// Scaffolds a new package at `<PATH>`. The directory must
251 /// not already exist.
252 New(NewArgs),
253 /// Add a dependency to a cabin.toml manifest file.
254 ///
255 /// Edits the manifest in place, preserving its existing
256 /// formatting and comments. Use `--port <name>` to add a bundled
257 /// foundation port or `--path <dir>` to add a local package;
258 /// registry dependencies are not supported yet.
259 Add(crate::cli::add::AddArgs),
260 /// Remove a dependency from a cabin.toml manifest file.
261 ///
262 /// Deletes the named `[dependencies]` (or `[dev-dependencies]`,
263 /// with `--dev`) entry, leaving the rest of the manifest intact.
264 Remove(crate::cli::remove::RemoveArgs),
265 /// Output workspace metadata as JSON.
266 ///
267 /// Prints the loaded workspace graph, selected build
268 /// configuration view, and lockfile state (if any) in
269 /// machine-readable form. Use this for tooling / scripts;
270 /// the human-facing inspection commands are `cabin tree`
271 /// and `cabin explain`.
272 #[command(hide = true)]
273 Metadata(ManifestArgs),
274 /// Compile a local package and all of its dependencies.
275 ///
276 /// Plans the build, writes `build.ninja` plus a
277 /// Clang-compatible `compile_commands.json`, and invokes
278 /// Ninja.
279 //
280 // `visible_alias = "b"` matches cargo's `build, b`
281 // rendering: clap auto-renders the alias next to the
282 // canonical name in `cabin --help` / `cabin --list`, and
283 // `cabin b` is parsed identically to `cabin build`.
284 #[command(visible_alias = "b")]
285 Build(BuildArgs),
286 /// Check a local package and its dependencies for errors.
287 ///
288 /// Type-checks the workspace's C/C++ sources with the compiler's
289 /// `-fsyntax-only` mode, reusing the same build graph as
290 /// `cabin build` but skipping code generation, archiving, and
291 /// linking. No object files or binaries are produced. Faster than
292 /// a full build for catching compile errors.
293 Check(BuildArgs),
294 /// Remove the built directory.
295 ///
296 /// Deletes Cabin-generated build artifacts under the
297 /// resolved `--build-dir`. Source files are never
298 /// touched.
299 Clean(CleanArgs),
300 /// Run a binary of the local package.
301 ///
302 /// Builds the selected `executable` target and executes
303 /// it. Arguments after `--` are forwarded verbatim to the
304 /// executed program.
305 #[command(visible_alias = "r")]
306 Run(crate::cli::run::RunArgs),
307 /// Run the tests of a local package.
308 ///
309 /// Builds the workspace's `test` targets and executes
310 /// each one with a deterministic per-test `CABIN_*`
311 /// environment overlay.
312 #[command(visible_alias = "t")]
313 Test(crate::cli::test::TestArgs),
314 /// Resolve versioned dependencies.
315 ///
316 /// Resolves the manifest's versioned dependencies against
317 /// a local JSON package index and prints the result.
318 /// Most users prefer `cabin metadata` or `cabin update`.
319 #[command(hide = true)]
320 Resolve(ResolveArgs),
321 /// Update dependencies as recorded in `cabin.lock`.
322 Update(UpdateArgs),
323 /// Fetch registry dependencies into the artifact cache.
324 ///
325 /// Fetches, verifies, and extracts the source archives of
326 /// resolved registry dependencies. Triggered
327 /// automatically by `cabin build`, `cabin run`, and
328 /// `cabin test`; use this command to warm the cache.
329 #[command(hide = true)]
330 Fetch(FetchArgs),
331 /// Vendor external versioned dependencies locally.
332 ///
333 /// Materializes the selected external registry dependency
334 /// closure into a deterministic local file-registry directory
335 /// for offline use. Local path dependencies stay local.
336 /// Combine with `--offline --index-path <vendor-dir>` on
337 /// subsequent commands.
338 #[command(hide = true)]
339 Vendor(crate::cli::vendor::VendorArgs),
340 /// Display the dependency tree.
341 ///
342 /// Renders the loaded workspace / local-path dependency
343 /// graph as a tree (human or JSON). Workspace, feature,
344 /// kind-filter, and patch flags affect this view; option and
345 /// variant selectors are build-configuration inputs and do
346 /// not change the tree.
347 #[command(hide = true)]
348 Tree(crate::cli::tree::TreeArgs),
349 /// Explain a loaded package, target, source, or feature.
350 ///
351 /// Package, target, source, and feature subcommands map to
352 /// the typed explanation model in `cabin-explain`.
353 /// `build-config` reuses the same resolved configuration
354 /// shape as `cabin metadata`.
355 #[command(hide = true)]
356 Explain(crate::cli::explain::ExplainArgs),
357 /// Assemble the local package into a distributable archive.
358 ///
359 /// Builds a deterministic source archive plus canonical
360 /// metadata for the package at `--manifest-path`.
361 /// Typically driven by `cabin publish`.
362 #[command(hide = true)]
363 Package(PackageArgs),
364 /// Publish a package to a local file registry.
365 ///
366 /// With `--registry-dir <PATH>`, writes the archive plus
367 /// canonical metadata into a Cabin file registry. With
368 /// `--dry-run` alone, stages the same artifacts under
369 /// `--output-dir` without touching any registry. Remote
370 /// registry protocols are not supported.
371 Publish(PublishArgs),
372 /// Format codes using clang-format.
373 ///
374 /// Walks the workspace's C/C++ sources and rewrites
375 /// them in place using the user's `clang-format`.
376 Fmt(crate::cli::fmt::FmtArgs),
377 /// Run clang-tidy.
378 ///
379 /// Drives `run-clang-tidy` over the workspace's C/C++
380 /// sources using the generated `compile_commands.json`.
381 Tidy(crate::cli::tidy::TidyArgs),
382 /// List or inspect bundled foundation-port recipes.
383 Port(crate::port_subcommand::PortArgs),
384 /// Generate shell completion scripts for the `cabin` CLI.
385 #[command(hide = true)]
386 Compgen(CompgenArgs),
387 /// Generate man pages for the `cabin` CLI.
388 #[command(hide = true)]
389 Mangen(MangenArgs),
390 /// Show version information.
391 ///
392 /// Without flags, prints the concise release name (same
393 /// wording as `cabin --version`). With `-v` /
394 /// `--verbose`, prints a stable key/value block describing
395 /// the build (`release`, `commit-hash`, `commit-date`,
396 /// `host`, `os`); rows whose underlying value is unknown
397 /// are omitted.
398 Version(crate::cli::version::VersionArgs),
399}
400
401#[derive(Debug, Args)]
402pub(crate) struct InitArgs {
403 /// Package name. Defaults to the current directory name.
404 #[arg(long)]
405 pub name: Option<String>,
406
407 /// Use a binary (application) template [default].
408 ///
409 /// Conflicts with `--lib`.
410 #[arg(short = 'b', long, group = "init_scaffold_kind")]
411 pub bin: bool,
412
413 /// Use a library template.
414 ///
415 /// Conflicts with `--bin`.
416 #[arg(short = 'l', long, group = "init_scaffold_kind")]
417 pub lib: bool,
418}
419
420#[derive(Debug, Args)]
421pub(crate) struct NewArgs {
422 /// Path of the new package directory. The directory must not already exist.
423 #[arg(value_name = "PATH")]
424 pub path: PathBuf,
425
426 /// Package name. Defaults to the final component of `<PATH>`.
427 #[arg(long)]
428 pub name: Option<String>,
429
430 /// Use a binary (application) template [default].
431 ///
432 /// Conflicts with `--lib`.
433 #[arg(short = 'b', long, group = "new_scaffold_kind")]
434 pub bin: bool,
435
436 /// Use a library template.
437 ///
438 /// Conflicts with `--bin`.
439 #[arg(short = 'l', long, group = "new_scaffold_kind")]
440 pub lib: bool,
441}
442
443#[derive(Debug, Args)]
444pub(crate) struct CleanArgs {
445 /// Path to the cabin.toml manifest. Same precedence rules
446 /// as `cabin build`.
447 #[arg(long, value_name = "PATH")]
448 pub manifest_path: Option<PathBuf>,
449
450 /// Build output directory. Same precedence rules as
451 /// `cabin build`: `--build-dir` > `CABIN_BUILD_DIR` >
452 /// `[paths] build-dir` config setting > built-in default
453 /// `build`.
454 #[arg(long, value_name = "PATH")]
455 pub build_dir: Option<PathBuf>,
456
457 /// Compatibility alias for `--profile release`. Cannot be
458 /// used together with `--profile`.
459 #[arg(long, conflicts_with = "profile")]
460 pub release: bool,
461
462 /// Limit the clean to the named build profile. Without this
463 /// flag every known profile sub-tree is in scope.
464 #[arg(long, value_name = "NAME")]
465 pub profile: Option<String>,
466
467 /// Print the deletion plan without removing anything. Output
468 /// lists the paths that would be removed in deterministic
469 /// order.
470 #[arg(long)]
471 pub dry_run: bool,
472
473 /// Workspace package-selection flags.
474 #[command(flatten)]
475 pub workspace_selection: WorkspaceSelectionArgs,
476}
477
478#[derive(Debug, Args)]
479pub(crate) struct ManifestArgs {
480 /// Path to the cabin.toml manifest. May be a single-package manifest
481 /// or a workspace root.
482 #[arg(long, value_name = "PATH")]
483 pub manifest_path: Option<PathBuf>,
484
485 /// Feature selection flags. Empty by default. When any
486 /// selection flag is passed, `cabin metadata --format json`
487 /// adds a `configuration` block to each primary package
488 /// describing the resolved configuration.
489 #[command(flatten)]
490 pub selection: ConfigSelectionArgs,
491
492 /// Workspace package-selection flags. The metadata view
493 /// always reports every loaded package; selection flags only
494 /// narrow the `selected_packages` list.
495 #[command(flatten)]
496 pub workspace_selection: WorkspaceSelectionArgs,
497
498 /// Output format. `human` is a readable summary; `json`
499 /// produces a machine-parseable document. Defaults to `json`
500 /// for back-compat with scripts that pipe the metadata output
501 /// into `jq`.
502 #[arg(long, value_name = "FORMAT", default_value = "json")]
503 pub format: ResolveFormat,
504
505 /// Profile to evaluate for the metadata view. Defaults to
506 /// `dev`. The view always lists every available profile in
507 /// the `profiles.available` array regardless of which one is
508 /// selected.
509 #[arg(long, value_name = "NAME")]
510 pub profile: Option<String>,
511
512 /// Toolchain-selection flags. Same precedence rules as
513 /// `cabin build` so the metadata view reflects exactly the
514 /// toolchain a build would use.
515 #[command(flatten)]
516 pub toolchain: ToolchainSelectionArgs,
517
518 /// Disable every active patch and source-replacement entry
519 /// for this invocation. Manifest `[patch]` tables and
520 /// config `[patch]` / `[source-replacement]` declarations
521 /// are ignored; ordinary `path = "..."` dependency edges
522 /// and dependency declarations stay active.
523 #[arg(long)]
524 pub no_patches: bool,
525
526 /// Forbid network access. `cabin metadata` rejects an HTTP
527 /// `--index-url` (or a `[registry] index-url` in the active
528 /// config) when this flag is set so the metadata view stays
529 /// fully local.
530 #[arg(long)]
531 pub offline: bool,
532}
533
534#[derive(Debug, Args)]
535pub(crate) struct BuildArgs {
536 /// Path to the cabin.toml manifest.
537 #[arg(long, value_name = "PATH")]
538 pub manifest_path: Option<PathBuf>,
539
540 /// Directory for build outputs (build.ninja, object files, binaries).
541 /// Defaults to `build/`; a config-provided `paths.build-dir`
542 /// overrides this default.
543 #[arg(long, value_name = "PATH")]
544 pub build_dir: Option<PathBuf>,
545
546 /// Build with optimizations.
547 ///
548 /// Use release flags (-O3 -DNDEBUG) instead of debug flags
549 /// (-g -O0). Compatibility alias for `--profile release`;
550 /// cannot be used together with `--profile`.
551 #[arg(short = 'r', long, conflicts_with = "profile")]
552 pub release: bool,
553
554 /// Select the build profile (`dev`, `release`, or any custom
555 /// profile declared in `[profile.<name>]`). Defaults to `dev`.
556 /// Mutually exclusive with `--release`.
557 #[arg(long, value_name = "NAME")]
558 pub profile: Option<String>,
559
560 /// Path to a directory containing the local JSON package index.
561 /// Required when the manifest declares any versioned dependencies
562 /// and `--index-url` is not given. Mutually exclusive with
563 /// `--index-url`.
564 #[arg(long, value_name = "PATH")]
565 pub index_path: Option<PathBuf>,
566
567 /// Sparse HTTP index URL to read package metadata from.
568 /// Mutually exclusive with `--index-path`. Static sparse HTTP
569 /// serving of the file-registry layout is supported
570 /// (`<url>/config.json`, `<url>/packages/<name>.json`).
571 #[arg(long, value_name = "URL")]
572 pub index_url: Option<String>,
573
574 /// Override the default artifact cache directory.
575 #[arg(long, value_name = "PATH")]
576 pub cache_dir: Option<PathBuf>,
577
578 /// Require an existing, current `cabin.lock`. Resolution is not
579 /// allowed to choose any version that differs from the lockfile.
580 #[arg(long, conflicts_with = "frozen")]
581 pub locked: bool,
582
583 /// Like `--locked`, but also rejects state-writing side effects:
584 /// The lockfile must not change and the artifact cache will not be
585 /// populated. Already-cached artifacts may be reused.
586 #[arg(long)]
587 pub frozen: bool,
588
589 /// Forbid network access. Cabin refuses to use an HTTP index URL
590 /// (`--index-url` or a `[registry] index-url` config setting) and
591 /// expects every needed artifact to be available from a local
592 /// index (`--index-path`) or already in the artifact cache.
593 /// Combine with `cabin vendor` to consume a self-contained vendor
594 /// directory.
595 #[arg(long)]
596 pub offline: bool,
597
598 /// Enable named features. May be passed multiple times; values
599 /// may also be comma-separated (`--features simd,ssl`). The
600 /// selection applies to the root package being built.
601 #[arg(long, value_name = "FEATURES")]
602 pub features: Vec<String>,
603
604 /// Enable every feature declared by the root package. Combines
605 /// with `--features` (the union is the same as `--all-features`)
606 /// and overrides `--no-default-features`.
607 #[arg(long)]
608 pub all_features: bool,
609
610 /// Disable the package's default features. Without this flag, the
611 /// names listed under `[features].default` are enabled.
612 #[arg(long)]
613 pub no_default_features: bool,
614
615 /// Workspace package-selection flags.
616 #[command(flatten)]
617 pub workspace_selection: WorkspaceSelectionArgs,
618
619 /// Toolchain-selection flags. Each flag (when supplied)
620 /// overrides any `CC`/`CXX`/`AR` environment variable and
621 /// any `[toolchain]` table in the workspace root manifest.
622 #[command(flatten)]
623 pub toolchain: ToolchainSelectionArgs,
624
625 /// Disable every active patch and source-replacement entry
626 /// for this invocation. See `docs/patch-overrides.md`.
627 #[arg(long)]
628 pub no_patches: bool,
629
630 /// Number of parallel jobs to use for building.
631 ///
632 /// Precedence: this flag > `CABIN_BUILD_JOBS` env var >
633 /// `[build] jobs` config setting > backend default. The
634 /// value must be a positive integer; `0` is rejected.
635 #[arg(short = 'j', long = "jobs", value_name = "N")]
636 pub jobs: Option<cabin_core::BuildJobs>,
637}
638
639/// Toolchain-selection flag bundle shared by `cabin build` and
640/// `cabin metadata`. Each flag accepts either a bare command name
641/// (`clang++`, resolved against `PATH`) or an explicit path
642/// (`/opt/llvm/bin/clang++`).
643#[derive(Debug, Args, Default)]
644pub(crate) struct ToolchainSelectionArgs {
645 /// Override the C compiler. Accepts a bare command name or a
646 /// path. Highest precedence — also overrides `CC` and
647 /// `[toolchain].cc`.
648 #[arg(long, value_name = "PATH-OR-NAME")]
649 pub cc: Option<String>,
650
651 /// Override the C++ compiler. Accepts a bare command name or
652 /// a path. Highest precedence — also overrides `CXX` and
653 /// `[toolchain].cxx`.
654 #[arg(long, value_name = "PATH-OR-NAME")]
655 pub cxx: Option<String>,
656
657 /// Override the static-library archiver. Accepts a bare
658 /// command name or a path. Highest precedence — also
659 /// overrides `AR` and `[toolchain].ar`.
660 #[arg(long, value_name = "PATH-OR-NAME")]
661 pub ar: Option<String>,
662
663 /// Select a compiler-cache wrapper that prefixes every C++
664 /// compile command. Accepts `none`, `ccache`, or `sccache`.
665 /// Highest precedence — also overrides
666 /// `CABIN_COMPILER_WRAPPER`, config `[build.cache]`, and
667 /// any manifest `[profile.cache]` or
668 /// `[target.'cfg(...)'.profile.cache]` declaration.
669 /// Mutually exclusive with `--no-compiler-wrapper`.
670 #[arg(long, value_name = "WRAPPER", conflicts_with = "no_compiler_wrapper")]
671 pub compiler_wrapper: Option<String>,
672
673 /// Disable the compiler-cache wrapper for this invocation,
674 /// regardless of any environment variable or manifest
675 /// declaration. Equivalent to `--compiler-wrapper none` but
676 /// shorter to type. Mutually exclusive with
677 /// `--compiler-wrapper`.
678 #[arg(long)]
679 pub no_compiler_wrapper: bool,
680}
681
682/// Selection-flag bundle shared by `cabin build` and `cabin metadata`.
683#[derive(Debug, Args, Default)]
684pub(crate) struct ConfigSelectionArgs {
685 /// Enable named features. May be repeated and/or comma-separated.
686 #[arg(long, value_name = "FEATURES")]
687 pub features: Vec<String>,
688
689 /// Enable every declared feature.
690 #[arg(long)]
691 pub all_features: bool,
692
693 /// Disable default features.
694 #[arg(long)]
695 pub no_default_features: bool,
696}
697
698/// Workspace selection flags for `cabin update`.
699///
700/// `cabin update` reserves `--package <name>` for its
701/// "refresh just this direct registry dep" semantic, so this
702/// bundle deliberately omits `-p / --package`. Members can still
703/// be scoped by `--workspace`, `--default-members`, and
704/// `--exclude`. Adding a separate long flag (e.g.
705/// `--scope-package`) for member-name selection is a deferred
706/// improvement.
707#[derive(Debug, Args, Default)]
708pub(crate) struct WorkspaceSelectionArgsForUpdate {
709 /// Operate on every workspace member, then apply `--exclude`.
710 #[arg(long, conflicts_with = "default_members")]
711 pub workspace: bool,
712
713 /// Operate on `[workspace.default-members]`. Errors when the
714 /// Workspace declares no default-members.
715 #[arg(long, conflicts_with = "workspace")]
716 pub default_members: bool,
717
718 /// Drop the named package from the selection. Only valid in
719 /// combination with `--workspace` or `--default-members`.
720 #[arg(long, value_name = "PACKAGE")]
721 pub exclude: Vec<String>,
722}
723
724/// Workspace package-selection flags shared across the commands
725/// that operate on a (possibly multi-member) workspace.
726///
727/// Empty by default, in which case the documented "current
728/// package" fallback applies (single-package builds keep working
729/// unchanged; workspace builds use `[workspace.default-members]`
730/// if declared, otherwise every member).
731#[derive(Debug, Args, Default)]
732pub(crate) struct WorkspaceSelectionArgs {
733 /// Operate on every workspace member, then apply `--exclude`.
734 /// Mutually exclusive with `--package` / `--default-members`.
735 #[arg(
736 long,
737 conflicts_with_all = &["package", "default_members"],
738 )]
739 pub workspace: bool,
740
741 /// Operate on the named workspace package. Repeat the flag to
742 /// select multiple packages. Errors when a name is not a workspace
743 /// member or appears twice in the workspace.
744 #[arg(long = "package", short = 'p', value_name = "PACKAGE")]
745 pub package: Vec<String>,
746
747 /// Operate on `[workspace.default-members]`. Errors when the
748 /// workspace declares no default-members.
749 #[arg(long, conflicts_with_all = &["workspace", "package"])]
750 pub default_members: bool,
751
752 /// Drop the named package from the selection. Only valid in
753 /// combination with `--workspace` or `--default-members`, or with
754 /// the no-flag default-member fallback.
755 #[arg(long, value_name = "PACKAGE")]
756 pub exclude: Vec<String>,
757}
758
759#[derive(Debug, Args)]
760pub(crate) struct FetchArgs {
761 /// Path to the cabin.toml manifest.
762 #[arg(long, value_name = "PATH")]
763 pub manifest_path: Option<PathBuf>,
764
765 /// Path to a directory containing the local JSON package index.
766 /// Required when the manifest declares any versioned dependencies
767 /// and `--index-url` is not given. Mutually exclusive with
768 /// `--index-url`.
769 #[arg(long, value_name = "PATH")]
770 pub index_path: Option<PathBuf>,
771
772 /// Sparse HTTP index URL to read package metadata from.
773 /// Mutually exclusive with `--index-path`.
774 #[arg(long, value_name = "URL")]
775 pub index_url: Option<String>,
776
777 /// Override the default artifact cache directory.
778 #[arg(long, value_name = "PATH")]
779 pub cache_dir: Option<PathBuf>,
780
781 /// Require an existing, current `cabin.lock`. Resolution is not
782 /// allowed to choose any version that differs from the lockfile.
783 #[arg(long, conflicts_with = "frozen")]
784 pub locked: bool,
785
786 /// Like `--locked`, but also rejects state-writing side effects.
787 /// The lockfile is not written and the artifact cache will not be
788 /// populated. Already-cached artifacts may be reused.
789 #[arg(long)]
790 pub frozen: bool,
791
792 /// Forbid network access. Cabin refuses to use an HTTP index
793 /// URL (`--index-url` or a `[registry] index-url` config setting)
794 /// and expects every needed input to be local or already cached.
795 #[arg(long)]
796 pub offline: bool,
797
798 /// Output format. `human` is a readable summary; `json` produces a
799 /// machine-parseable document. Defaults to `human`.
800 #[arg(long, value_name = "FORMAT", default_value = "human")]
801 pub format: ResolveFormat,
802
803 /// Workspace package-selection flags.
804 #[command(flatten)]
805 pub workspace_selection: WorkspaceSelectionArgs,
806
807 /// Disable every active patch and source-replacement entry
808 /// for this invocation. See `docs/patch-overrides.md`.
809 #[arg(long)]
810 pub no_patches: bool,
811}
812
813#[derive(Debug, Args)]
814pub(crate) struct PackageArgs {
815 /// Path to the cabin.toml manifest. Must point at a single
816 /// package; pure-workspace roots are rejected unless the
817 /// Workspace selects exactly one member with `--package`.
818 #[arg(long, value_name = "PATH")]
819 pub manifest_path: Option<PathBuf>,
820
821 /// Directory for the generated archive and metadata.
822 #[arg(long, default_value = "dist")]
823 pub output_dir: PathBuf,
824
825 /// Output format. `human` is a readable summary; `json` produces
826 /// a machine-parseable document. Defaults to `human`.
827 #[arg(long, value_name = "FORMAT", default_value = "human")]
828 pub format: ResolveFormat,
829
830 /// Workspace package-selection flags. In a workspace with
831 /// multiple members, `cabin package` requires a single
832 /// `--package <name>` selection.
833 #[command(flatten)]
834 pub workspace_selection: WorkspaceSelectionArgs,
835}
836
837#[derive(Debug, Args)]
838pub(crate) struct PublishArgs {
839 /// Path to the cabin.toml manifest. Must point at a single
840 /// package; pure-workspace roots are rejected.
841 #[arg(long, value_name = "PATH")]
842 pub manifest_path: Option<PathBuf>,
843
844 /// Directory for the dry-run's archive and metadata when
845 /// `--registry-dir` is not given. Defaults to `dist/`. Mutually
846 /// exclusive with `--registry-dir`.
847 #[arg(long, value_name = "PATH")]
848 pub output_dir: Option<PathBuf>,
849
850 /// Run a publish dry-run only. With `--registry-dir`, validates
851 /// what would happen against the registry without mutating it.
852 /// Without `--registry-dir`, runs the staging-only dry-run that
853 /// writes the archive + metadata to `--output-dir`.
854 #[arg(long)]
855 pub dry_run: bool,
856
857 /// Local file-registry root to publish into. Without
858 /// `--dry-run`, the registry is mutated; with `--dry-run`, every
859 /// pre-write check runs but the registry is left untouched.
860 #[arg(long, value_name = "PATH")]
861 pub registry_dir: Option<PathBuf>,
862
863 /// Output format for the publish or dry-run report.
864 #[arg(long, value_name = "FORMAT", default_value = "human")]
865 pub format: ResolveFormat,
866
867 /// Workspace package-selection flags. In a workspace with
868 /// multiple members, `cabin publish` requires a single
869 /// `--package <name>` selection.
870 #[command(flatten)]
871 pub workspace_selection: WorkspaceSelectionArgs,
872}
873
874#[derive(Debug, Args)]
875pub(crate) struct ResolveArgs {
876 /// Path to the cabin.toml manifest.
877 #[arg(long, value_name = "PATH")]
878 pub manifest_path: Option<PathBuf>,
879
880 /// Path to a directory containing the local JSON package index.
881 /// Required when the manifest declares any versioned dependencies
882 /// and `--index-url` is not given. Mutually exclusive with
883 /// `--index-url`.
884 #[arg(long, value_name = "PATH")]
885 pub index_path: Option<PathBuf>,
886
887 /// Sparse HTTP index URL to read package metadata from.
888 /// Mutually exclusive with `--index-path`.
889 #[arg(long, value_name = "URL")]
890 pub index_url: Option<String>,
891
892 /// Output format. `human` is a readable summary; `json` produces a
893 /// machine-parseable document. Defaults to `human`.
894 #[arg(long, value_name = "FORMAT", default_value = "human")]
895 pub format: ResolveFormat,
896
897 /// Require an existing, current `cabin.lock`. Resolution is not
898 /// allowed to choose any version that differs from the lockfile.
899 /// Implies that `cabin.lock` will not be written.
900 #[arg(long, conflicts_with = "frozen")]
901 pub locked: bool,
902
903 /// Like `--locked`, but also rejects any state-writing side
904 /// effects.
905 #[arg(long)]
906 pub frozen: bool,
907
908 /// Forbid network access. Cabin refuses to use an HTTP index
909 /// URL (`--index-url` or a `[registry] index-url` config setting)
910 /// and expects every needed input to be local or already cached.
911 #[arg(long)]
912 pub offline: bool,
913
914 /// Workspace package-selection flags. The resolver is
915 /// workspace-flat (every member shares one resolution), so
916 /// selection only narrows the diagnostic output, not the
917 /// resolution itself.
918 #[command(flatten)]
919 pub workspace_selection: WorkspaceSelectionArgs,
920
921 /// Feature names to enable on selected root packages.
922 /// Repeatable; values may also be comma-separated.
923 #[arg(long, value_name = "FEATURES")]
924 pub features: Vec<String>,
925
926 /// Enable every declared feature on selected root packages.
927 /// Combines with `--features` (the union is requested).
928 #[arg(long)]
929 pub all_features: bool,
930
931 /// Disable selected root packages' `default` feature.
932 #[arg(long)]
933 pub no_default_features: bool,
934
935 /// Disable every active patch and source-replacement entry
936 /// for this invocation. See `docs/patch-overrides.md`.
937 #[arg(long)]
938 pub no_patches: bool,
939}
940
941#[derive(Debug, Args)]
942pub(crate) struct UpdateArgs {
943 /// Path to the cabin.toml manifest.
944 #[arg(long, value_name = "PATH")]
945 pub manifest_path: Option<PathBuf>,
946
947 /// Path to a directory containing the local JSON package index.
948 /// Required when the manifest declares any versioned dependencies
949 /// and `--index-url` is not given. Mutually exclusive with
950 /// `--index-url`.
951 #[arg(long, value_name = "PATH")]
952 pub index_path: Option<PathBuf>,
953
954 /// Sparse HTTP index URL to read package metadata from.
955 /// Mutually exclusive with `--index-path`.
956 #[arg(long, value_name = "URL")]
957 pub index_url: Option<String>,
958
959 /// Update only the named **dependency** (and any of its
960 /// transitive deps that must change to satisfy the new
961 /// constraints). Without this flag every locked package is
962 /// re-resolved.
963 ///
964 /// `--package` here means "refresh this direct versioned
965 /// dependency", *not* "scope to this workspace member".
966 /// Workspace members can still be scoped through
967 /// `--workspace`, `--default-members`, and `--exclude`; the
968 /// workspace-selection bundle on `cabin update` deliberately
969 /// omits `-p` / `--package` to avoid the name collision.
970 #[arg(long, value_name = "NAME")]
971 pub package: Option<String>,
972
973 /// Output format for the resulting resolution.
974 #[arg(long, value_name = "FORMAT", default_value = "human")]
975 pub format: ResolveFormat,
976
977 /// Forbid network access. Cabin refuses to use an HTTP index
978 /// URL (`--index-url` or a `[registry] index-url` config setting)
979 /// and expects every needed input to be local or already cached.
980 #[arg(long)]
981 pub offline: bool,
982
983 /// Workspace package-selection flags scoped to
984 /// `cabin update`'s flag space (no `-p / --package`; see the
985 /// docstring on `package` above).
986 #[command(flatten)]
987 pub workspace_selection: WorkspaceSelectionArgsForUpdate,
988
989 /// Disable every active patch and source-replacement entry
990 /// for this invocation. See `docs/patch-overrides.md`.
991 #[arg(long)]
992 pub no_patches: bool,
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
996pub(crate) enum ResolveFormat {
997 Human,
998 Json,
999}
1000
1001/// Default manifest filename used by every command.
1002const MANIFEST_FILENAME: &str = scaffold::MANIFEST_FILENAME;
1003
1004/// Dispatch a parsed CLI invocation. Returns the exit code the
1005/// process should propagate. Most commands return
1006/// `ExitCode::SUCCESS` on the happy path; `cabin run` forwards
1007/// the spawned program's exit status so a non-zero exit from the
1008/// program becomes Cabin's own exit status.
1009///
1010/// The `cli.color` field carries the user's `--color` choice;
1011/// the resolved [`cabin_core::ColorChoice`] for top-level
1012/// error rendering is computed in `main.rs` against the env
1013/// and the user-level config. Subcommands today produce
1014/// uncolored status output and so do not consume the resolved
1015/// color; when a subcommand learns to emit styled output, it
1016/// should accept the resolved choice as an explicit argument
1017/// rather than re-deriving it here.
1018pub(crate) fn run(
1019 cli: Cli,
1020 reporter: Reporter,
1021 color: cabin_core::ColorChoice,
1022) -> Result<std::process::ExitCode> {
1023 use std::process::ExitCode;
1024 // `--version` (and the short `-V`) routes through the same
1025 // formatter `cabin version` uses, so `cabin --version -v`
1026 // produces the verbose key/value block instead of the
1027 // concise single line clap's auto-flag would emit. The
1028 // flag wins over any subcommand and over `--list`, matching
1029 // cargo's precedence.
1030 if cli.version {
1031 crate::cli::version::version(crate::cli::version::VersionArgs {}, reporter.verbosity())?;
1032 return Ok(ExitCode::SUCCESS);
1033 }
1034 // `--list` is mutually exclusive with every other input;
1035 // clap rejects `cabin --list <subcommand>` for us. Print
1036 // the full subcommand list and exit successfully. The
1037 // listing is written through a `termcolor::StandardStream`
1038 // tuned to the caller-resolved color choice so the
1039 // cargo-style palette (green heading, cyan subcommand
1040 // names) appears whenever `--color` says it should.
1041 if cli.list {
1042 let mut stdout =
1043 termcolor::StandardStream::stdout(cabin_diagnostics::termcolor_choice(color));
1044 crate::command_list::print_list(&mut stdout)?;
1045 return Ok(ExitCode::SUCCESS);
1046 }
1047 let Some(command) = cli.command else {
1048 // `cabin` with no subcommand prints the curated help
1049 // and exits zero, matching the prior implicit behavior
1050 // (clap's auto help) but routed through the dispatcher
1051 // so the exit code is documented here.
1052 let mut cmd = <Cli as clap::CommandFactory>::command();
1053 cmd.print_help().context("failed to print top-level help")?;
1054 // Cargo prints help and exits 0 when invoked with no
1055 // arguments. Cabin matches that.
1056 return Ok(ExitCode::SUCCESS);
1057 };
1058 match command {
1059 Command::Init(args) => init(&args, reporter).map(|()| ExitCode::SUCCESS),
1060 Command::New(args) => new(&args, reporter).map(|()| ExitCode::SUCCESS),
1061 Command::Add(args) => crate::cli::add::add(&args, reporter).map(|()| ExitCode::SUCCESS),
1062 Command::Remove(args) => {
1063 crate::cli::remove::remove(&args, reporter).map(|()| ExitCode::SUCCESS)
1064 }
1065 Command::Metadata(args) => {
1066 crate::cli::metadata::metadata(&args, reporter).map(|()| ExitCode::SUCCESS)
1067 }
1068 Command::Build(args) => {
1069 build(&args, reporter, BuildMode::Build).map(|()| ExitCode::SUCCESS)
1070 }
1071 Command::Check(args) => {
1072 build(&args, reporter, BuildMode::Check).map(|()| ExitCode::SUCCESS)
1073 }
1074 Command::Clean(args) => clean(&args, reporter).map(|()| ExitCode::SUCCESS),
1075 Command::Run(args) => crate::cli::run::run(&args, reporter),
1076 Command::Test(args) => crate::cli::test::test(&args, reporter).map(|()| ExitCode::SUCCESS),
1077 Command::Resolve(args) => resolve(&args, reporter).map(|()| ExitCode::SUCCESS),
1078 Command::Update(args) => update(&args, reporter).map(|()| ExitCode::SUCCESS),
1079 Command::Fetch(args) => fetch(&args, reporter).map(|()| ExitCode::SUCCESS),
1080 Command::Vendor(args) => {
1081 crate::cli::vendor::vendor(&args, reporter).map(|()| ExitCode::SUCCESS)
1082 }
1083 Command::Tree(args) => crate::cli::tree::tree(&args).map(|()| ExitCode::SUCCESS),
1084 Command::Explain(args) => {
1085 crate::cli::explain::explain(&args, reporter).map(|()| ExitCode::SUCCESS)
1086 }
1087 Command::Package(args) => package(&args, reporter).map(|()| ExitCode::SUCCESS),
1088 Command::Publish(args) => publish(&args, reporter).map(|()| ExitCode::SUCCESS),
1089 Command::Fmt(args) => crate::cli::fmt::fmt(&args, reporter),
1090 Command::Tidy(args) => crate::cli::tidy::tidy(&args, reporter),
1091 Command::Port(args) => {
1092 crate::port_subcommand::port(&args, reporter).map(|()| ExitCode::SUCCESS)
1093 }
1094 Command::Compgen(args) => crate::completions::run(&args).map(|()| ExitCode::SUCCESS),
1095 Command::Mangen(args) => crate::manpages::run(&args).map(|()| ExitCode::SUCCESS),
1096 Command::Version(args) => {
1097 crate::cli::version::version(args, reporter.verbosity()).map(|()| ExitCode::SUCCESS)
1098 }
1099 }
1100}
1101
1102/// Render the optimization / debuginfo descriptor that follows
1103/// the profile name in the `Finished` status line, matching
1104/// cargo's own banner:
1105///
1106/// - `unoptimized + debuginfo` for `dev` and any other `O0` +
1107/// debug build,
1108/// - `optimized` for `release` and other non-zero opt levels,
1109/// - `optimized + debuginfo` when both flags are on.
1110pub(crate) fn profile_descriptor(profile: &cabin_core::ResolvedProfile) -> String {
1111 let opt = if matches!(profile.opt_level, cabin_core::OptLevel::O0) {
1112 "unoptimized"
1113 } else {
1114 "optimized"
1115 };
1116 if profile.debug {
1117 format!("{opt} + debuginfo")
1118 } else {
1119 opt.to_owned()
1120 }
1121}
1122
1123/// Translate `cabin build`'s `--profile` / `--release` flags into
1124/// a typed [`cabin_core::ProfileSelection`].
1125///
1126/// `--release` is preserved as a compatibility alias for
1127/// `--profile release`. clap's `conflicts_with` already rejects
1128/// the both-set combination so this helper only sees one of the
1129/// three possible inputs.
1130fn profile_selection_for_build(
1131 args: &BuildArgs,
1132 config: &cabin_config::EffectiveConfig,
1133) -> Result<cabin_core::ProfileSelection> {
1134 profile_selection_from_flags(args.profile.as_deref(), args.release, config)
1135}
1136
1137/// Shared profile-selection precedence: explicit `--profile NAME`
1138/// wins, then the legacy `--release` alias, then any config-
1139/// supplied default, then the built-in `dev` profile. Used by
1140/// `cabin build` and `cabin test`.
1141pub(crate) fn profile_selection_from_flags(
1142 profile: Option<&str>,
1143 release: bool,
1144 config: &cabin_config::EffectiveConfig,
1145) -> Result<cabin_core::ProfileSelection> {
1146 if let Some(name) = profile {
1147 let pname = cabin_core::ProfileName::new(name.to_owned())
1148 .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1149 return Ok(cabin_core::ProfileSelection::from_name(pname));
1150 }
1151 if release {
1152 return Ok(cabin_core::ProfileSelection::release_alias());
1153 }
1154 if let Some((selection, _source)) = crate::cli::config::config_profile_selection(config)? {
1155 return Ok(selection);
1156 }
1157 Ok(cabin_core::ProfileSelection::default_dev())
1158}
1159
1160/// `cabin metadata` accepts a `--profile` flag but no `--release`
1161/// alias (metadata is read-only and doesn't need the legacy spelling).
1162/// Falls back to a config-supplied default when the user did not
1163/// pass `--profile`; otherwise the built-in `dev` profile applies.
1164pub(crate) fn profile_selection_for_metadata(
1165 name: Option<&str>,
1166 config: &cabin_config::EffectiveConfig,
1167) -> Result<cabin_core::ProfileSelection> {
1168 profile_selection_from_flags(name, false, config)
1169}
1170
1171/// Look up the profile-definition table that should drive
1172/// resolution. Profiles are workspace-wide: only the entry-point
1173/// manifest's `[profile.*]` tables count, so we read them off the
1174/// graph's root package (workspace root or single-package root).
1175pub(crate) fn workspace_profile_definitions(
1176 graph: &PackageGraph,
1177) -> BTreeMap<cabin_core::ProfileName, cabin_core::ProfileDefinition> {
1178 graph.root_settings.profiles.clone()
1179}
1180
1181/// Workspace-root manifest's `[toolchain]` plus any
1182/// `[target.'cfg(...)'.toolchain]` overrides. Workspace member
1183/// manifests cannot declare a `[toolchain]` table — the workspace
1184/// loader rejects them — so reading off the root is sufficient.
1185pub(crate) fn workspace_toolchain_settings(graph: &PackageGraph) -> cabin_core::ToolchainSettings {
1186 graph.root_settings.toolchain.clone()
1187}
1188
1189/// Translate `cabin build`'s / `cabin metadata`'s tool-selection
1190/// CLI flags into a typed [`cabin_core::ToolchainSelection`].
1191pub(crate) fn toolchain_selection_from_args(
1192 args: &ToolchainSelectionArgs,
1193) -> Result<cabin_core::ToolchainSelection> {
1194 let mut sel = cabin_core::ToolchainSelection::default();
1195 if let Some(raw) = &args.cc {
1196 sel = sel.with_cli(cabin_core::ToolKind::CCompiler, parse_cli_tool(raw)?);
1197 }
1198 if let Some(raw) = &args.cxx {
1199 sel = sel.with_cli(cabin_core::ToolKind::CxxCompiler, parse_cli_tool(raw)?);
1200 }
1201 if let Some(raw) = &args.ar {
1202 sel = sel.with_cli(cabin_core::ToolKind::Archiver, parse_cli_tool(raw)?);
1203 }
1204 Ok(sel)
1205}
1206
1207fn parse_cli_tool(raw: &str) -> Result<cabin_core::ToolSpec> {
1208 let trimmed = raw.trim();
1209 if trimmed.is_empty() {
1210 bail!("tool argument must be a non-empty path or command name");
1211 }
1212 Ok(cabin_core::ToolSpec::parse(trimmed.to_owned()))
1213}
1214
1215/// Resolve a toolchain by layering manifest settings, the
1216/// optional `[toolchain]` config layer, and process-discovered
1217/// defaults on top of `selection` (already-parsed CLI overrides
1218/// or `ToolchainSelection::default()`).
1219pub(crate) fn resolve_toolchain_layered(
1220 graph: &PackageGraph,
1221 selection: &cabin_core::ToolchainSelection,
1222 effective_config: &cabin_config::EffectiveConfig,
1223 host_platform: &cabin_core::TargetPlatform,
1224) -> Result<cabin_core::ResolvedToolchain> {
1225 let manifest_toolchain_settings = workspace_toolchain_settings(graph);
1226 let config_toolchain_layer = crate::cli::config::toolchain_layer(effective_config);
1227 let mut toolchain_inputs = cabin_toolchain::ResolveInputs::from_process(
1228 selection,
1229 &manifest_toolchain_settings,
1230 host_platform,
1231 );
1232 if let Some(layer) = config_toolchain_layer.as_ref() {
1233 toolchain_inputs = toolchain_inputs.with_config(layer);
1234 }
1235 Ok(cabin_toolchain::resolve_toolchain(&toolchain_inputs)?)
1236}
1237
1238/// Translate the `--compiler-wrapper` / `--no-compiler-wrapper`
1239/// CLI flag pair into a typed
1240/// [`cabin_core::CompilerWrapperRequest`] override. Clap already
1241/// rejects passing both flags simultaneously; this helper only
1242/// validates the value passed to `--compiler-wrapper`.
1243pub(crate) fn compiler_wrapper_override_from_args(
1244 args: &ToolchainSelectionArgs,
1245) -> Result<Option<cabin_core::CompilerWrapperRequest>> {
1246 if args.no_compiler_wrapper {
1247 return Ok(Some(cabin_core::CompilerWrapperRequest::Disabled));
1248 }
1249 let Some(raw) = args.compiler_wrapper.as_deref() else {
1250 return Ok(None);
1251 };
1252 let parsed = cabin_core::CompilerWrapperRequest::parse(raw)
1253 .with_context(|| format!("invalid --compiler-wrapper value `{raw}`"))?;
1254 Ok(Some(parsed))
1255}
1256
1257/// Resolve the compiler-cache wrapper by layering the CLI
1258/// override (`--compiler-wrapper` / `--no-compiler-wrapper`), the
1259/// manifest's `[build.cache]` settings, the optional config
1260/// `[build.cache.compiler-wrapper]` layer, and process-detected
1261/// version metadata. Returns the typed resolution on success;
1262/// callers that want fail-soft behavior (e.g. `cabin metadata`)
1263/// call `resolve_compiler_wrapper` directly.
1264pub(crate) fn resolve_compiler_wrapper_layered(
1265 cli_override: Option<cabin_core::CompilerWrapperRequest>,
1266 manifest_settings: &cabin_core::CompilerWrapperManifestSettings,
1267 effective_config: &cabin_config::EffectiveConfig,
1268 host_platform: &cabin_core::TargetPlatform,
1269) -> Result<Option<cabin_core::ResolvedCompilerWrapper>> {
1270 let mut wrapper_inputs = cabin_toolchain::WrapperInputs::from_process(
1271 cli_override,
1272 manifest_settings,
1273 host_platform,
1274 );
1275 if let Some(layer) = crate::cli::config::wrapper_layer(effective_config) {
1276 wrapper_inputs = wrapper_inputs.with_config(layer);
1277 }
1278 cabin_toolchain::resolve_compiler_wrapper(
1279 &wrapper_inputs,
1280 Some(&cabin_toolchain::ProcessRunner),
1281 )
1282 .map_err(|err| anyhow::anyhow!(err.to_string()))
1283}
1284
1285/// Workspace-root manifest's compiler-wrapper settings. Mirrors
1286/// [`workspace_toolchain_settings`] — the workspace loader rejects
1287/// non-empty declarations on member manifests so reading the root
1288/// is sufficient.
1289pub(crate) fn workspace_compiler_wrapper_settings(
1290 graph: &PackageGraph,
1291) -> cabin_core::CompilerWrapperManifestSettings {
1292 graph.root_settings.compiler_wrapper.clone()
1293}
1294
1295/// Compute per-package effective language standards for every
1296/// package in the graph (pure manifest data; no toolchain input).
1297/// Keyed by package index, mirroring `resolve_per_package_build_flags`.
1298pub(crate) fn resolve_per_package_language_standards(
1299 graph: &PackageGraph,
1300) -> HashMap<usize, cabin_core::ResolvedLanguageStandards> {
1301 graph
1302 .packages
1303 .iter()
1304 .enumerate()
1305 .map(|(idx, pkg)| {
1306 (
1307 idx,
1308 cabin_core::resolve_language_standards(&pkg.package.language),
1309 )
1310 })
1311 .collect()
1312}
1313
1314/// Compute per-package `ResolvedProfileFlags` for every package in
1315/// the graph. The result is keyed by package index so callers
1316/// (planner, metadata view) can read them without rerunning the
1317/// merge per package.
1318pub(crate) fn resolve_per_package_build_flags(
1319 graph: &PackageGraph,
1320 profile_build: Option<&cabin_core::ProfileFlags>,
1321 host_platform: &cabin_core::TargetPlatform,
1322 feature_resolution: &cabin_feature::FeatureResolution,
1323 detection: Option<&cabin_core::ToolchainDetectionReport>,
1324) -> (
1325 HashMap<usize, cabin_core::ResolvedProfileFlags>,
1326 HashMap<usize, Vec<cabin_core::StandardFlagConflict>>,
1327) {
1328 // Detected compiler identities gate `[target.'cfg(cc/cxx = ...)'.profile]`
1329 // layers. `None` (fail-soft commands where detection failed) evaluates
1330 // those layers as family `unknown` with no version.
1331 let (cc_identity, cxx_identity) = match detection {
1332 Some(report) => (
1333 report.cc.as_ref().map(|tool| &tool.identity),
1334 Some(&report.cxx.identity),
1335 ),
1336 None => (None, None),
1337 };
1338 let mut out = HashMap::with_capacity(graph.packages.len());
1339 let mut conflicts: HashMap<usize, Vec<cabin_core::StandardFlagConflict>> = HashMap::new();
1340 for (idx, pkg) in graph.packages.iter().enumerate() {
1341 // A registry/downloaded dependency's own `[profile]` build flags are
1342 // untrusted: only local packages (the workspace root, its members, and
1343 // `path` dependencies) may contribute raw compiler/linker flags.
1344 // `resolve_build_flags` drops the dependency's cflags/cxxflags/ldflags
1345 // when this is false, so a malicious dependency cannot smuggle a
1346 // code-executing compiler flag (e.g. `-fplugin=`) onto its build line.
1347 let package_trusted = matches!(pkg.kind, cabin_workspace::PackageKind::Local);
1348 // The package's resolved enabled features gate its
1349 // `[target.'cfg(feature = "...")'.profile]` flag layers. cabin-core
1350 // stays feature-vocabulary-only (it must not depend on cabin-feature),
1351 // so the cli pulls the name set out of the resolution and hands core
1352 // a bare `&BTreeSet<String>`.
1353 let package_features = feature_resolution.for_package(idx);
1354 let ctx = cabin_core::ConditionContext::with_features(
1355 host_platform,
1356 &package_features.enabled_features,
1357 )
1358 .with_compilers(cc_identity, cxx_identity);
1359 let resolved = cabin_core::resolve_build_flags(
1360 &pkg.package.build,
1361 profile_build,
1362 &ctx,
1363 package_trusted,
1364 );
1365 // The documented escape-hatch conflict *candidates*: a
1366 // first-class standard declaration plus an explicit
1367 // `-std=` / `/std:` in the same package's manifest-derived
1368 // flags. Detected before the system-dep / env augmentation
1369 // layers so CFLAGS / CXXFLAGS and pkg-config output stay
1370 // exempt; the build planner surfaces a candidate only when
1371 // a compile its scope covers is actually planned.
1372 let pkg_conflicts = cabin_core::find_standard_flag_conflicts(
1373 pkg.package.name.as_str(),
1374 &pkg.package.language,
1375 &pkg.package.targets,
1376 &resolved,
1377 );
1378 if !pkg_conflicts.is_empty() {
1379 conflicts.insert(idx, pkg_conflicts);
1380 }
1381 out.insert(idx, resolved);
1382 }
1383 (out, conflicts)
1384}
1385
1386/// Apply the documented post-profile build-flag layers — `pkg-config`
1387/// probes for active system dependencies, then `CPPFLAGS` / `CFLAGS`
1388/// / `CXXFLAGS` / `LDFLAGS` from the process environment — in the
1389/// order both layers must run for the resulting
1390/// `BuildConfiguration::fingerprint` to stay stable across commands.
1391/// Reports from both layers are intentionally discarded; callers that
1392/// need them invoke the individual `crate::cli::system_deps` /
1393/// `crate::cli::env_flags` helpers directly.
1394pub(crate) fn augment_build_flags(
1395 graph: &PackageGraph,
1396 host_platform: &cabin_core::TargetPlatform,
1397 dev_for: &BTreeSet<String>,
1398 build_flags: HashMap<usize, cabin_core::ResolvedProfileFlags>,
1399 reporter: Reporter,
1400) -> Result<HashMap<usize, cabin_core::ResolvedProfileFlags>> {
1401 let (build_flags, _system_dep_reports) =
1402 crate::cli::system_deps::augment_build_flags_with_system_deps(
1403 graph,
1404 host_platform,
1405 dev_for,
1406 build_flags,
1407 reporter,
1408 )?;
1409 let (build_flags, _env_build_flags) = crate::cli::env_flags::augment_build_flags_with_env(
1410 graph,
1411 build_flags,
1412 |k| std::env::var_os(k),
1413 reporter,
1414 )?;
1415 Ok(build_flags)
1416}
1417
1418/// Convert raw `--features` flag values into a `SelectionRequest`.
1419/// Validation against package declarations happens later in
1420/// `BuildConfiguration::resolve`.
1421pub(crate) fn build_selection_request(
1422 feature_args: &[String],
1423 all_features: bool,
1424 no_default_features: bool,
1425) -> cabin_core::SelectionRequest {
1426 let mut features: BTreeSet<String> = BTreeSet::new();
1427 for raw in feature_args {
1428 for token in raw.split(',') {
1429 let trimmed = token.trim();
1430 if trimmed.is_empty() {
1431 continue;
1432 }
1433 features.insert(trimmed.to_owned());
1434 }
1435 }
1436 cabin_core::SelectionRequest {
1437 features,
1438 all_features,
1439 no_default_features,
1440 }
1441}
1442
1443/// Resolve a `BuildConfiguration` for every package in the graph.
1444/// CLI feature selection requests apply to primary packages only —
1445/// non-primary packages (transitive path / registry deps) fall back
1446/// to their declared defaults until per-dependency feature requests
1447/// land.
1448pub(crate) fn resolve_build_configurations(
1449 graph: &PackageGraph,
1450 request: &cabin_core::SelectionRequest,
1451 selected: &[usize],
1452 profile: &cabin_core::ResolvedProfile,
1453 toolchain: &cabin_core::ToolchainSummary,
1454 build_flags: &HashMap<usize, cabin_core::ResolvedProfileFlags>,
1455) -> Result<HashMap<usize, cabin_core::BuildConfiguration>> {
1456 use HashMap;
1457 let selected_set: HashSet<usize> = selected.iter().copied().collect();
1458 let mut out: HashMap<usize, cabin_core::BuildConfiguration> = HashMap::new();
1459 for (idx, pkg) in graph.packages.iter().enumerate() {
1460 // CLI feature requests apply only to *selected* packages.
1461 // Non-selected packages — including workspace siblings the
1462 // user did not pick — fall back to their declared defaults
1463 // so an unrelated package's missing feature does not fail
1464 // an unrelated build.
1465 let pkg_request = if selected_set.contains(&idx) {
1466 request.clone()
1467 } else {
1468 cabin_core::SelectionRequest::default()
1469 };
1470 let pkg_flags = build_flags.get(&idx).cloned().unwrap_or_default();
1471 let cfg = cabin_core::BuildConfiguration::resolve(cabin_core::BuildConfigurationInput {
1472 package: pkg.package.name.as_str(),
1473 features: &pkg.package.features,
1474 request: &pkg_request,
1475 profile: profile.clone(),
1476 toolchain: toolchain.clone(),
1477 build_flags: pkg_flags,
1478 language: cabin_core::LanguageStandardsSummary::from_package(&pkg.package),
1479 })
1480 .with_context(|| {
1481 format!(
1482 "invalid configuration selection for package `{}`",
1483 pkg.package.name.as_str()
1484 )
1485 })?;
1486 out.insert(idx, cfg);
1487 }
1488 Ok(out)
1489}
1490
1491/// Resolve the manifest the user is operating on. When the
1492/// user did not pass `--manifest-path` (the option is `None`), walk
1493/// upward from the current directory looking for a workspace root
1494/// and prefer it. When the user passed `--manifest-path`
1495/// Explicitly — even with the value `cabin.toml` — the supplied
1496/// Path is honored as-is so callers can intentionally target a
1497/// specific manifest from any directory.
1498pub(crate) fn resolve_invocation_manifest(args_path: Option<&Path>) -> Result<PathBuf> {
1499 let cwd = std::env::current_dir().context("failed to determine current directory")?;
1500 match args_path {
1501 Some(path) => {
1502 if path.is_absolute() {
1503 Ok(path.to_path_buf())
1504 } else {
1505 Ok(cwd.join(path))
1506 }
1507 }
1508 None => {
1509 if let Some(found) = cabin_workspace::discover_workspace_root(&cwd)? {
1510 Ok(found.manifest_path)
1511 } else {
1512 Ok(cwd.join(MANIFEST_FILENAME))
1513 }
1514 }
1515 }
1516}
1517
1518/// Convert CLI workspace-selection flags into a
1519/// `cabin_workspace::PackageSelection`. The mode mirrors the order
1520/// of `WorkspaceSelectionArgs`'s field comments.
1521pub(crate) fn build_workspace_selection(
1522 args: &WorkspaceSelectionArgs,
1523) -> cabin_workspace::PackageSelection {
1524 use cabin_workspace::SelectionMode;
1525 let mode = if args.workspace {
1526 SelectionMode::WholeWorkspace
1527 } else if !args.package.is_empty() {
1528 SelectionMode::ExplicitPackages(args.package.clone())
1529 } else if args.default_members {
1530 SelectionMode::DefaultMembers
1531 } else {
1532 SelectionMode::CurrentPackage
1533 };
1534 cabin_workspace::PackageSelection {
1535 mode,
1536 exclude: args.exclude.clone(),
1537 }
1538}
1539
1540/// Build the selection's closure once and adapt a
1541/// [`cabin_feature::FeatureResolution`] handle into the
1542/// `Fn(usize, &str) -> bool` optional-dep filter the workspace
1543/// versioned-dep helpers consume. Shared by the collect / has shims
1544/// below so the closure build + filter adapter live in one place.
1545fn closure_and_optional_filter<'a>(
1546 graph: &PackageGraph,
1547 selection: &cabin_workspace::ResolvedSelection,
1548 features: &'a cabin_feature::FeatureResolution,
1549) -> (BTreeSet<usize>, impl Fn(usize, &str) -> bool + 'a) {
1550 (selection.closure(graph), move |idx, name| {
1551 features.is_optional_dep_enabled(idx, name)
1552 })
1553}
1554
1555/// Collect every versioned dependency reachable from `selection`
1556/// after dropping patched names. Thin shim around the typed API
1557/// in `cabin-workspace`.
1558pub(crate) fn collect_closure_versioned_deps_excluding_patches(
1559 graph: &PackageGraph,
1560 selection: &cabin_workspace::ResolvedSelection,
1561 features: &cabin_feature::FeatureResolution,
1562 patched_names: &BTreeSet<String>,
1563 dev_for: &BTreeSet<String>,
1564) -> Result<BTreeMap<PackageName, semver::VersionReq>> {
1565 let (closure, is_optional_dep_enabled) =
1566 closure_and_optional_filter(graph, selection, features);
1567 cabin_workspace::collect_closure_versioned_deps_excluding_with_dev(
1568 graph,
1569 &closure,
1570 is_optional_dep_enabled,
1571 patched_names,
1572 dev_for,
1573 )
1574 .map_err(Into::into)
1575}
1576
1577/// Merge `extra` into `into`, joining version requirements for
1578/// names that appear in both so the resolver sees a single
1579/// requirement per package. Mirrors the join-and-reparse pattern
1580/// the workspace closure walker uses.
1581fn merge_versioned_deps(
1582 into: &mut BTreeMap<PackageName, semver::VersionReq>,
1583 extra: BTreeMap<PackageName, semver::VersionReq>,
1584) -> Result<()> {
1585 for (name, req) in extra {
1586 match into.entry(name.clone()) {
1587 std::collections::btree_map::Entry::Vacant(slot) => {
1588 slot.insert(req);
1589 }
1590 std::collections::btree_map::Entry::Occupied(mut slot) => {
1591 let parsed = cabin_workspace::combine_version_reqs(&[
1592 slot.get().to_string(),
1593 req.to_string(),
1594 ])
1595 .map_err(|(joined, err)| {
1596 anyhow::anyhow!(
1597 "conflicting dependency requirements for {}: {}: {}",
1598 name.as_str(),
1599 joined,
1600 err
1601 )
1602 })?;
1603 slot.insert(parsed);
1604 }
1605 }
1606 }
1607 Ok(())
1608}
1609
1610/// Whether the selected closure carries any versioned
1611/// (registry-bound) dependency that the artifact pipeline would
1612/// need to fetch. Thin shim around the typed API in
1613/// `cabin-workspace`.
1614pub(crate) fn closure_has_versioned_deps_excluding_patches(
1615 graph: &PackageGraph,
1616 selection: &cabin_workspace::ResolvedSelection,
1617 features: &cabin_feature::FeatureResolution,
1618 patched_names: &BTreeSet<String>,
1619 dev_for: &BTreeSet<String>,
1620) -> bool {
1621 let (closure, is_optional_dep_enabled) =
1622 closure_and_optional_filter(graph, selection, features);
1623 cabin_workspace::closure_has_versioned_deps_excluding_with_dev(
1624 graph,
1625 &closure,
1626 is_optional_dep_enabled,
1627 patched_names,
1628 dev_for,
1629 )
1630}
1631
1632/// Resolve features for the selected closure. Roots receive the
1633/// caller-provided request; non-root reachable packages inherit
1634/// requests through dependency edges per the documented feature
1635/// model. The returned [`cabin_feature::FeatureResolution`] is
1636/// then threaded into the dependency-iteration helpers so
1637/// disabled optional dependencies disappear from the resolver /
1638/// fetch / build planning.
1639pub(crate) fn compute_feature_resolution(
1640 graph: &PackageGraph,
1641 selection: &cabin_workspace::ResolvedSelection,
1642 request: &cabin_core::SelectionRequest,
1643) -> Result<cabin_feature::FeatureResolution> {
1644 let root_request: cabin_feature::RootFeatureRequest = request.into();
1645 let platform = cabin_core::TargetPlatform::current();
1646 cabin_feature::resolve_features(graph, &selection.packages, &root_request, &platform)
1647 .map_err(|e| anyhow::anyhow!(e.to_string()))
1648}
1649
1650/// Pick the primary packages that contribute versioned
1651/// deps to a resolve / fetch / update run. When the user passed
1652/// workspace-selection flags, only their selected packages
1653/// contribute. Otherwise the documented default applies (root
1654/// package or every primary).
1655fn selected_resolution_packages(
1656 graph: &PackageGraph,
1657 selection: &cabin_workspace::PackageSelection,
1658) -> Result<cabin_workspace::ResolvedSelection> {
1659 cabin_workspace::resolve_package_selection(graph, selection).map_err(std::convert::Into::into)
1660}
1661
1662/// Pick the single package manifest path that
1663/// `cabin package` / `cabin publish` should operate on. When the
1664/// invocation manifest is a workspace root, the user must supply
1665/// exactly one explicit `--package <name>` selection. Otherwise we
1666/// honor the existing single-package contract.
1667/// Result of selecting a single package manifest for a
1668/// workspace-aware `cabin package` / `cabin publish` invocation.
1669/// Carries both the manifest path and the pre-resolved `Package`,
1670/// so member manifests with `dep = { workspace = true }` reach
1671/// `cabin-package` after the workspace loader has substituted the
1672/// inherited requirement.
1673struct SinglePackageSelection {
1674 manifest_path: PathBuf,
1675 /// `Some` when the manifest was loaded through a workspace
1676 /// (so `cabin-workspace` resolved any `workspace = true` deps).
1677 /// `None` when the user passed a standalone manifest path; in
1678 /// that case `cabin-package`'s own validator decides what to do
1679 /// with any unresolved workspace dep it sees.
1680 resolved_project: Option<cabin_core::Package>,
1681 /// Raw `[workspace.<kind>-dependencies]` strings from the
1682 /// workspace root, so archive staging can rewrite dependency
1683 /// `{ workspace = true }` markers to the author's original
1684 /// requirement spelling. Empty for standalone manifests.
1685 workspace_dep_requirements: cabin_core::WorkspaceDepRequirements,
1686}
1687
1688fn select_single_package_manifest(
1689 invocation: &Path,
1690 selection: &WorkspaceSelectionArgs,
1691 command: &'static str,
1692) -> Result<SinglePackageSelection> {
1693 let parsed = cabin_manifest::load_manifest(invocation)
1694 .with_context(|| format!("failed to load manifest at {}", invocation.display()))?;
1695 if parsed.workspace.is_none() {
1696 // Single-package manifest: the existing behavior applies
1697 // unchanged. Reject workspace-selection flags so the user
1698 // never gets the impression Cabin honored them silently.
1699 if selection.workspace
1700 || selection.default_members
1701 || !selection.package.is_empty()
1702 || !selection.exclude.is_empty()
1703 {
1704 bail!(
1705 "workspace package-selection flags are not valid for `cabin {command}` against a non-workspace manifest"
1706 );
1707 }
1708 return Ok(SinglePackageSelection {
1709 manifest_path: invocation.to_path_buf(),
1710 resolved_project: None,
1711 workspace_dep_requirements: cabin_core::WorkspaceDepRequirements::default(),
1712 });
1713 }
1714 // The root manifest parse carries the original requirement
1715 // strings; the loader-resolved `Package` below would respell
1716 // them through `semver::VersionReq`.
1717 let mut workspace_dep_requirements = cabin_core::WorkspaceDepRequirements::default();
1718 if let Some(workspace) = &parsed.workspace {
1719 for (kind, table) in [
1720 (cabin_core::DependencyKind::Normal, &workspace.dependencies),
1721 (cabin_core::DependencyKind::Dev, &workspace.dev_dependencies),
1722 ] {
1723 for (name, requirement) in table {
1724 workspace_dep_requirements.insert(kind, name.clone(), requirement.clone());
1725 }
1726 }
1727 }
1728 if selection.package.len() != 1 || selection.workspace || selection.default_members {
1729 bail!(
1730 "`cabin {command}` requires a single `--package <name>` selection inside a workspace; use `--package <name>` to pick the package to {command}"
1731 );
1732 }
1733 if !selection.exclude.is_empty() {
1734 bail!(
1735 "`--exclude` is not valid for `cabin {command}`; pass exactly one `--package <name>`"
1736 );
1737 }
1738 // Package / publish only need to identify the selected
1739 // workspace member; foundation-port edges are skipped so the
1740 // selection works without network access on workspaces with
1741 // HTTP-backed ports that have never been cached.
1742 let graph = cabin_workspace::load_workspace_skip_ports(invocation)?;
1743 let name = &selection.package[0];
1744 let idx = graph
1745 .index_of(name)
1746 .ok_or_else(|| anyhow::anyhow!("package `{name}` is not a member of this workspace"))?;
1747 if !graph.primary_packages.contains(&idx) {
1748 bail!("package `{name}` is not a member of this workspace");
1749 }
1750 Ok(SinglePackageSelection {
1751 manifest_path: graph.packages[idx].manifest_path.clone(),
1752 resolved_project: Some(graph.packages[idx].package.clone()),
1753 workspace_dep_requirements,
1754 })
1755}
1756
1757pub(crate) fn lock_mode_for_flags(locked: bool, frozen: bool) -> LockMode {
1758 if locked || frozen {
1759 LockMode::Locked
1760 } else {
1761 LockMode::PreferLocked
1762 }
1763}
1764
1765/// Resolve the cache directory using --cache-dir,
1766/// `$CABIN_CACHE_DIR`, or the user-global platform fallback.
1767///
1768/// Precedence: `--cache-dir` ▶ `$CABIN_CACHE_DIR` ▶
1769/// `$CABIN_CACHE_HOME` ▶ the platform base cache directory with a
1770/// `cabin` suffix (`$XDG_CACHE_HOME/cabin` / `~/.cache/cabin` on
1771/// Linux, `~/Library/Caches/cabin` on macOS, `%LOCALAPPDATA%\cabin`
1772/// on Windows). The fallback shape mirrors `cabin_config::discovery`
1773/// so the cache home and config home follow the same rule.
1774///
1775/// The cache is content-addressed (e.g. foundation-port archives
1776/// land at `<cache>/ports/archives/sha256/<hex>.tar.gz`), so the
1777/// user-global default lets two projects on the same machine
1778/// share a single download.
1779pub(crate) fn cache_dir_for(override_dir: Option<&Path>) -> Result<PathBuf> {
1780 let user_cache_home = directories::BaseDirs::new().map(|dirs| dirs.cache_dir().join("cabin"));
1781 cache_dir_for_with_env(
1782 override_dir,
1783 &|key| std::env::var_os(key),
1784 user_cache_home.as_deref(),
1785 )
1786}
1787
1788/// Inner form of [`cache_dir_for`] with the env lookup and the
1789/// platform user cache home injected so tests can drive the
1790/// precedence chain without touching real process env. Production
1791/// code calls [`cache_dir_for`].
1792fn cache_dir_for_with_env(
1793 override_dir: Option<&Path>,
1794 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1795 xdg_cache_home: Option<&Path>,
1796) -> Result<PathBuf> {
1797 if let Some(p) = override_dir {
1798 return absolutise(p)
1799 .with_context(|| format!("failed to resolve cache dir {}", p.display()));
1800 }
1801 if let Some(val) = env(cabin_env::CABIN_CACHE_DIR).filter(|v| !v.is_empty()) {
1802 let p = PathBuf::from(val);
1803 return absolutise(&p)
1804 .with_context(|| format!("failed to resolve cache dir {}", p.display()));
1805 }
1806 user_cache_default(env, xdg_cache_home).ok_or_else(|| {
1807 anyhow::anyhow!(
1808 "no cache directory: set --cache-dir, CABIN_CACHE_DIR, CABIN_CACHE_HOME, XDG_CACHE_HOME, or HOME"
1809 )
1810 })
1811}
1812
1813/// User-global cache root: `$CABIN_CACHE_HOME` if set, otherwise
1814/// the platform user cache home with the `cabin` suffix already
1815/// applied (see [`cache_dir_for`] for the per-OS shapes). The
1816/// `CABIN_CACHE_HOME` override is Cabin-specific and resolves
1817/// directly to its value with no extra `cabin` component.
1818fn user_cache_default(
1819 env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1820 xdg_cache_home: Option<&Path>,
1821) -> Option<PathBuf> {
1822 if let Some(d) = env(cabin_env::CABIN_CACHE_HOME).filter(|v| !v.is_empty()) {
1823 return Some(PathBuf::from(d));
1824 }
1825 xdg_cache_home.map(Path::to_path_buf)
1826}
1827
1828pub(crate) struct ArtifactPipelineRequest<'a> {
1829 pub(crate) manifest_path: &'a Path,
1830 pub(crate) initial_graph: &'a PackageGraph,
1831 pub(crate) index_path: Option<&'a Path>,
1832 pub(crate) index_url: Option<&'a str>,
1833 pub(crate) mode: LockMode,
1834 pub(crate) allow_write: bool,
1835 pub(crate) frozen: bool,
1836 pub(crate) cache_dir: &'a Path,
1837 pub(crate) reporter: Reporter,
1838 /// Workspace selection that contributes versioned deps
1839 /// to the resolution. Defaults to every primary package when
1840 /// the user passes no selection flags.
1841 pub(crate) selection: cabin_workspace::PackageSelection,
1842 /// Feature flags from the CLI. Drives optional-dependency
1843 /// inclusion.
1844 pub(crate) selection_request: &'a cabin_core::SelectionRequest,
1845 /// Names of patched packages — the pipeline must skip them
1846 /// because they ship from a local working copy and never need
1847 /// to be fetched from the index.
1848 pub(crate) patched_names: &'a BTreeSet<String>,
1849 /// Active patches recorded into the new lockfile and
1850 /// compared against the existing lockfile under `--locked`.
1851 pub(crate) active_patches: &'a cabin_workspace::ActivePatchSet,
1852 /// Active source-replacement entries (post-merge) recorded
1853 /// into the new lockfile.
1854 pub(crate) source_replacements: &'a cabin_core::SourceReplacementSettings,
1855 /// Whether `--no-patches` was supplied — suppresses
1856 /// source-replacement records on the lockfile to match the
1857 /// "no local override policy" semantics.
1858 pub(crate) no_patches: bool,
1859 /// Names of packages whose `[dev-dependencies]` should be
1860 /// activated for this invocation. Empty for `cabin build`;
1861 /// `cabin test` passes the selected primary packages' names
1862 /// so the resolver / fetch path picks up dev-deps the test
1863 /// executables need.
1864 pub(crate) dev_for: &'a BTreeSet<String>,
1865}
1866
1867pub(crate) struct ArtifactPipeline {
1868 pub(crate) fetched: Vec<FetchedPackage>,
1869}
1870
1871impl ArtifactPipeline {
1872 /// Project each fetched package into the
1873 /// [`RegistryPackageSource`] the workspace loader consumes,
1874 /// pinning every manifest at `<source_dir>/cabin.toml`. Shared
1875 /// by `build` / `run` / `test`, which all feed the fetched
1876 /// closure back into a strict workspace reload.
1877 pub(crate) fn registry_sources(&self) -> Vec<RegistryPackageSource> {
1878 self.fetched
1879 .iter()
1880 .map(|p| RegistryPackageSource {
1881 name: p.name.clone(),
1882 version: p.version.clone(),
1883 manifest_path: p.source_dir.join("cabin.toml"),
1884 })
1885 .collect()
1886 }
1887}
1888
1889/// Resolved index access: either a directory on disk we already
1890/// turned into a [`PackageIndex`], or a live HTTP client we will use
1891/// to download artifacts.
1892enum IndexAccess {
1893 Local,
1894 Http(cabin_index_http::HttpClient),
1895}
1896
1897/// Run the resolve → lockfile → fetch pipeline used by both
1898/// `cabin fetch` and `cabin build`.
1899pub(crate) fn run_artifact_pipeline(
1900 request: &ArtifactPipelineRequest<'_>,
1901) -> Result<ArtifactPipeline> {
1902 let manifest_path = request.manifest_path;
1903 let graph = request.initial_graph;
1904 let resolved_selection = selected_resolution_packages(graph, &request.selection)?;
1905 let features =
1906 compute_feature_resolution(graph, &resolved_selection, request.selection_request)?;
1907 let mut root_deps = collect_closure_versioned_deps_excluding_patches(
1908 graph,
1909 &resolved_selection,
1910 &features,
1911 request.patched_names,
1912 request.dev_for,
1913 )?;
1914 // Patched manifests are not part of the workspace graph at
1915 // this point, so their own `[dependencies]` never appeared
1916 // in the closure walk. Fold them in so a workspace whose only
1917 // versioned dep is patched still resolves and fetches the
1918 // patched manifest's transitive registry edges.
1919 let patched_root_deps =
1920 collect_patched_versioned_deps(request.active_patches, request.patched_names)?;
1921 merge_versioned_deps(&mut root_deps, patched_root_deps)?;
1922 // short-circuit when neither the selected closure nor the
1923 // active patch set introduces a versioned dependency.
1924 // Loading an index, walking the lockfile, and downloading
1925 // artifacts are all unnecessary in that case.
1926 if root_deps.is_empty() {
1927 return Ok(ArtifactPipeline {
1928 fetched: Vec::new(),
1929 });
1930 }
1931 // pick a stable synthetic root identity for pure
1932 // workspace roots; fall back to the [package] root otherwise.
1933 let (root_name, root_version) = match graph.root_package {
1934 Some(idx) => (
1935 graph.packages[idx].package.name.clone(),
1936 graph.packages[idx].package.version.clone(),
1937 ),
1938 None => cabin_workspace::synthetic_root_identity(graph),
1939 };
1940
1941 let lockfile_path = lockfile_path_for(manifest_path);
1942
1943 let existing_lockfile: Option<Lockfile> = if lockfile_path.is_file() {
1944 Some(
1945 cabin_lockfile::read_lockfile(&lockfile_path)
1946 .with_context(|| format!("failed to read {}", lockfile_path.display()))?,
1947 )
1948 } else {
1949 if matches!(request.mode, LockMode::Locked) {
1950 bail!(
1951 "cannot resolve with --locked because {} does not exist",
1952 lockfile_path.display()
1953 );
1954 }
1955 None
1956 };
1957
1958 let (index, access) = load_index_for_pipeline(
1959 request.index_path,
1960 request.index_url,
1961 request.frozen,
1962 &root_deps,
1963 )?;
1964
1965 let resolver_mode = match &request.mode {
1966 LockMode::PreferLocked => ResolveMode::PreferLocked,
1967 LockMode::Locked => ResolveMode::Locked,
1968 LockMode::UpdateAll => ResolveMode::UpdateAll,
1969 LockMode::UpdatePackage(name) => ResolveMode::UpdatePackage(
1970 PackageName::new(name.clone())
1971 .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?,
1972 ),
1973 };
1974
1975 let mut input = ResolveInput::new(root_name, root_version, root_deps);
1976 if let Some(lock) = &existing_lockfile {
1977 for pkg in &lock.packages {
1978 input.locked.insert(
1979 pkg.name.clone(),
1980 LockedVersion {
1981 version: pkg.version.clone(),
1982 checksum: pkg.checksum.clone(),
1983 },
1984 );
1985 }
1986 }
1987 input.mode = resolver_mode;
1988
1989 // Patch / source-replacement state recorded into the new
1990 // lockfile and compared against the existing lockfile under
1991 // `--locked`.
1992 let active_patch_records = crate::cli::patch::lockfile_patches(request.active_patches);
1993 let active_replacement_records = crate::cli::patch::lockfile_source_replacements(
1994 request.source_replacements,
1995 request.no_patches,
1996 );
1997 if matches!(request.mode, LockMode::Locked)
1998 && let Some(prev) = &existing_lockfile
1999 && !prev.matches_patch_state(&active_patch_records, &active_replacement_records)
2000 {
2001 bail!(
2002 "--locked cannot be used because active patch / source-replacement policy differs from {}; re-run without --locked to refresh the lockfile",
2003 lockfile_path.display()
2004 );
2005 }
2006
2007 let output = cabin_resolver::resolve(&input, &index).context("dependency resolution failed")?;
2008
2009 let mut new_lockfile = lockfile_from_resolution(&output, &index);
2010 new_lockfile.patches = active_patch_records;
2011 new_lockfile.source_replacements = active_replacement_records;
2012
2013 if request.allow_write {
2014 let needs_write = match &existing_lockfile {
2015 Some(prev) => prev != &new_lockfile,
2016 None => true,
2017 };
2018 if needs_write {
2019 cabin_lockfile::write_lockfile(&lockfile_path, &new_lockfile)
2020 .with_context(|| format!("failed to write {}", lockfile_path.display()))?;
2021 request
2022 .reporter
2023 .aux_verbose(format_args!("cabin: wrote {}", lockfile_path.display()));
2024 } else {
2025 request.reporter.aux_verbose(format_args!(
2026 "cabin: {} is up to date",
2027 lockfile_path.display()
2028 ));
2029 }
2030 }
2031
2032 let plan = build_fetch_plan(&output, &index, &access)?;
2033 let cache = ArtifactCache::new(request.cache_dir);
2034 let result = cabin_artifact::fetch(
2035 &plan,
2036 &cache,
2037 FetchOptions {
2038 frozen: request.frozen,
2039 },
2040 )?;
2041 Ok(ArtifactPipeline {
2042 fetched: result.packages,
2043 })
2044}
2045
2046/// Pick the right index source for a fetch / build run, validate
2047/// CLI flag combinations, and return both the [`PackageIndex`] the
2048/// Resolver consumes and a tag describing which access mode the
2049/// fetch plan should use.
2050fn load_index_for_pipeline(
2051 index_path: Option<&Path>,
2052 index_url: Option<&str>,
2053 frozen: bool,
2054 root_deps: &BTreeMap<PackageName, semver::VersionReq>,
2055) -> Result<(PackageIndex, IndexAccess)> {
2056 match (index_path, index_url) {
2057 (Some(_), Some(_)) => bail!("use either --index-path or --index-url, not both"),
2058 (None, None) => {
2059 bail!("versioned dependencies require --index-path or --index-url")
2060 }
2061 (Some(path), None) => {
2062 let index_path = absolutise(path)
2063 .with_context(|| format!("failed to resolve {}", path.display()))?;
2064 let index = cabin_index::load_index(&index_path)
2065 .with_context(|| format!("failed to load index at {}", index_path.display()))?;
2066 Ok((index, IndexAccess::Local))
2067 }
2068 (None, Some(url)) => {
2069 if frozen {
2070 bail!(
2071 "cannot use --index-url with --frozen: there is no persistent HTTP index metadata cache, so a frozen run would have to perform network fetches it is not allowed to perform"
2072 );
2073 }
2074 let client = cabin_index_http::HttpClient::new();
2075 let http_index = cabin_index_http::HttpIndex::open(url, client.clone())?;
2076 let names: Vec<PackageName> = root_deps.keys().cloned().collect();
2077 let index = http_index.load_package_index(&names)?;
2078 Ok((index, IndexAccess::Http(client)))
2079 }
2080 }
2081}
2082
2083/// Build a [`FetchPlan`] from a resolver output and the index it ran
2084/// against. Each resolved registry package contributes exactly one
2085/// fetch entry; the index is the source of truth for `source` and
2086/// `checksum`.
2087///
2088/// `access` decides whether HTTP-resolved sources get downloaded
2089/// here (so `cabin-artifact` stays HTTP-free) or whether the source
2090/// Path is handed straight through as a local file.
2091fn build_fetch_plan(
2092 output: &ResolveOutput,
2093 index: &PackageIndex,
2094 access: &IndexAccess,
2095) -> Result<FetchPlan> {
2096 let mut entries = Vec::new();
2097 for resolved in &output.packages {
2098 if resolved.source != ResolvedSource::Index {
2099 continue;
2100 }
2101 let entry = index.package(&resolved.name).ok_or_else(|| {
2102 anyhow::anyhow!(
2103 "resolver chose `{} {}`, but it is not in the index",
2104 resolved.name.as_str(),
2105 resolved.version
2106 )
2107 })?;
2108 let meta = entry.versions.get(&resolved.version).ok_or_else(|| {
2109 anyhow::anyhow!(
2110 "resolver chose `{} {}`, but the index has no entry for this version",
2111 resolved.name.as_str(),
2112 resolved.version
2113 )
2114 })?;
2115 let source = meta.source.as_ref().ok_or_else(|| {
2116 anyhow::anyhow!(
2117 "package `{} {}` has no source artifact in the index",
2118 resolved.name.as_str(),
2119 resolved.version
2120 )
2121 })?;
2122 let checksum = meta.checksum.clone().ok_or_else(|| {
2123 anyhow::anyhow!(
2124 "missing checksum for `{} {}`; cabin fetch requires a sha256:<hex> entry in the index",
2125 resolved.name.as_str(),
2126 resolved.version
2127 )
2128 })?;
2129 let fetch_source = match (&source.location, access) {
2130 (cabin_index::SourceLocation::LocalPath(p), _) => {
2131 cabin_artifact::FetchSource::LocalArchive(p.clone())
2132 }
2133 (cabin_index::SourceLocation::HttpUrl(url), IndexAccess::Http(client)) => {
2134 let label = format!("{} {}", resolved.name.as_str(), resolved.version);
2135 let bytes = client.download(url, &label).with_context(|| {
2136 format!(
2137 "failed to download source archive for `{} {}`",
2138 resolved.name.as_str(),
2139 resolved.version
2140 )
2141 })?;
2142 cabin_artifact::FetchSource::InMemoryArchive(bytes)
2143 }
2144 (cabin_index::SourceLocation::HttpUrl(_), IndexAccess::Local) => {
2145 bail!(
2146 "package `{} {}` has an HTTP source URL but the run is using a local index",
2147 resolved.name.as_str(),
2148 resolved.version
2149 );
2150 }
2151 };
2152 entries.push(FetchEntry {
2153 name: resolved.name.clone(),
2154 version: resolved.version.clone(),
2155 checksum,
2156 source: fetch_source,
2157 });
2158 }
2159 Ok(FetchPlan { entries })
2160}
2161
2162/// What kind of resolution the CLI is asking for.
2163#[derive(Debug, Clone)]
2164pub(crate) enum LockMode {
2165 PreferLocked,
2166 Locked,
2167 UpdateAll,
2168 UpdatePackage(String),
2169}
2170
2171pub(crate) fn lockfile_path_for(manifest_path: &Path) -> PathBuf {
2172 manifest_path
2173 .parent()
2174 .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
2175 .join("cabin.lock")
2176}
2177
2178/// Read the lockfile at `lockfile_path` if it exists, attaching a
2179/// read-error context that names the path. Returns `Ok(None)` when
2180/// the file is absent. Shared by the read-only inspection commands
2181/// (`metadata` / `tree` / `explain`); the commands that enforce
2182/// `--locked` keep their own bespoke read so the missing-lockfile
2183/// case stays a hard error there.
2184pub(crate) fn read_optional_lockfile(lockfile_path: &Path) -> Result<Option<Lockfile>> {
2185 if lockfile_path.is_file() {
2186 Ok(Some(
2187 cabin_lockfile::read_lockfile(lockfile_path)
2188 .with_context(|| format!("failed to read {}", lockfile_path.display()))?,
2189 ))
2190 } else {
2191 Ok(None)
2192 }
2193}
2194
2195fn lockfile_from_resolution(output: &ResolveOutput, index: &cabin_index::PackageIndex) -> Lockfile {
2196 // We need each resolved package's transitive deps to write the
2197 // lockfile's `dependencies = [...]` field. The resolver doesn't
2198 // surface the dep edges directly, so we read them off the index
2199 // entry for the chosen version.
2200 let resolved_names: BTreeSet<&str> = output
2201 .packages
2202 .iter()
2203 .filter(|p| p.source == ResolvedSource::Index)
2204 .map(|p| p.name.as_str())
2205 .collect();
2206 let mut packages: Vec<LockedPackage> = Vec::new();
2207 for pkg in &output.packages {
2208 if pkg.source != ResolvedSource::Index {
2209 continue;
2210 }
2211 let entry = index
2212 .package(&pkg.name)
2213 .expect("index has every resolved package");
2214 let meta = entry
2215 .versions
2216 .get(&pkg.version)
2217 .expect("index has the resolved version");
2218 // Filter to only dep names that are also resolved (defensive).
2219 let mut deps: Vec<PackageName> = meta
2220 .dependencies
2221 .keys()
2222 .filter(|n| resolved_names.contains(n.as_str()))
2223 .cloned()
2224 .collect();
2225 deps.sort();
2226 packages.push(LockedPackage {
2227 name: pkg.name.clone(),
2228 version: pkg.version.clone(),
2229 source: LockedSource::Index,
2230 checksum: meta.checksum.clone(),
2231 dependencies: deps,
2232 });
2233 }
2234 packages.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
2235 Lockfile {
2236 version: cabin_lockfile::LOCKFILE_VERSION,
2237 packages,
2238 patches: Vec::new(),
2239 source_replacements: Vec::new(),
2240 }
2241}
2242
2243/// Resolve a path to an absolute one without requiring it to exist.
2244pub(crate) fn absolutise(path: &Path) -> std::io::Result<PathBuf> {
2245 if path.is_absolute() {
2246 Ok(path.to_path_buf())
2247 } else {
2248 Ok(std::env::current_dir()?.join(path))
2249 }
2250}
2251
2252#[cfg(test)]
2253mod tests {
2254 use super::*;
2255
2256 #[test]
2257 fn rendered_binary_template_round_trips_through_parser() {
2258 let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Binary);
2259 let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap();
2260 let package = parsed.package.expect("template should parse as a package");
2261 assert_eq!(package.name.as_str(), "hello");
2262 assert_eq!(package.targets.len(), 1);
2263 assert_eq!(package.targets[0].name.as_str(), "hello");
2264 }
2265
2266 #[test]
2267 fn rendered_library_template_round_trips_through_parser() {
2268 let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Library);
2269 let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap();
2270 let package = parsed.package.expect("template should parse as a package");
2271 assert_eq!(package.name.as_str(), "hello");
2272 assert_eq!(package.targets.len(), 1);
2273 assert_eq!(package.targets[0].name.as_str(), "hello");
2274 }
2275
2276 #[test]
2277 fn registry_dependency_build_flags_are_dropped_but_local_kept() {
2278 use cabin_core::{Package, Target};
2279 use cabin_workspace::{PackageKind, WorkspacePackage};
2280 use std::path::PathBuf;
2281
2282 fn dep_with_command_flags(name: &str, kind: PackageKind) -> WorkspacePackage {
2283 let mut package = Package::new(
2284 PackageName::new(name).unwrap(),
2285 semver::Version::parse("0.1.0").unwrap(),
2286 Vec::<Target>::new(),
2287 Vec::new(),
2288 )
2289 .unwrap();
2290 package.build.general.cflags = vec!["-fplugin=evil.so".into()];
2291 package.build.general.cxxflags = vec!["-B.".into()];
2292 package.build.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
2293 WorkspacePackage {
2294 package,
2295 manifest_dir: PathBuf::from("/tmp"),
2296 manifest_path: PathBuf::from("/tmp/cabin.toml"),
2297 kind,
2298 deps: Vec::new(),
2299 is_port: false,
2300 }
2301 }
2302
2303 let graph = PackageGraph {
2304 root_manifest_path: PathBuf::from("/tmp/cabin.toml"),
2305 root_dir: PathBuf::from("/tmp"),
2306 is_workspace_root: false,
2307 root_package: Some(0),
2308 root_settings: Default::default(),
2309 primary_packages: vec![0],
2310 default_members: vec![0],
2311 excluded_members: Vec::new(),
2312 packages: vec![
2313 dep_with_command_flags("local_dep", PackageKind::Local),
2314 dep_with_command_flags("registry_dep", PackageKind::Registry),
2315 ],
2316 };
2317
2318 let host = cabin_core::TargetPlatform::current();
2319 let (resolved, _conflicts) = resolve_per_package_build_flags(
2320 &graph,
2321 None,
2322 &host,
2323 &cabin_feature::FeatureResolution::default(),
2324 None,
2325 );
2326
2327 // A local package (workspace member / path dependency) is trusted:
2328 // its declared compiler and linker flags are preserved.
2329 let local = resolved.get(&0).expect("local package flags");
2330 assert_eq!(local.cflags, vec!["-fplugin=evil.so".to_owned()]);
2331 assert_eq!(local.cxxflags, vec!["-B.".to_owned()]);
2332 assert_eq!(local.ldflags, vec!["-fuse-ld=/tmp/evil".to_owned()]);
2333
2334 // A registry dependency is untrusted: its compiler and linker flags
2335 // are dropped so it cannot execute code at build time.
2336 let registry = resolved.get(&1).expect("registry package flags");
2337 assert!(registry.cflags.is_empty());
2338 assert!(registry.cxxflags.is_empty());
2339 assert!(registry.ldflags.is_empty());
2340 }
2341
2342 // -------------------------------------------------------------
2343 // cache_dir_for precedence (XDG user-global default)
2344 // -------------------------------------------------------------
2345
2346 type EnvFn = Box<dyn Fn(&str) -> Option<std::ffi::OsString>>;
2347
2348 /// Build an env-lookup closure backed by a fixed map. Mirrors
2349 /// the `env_with` helper in `cabin-config::discovery::tests`
2350 /// so the cache-dir tests look like the sibling config-dir
2351 /// tests they parallel.
2352 fn env_with(items: &[(&'static str, &str)]) -> EnvFn {
2353 let map: std::collections::HashMap<&'static str, std::ffi::OsString> = items
2354 .iter()
2355 .map(|(k, v)| (*k, std::ffi::OsString::from(*v)))
2356 .collect();
2357 Box::new(move |k| map.get(k).cloned())
2358 }
2359
2360 /// The user cache home (`<HOME>/.cache/cabin`) Cabin resolves on
2361 /// Linux when `HOME` is `home` and `XDG_CACHE_HOME` is unset.
2362 /// Tests inject this so they exercise the fallback chain without
2363 /// mutating the process environment.
2364 fn home_xdg_cache_home(home: &str) -> PathBuf {
2365 PathBuf::from(home).join(".cache").join("cabin")
2366 }
2367
2368 #[test]
2369 fn cache_dir_flag_wins_over_everything() {
2370 let env = env_with(&[
2371 ("CABIN_CACHE_DIR", "/tmp/from-env"),
2372 ("CABIN_CACHE_HOME", "/tmp/cabin-home"),
2373 ]);
2374 let xdg = PathBuf::from("/tmp/xdg/cabin");
2375 let out =
2376 cache_dir_for_with_env(Some(Path::new("/tmp/from-flag")), &env, Some(&xdg)).unwrap();
2377 // The `--cache-dir` and `CABIN_CACHE_DIR` arms absolutise
2378 // their value; compare against the same absolutisation so the
2379 // assertion holds on Windows (where `/tmp/from-flag` gains the
2380 // current drive) as well as on Unix.
2381 assert_eq!(out, absolutise(Path::new("/tmp/from-flag")).unwrap());
2382 }
2383
2384 #[test]
2385 fn cabin_cache_dir_env_wins_over_xdg() {
2386 let env = env_with(&[
2387 ("CABIN_CACHE_DIR", "/tmp/from-env"),
2388 ("CABIN_CACHE_HOME", "/tmp/cabin-home"),
2389 ]);
2390 let xdg = PathBuf::from("/tmp/xdg/cabin");
2391 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2392 assert_eq!(out, absolutise(Path::new("/tmp/from-env")).unwrap());
2393 }
2394
2395 #[test]
2396 fn cabin_cache_home_used_when_cabin_cache_dir_unset() {
2397 // CABIN_CACHE_HOME is a Cabin-specific override: it
2398 // resolves directly to its value with no `cabin`
2399 // segment appended, and it wins over the xdg-resolved
2400 // path.
2401 let env = env_with(&[("CABIN_CACHE_HOME", "/tmp/cabin-home")]);
2402 let xdg = PathBuf::from("/tmp/xdg/cabin");
2403 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2404 assert_eq!(out, PathBuf::from("/tmp/cabin-home"));
2405 }
2406
2407 #[test]
2408 fn xdg_cache_home_appends_cabin_segment() {
2409 // The injected `xdg_cache_home` represents the resolved user
2410 // cache home: the `cabin` segment is already applied, so
2411 // Cabin uses it verbatim.
2412 let env = env_with(&[]);
2413 let xdg = PathBuf::from("/tmp/xdg/cabin");
2414 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2415 assert_eq!(out, PathBuf::from("/tmp/xdg/cabin"));
2416 }
2417
2418 #[test]
2419 fn home_cache_fallback_used_when_xdg_unset() {
2420 // When `XDG_CACHE_HOME` is unset, `xdg` falls back to
2421 // `$HOME/.cache`; the injected path simulates that.
2422 let env = env_with(&[]);
2423 let xdg = home_xdg_cache_home("/tmp/home");
2424 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2425 assert_eq!(out, PathBuf::from("/tmp/home/.cache/cabin"));
2426 }
2427
2428 #[test]
2429 fn empty_cabin_cache_dir_value_falls_through() {
2430 // Empty-as-unset rule for CABIN_CACHE_DIR so a shell
2431 // that exports the variable as empty doesn't
2432 // short-circuit the XDG fallback.
2433 let env = env_with(&[("CABIN_CACHE_DIR", "")]);
2434 let xdg = home_xdg_cache_home("/tmp/home");
2435 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2436 assert_eq!(out, PathBuf::from("/tmp/home/.cache/cabin"));
2437 }
2438
2439 #[test]
2440 fn empty_cabin_cache_home_value_falls_through_to_xdg() {
2441 // Same empty-as-unset rule for CABIN_CACHE_HOME so a
2442 // shell that exports the variable as empty doesn't
2443 // short-circuit the XDG fallback.
2444 let env = env_with(&[("CABIN_CACHE_HOME", "")]);
2445 let xdg = PathBuf::from("/tmp/xdg/cabin");
2446 let out = cache_dir_for_with_env(None, &env, Some(&xdg)).unwrap();
2447 assert_eq!(out, PathBuf::from("/tmp/xdg/cabin"));
2448 }
2449
2450 #[test]
2451 fn all_envs_unset_returns_error() {
2452 // No CABIN_CACHE_HOME, no CABIN_CACHE_DIR, no xdg-resolved
2453 // path (e.g. HOME and XDG_CACHE_HOME both unset on the host).
2454 let env = env_with(&[]);
2455 let err = cache_dir_for_with_env(None, &env, None).unwrap_err();
2456 let msg = err.to_string();
2457 assert!(
2458 msg.contains("no cache directory"),
2459 "expected diagnostic mentioning 'no cache directory', got: {msg}"
2460 );
2461 }
2462}