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