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