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