Skip to main content

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}