Skip to main content

cabin/
cli.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::{
14    LockedVersion, ResolveInput, ResolveMode, ResolveOutput, ResolvedPackage, ResolvedSource,
15};
16use cabin_workspace::{PackageGraph, RegistryPackageSource, collect_patched_versioned_deps};
17
18use crate::completions::CompgenArgs;
19use crate::fetch_output_glue::emit_fetch_output;
20use crate::manpages::MangenArgs;
21use crate::metadata_glue::{MetadataInputs, MetadataView};
22use crate::term_color_glue::CliColorChoice;
23use crate::term_verbosity_glue::Reporter;
24
25/// Cargo-style color palette for clap's help / error
26/// rendering.  Mirrors the ANSI sequences `cargo --help
27/// --color always` emits today: bold + bright green for the
28/// section headings and the `Usage:` line, bold + bright cyan
29/// for literal tokens (the binary name, flag and subcommand
30/// names), plain cyan for value placeholders such as
31/// `<NAME>` / `[OPTIONS]`, bold + bright red for `error:`
32/// labels, and bold + yellow for the highlighted-invalid
33/// token inside diagnostic messages.
34fn cli_styles() -> clap::builder::Styles {
35    use clap::builder::styling::{AnsiColor, Color, Style};
36
37    let header_usage = Style::new()
38        .bold()
39        .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)));
40    let literal = Style::new()
41        .bold()
42        .fg_color(Some(Color::Ansi(AnsiColor::BrightCyan)));
43    let placeholder = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
44    let error = Style::new()
45        .bold()
46        .fg_color(Some(Color::Ansi(AnsiColor::BrightRed)));
47    let invalid = Style::new()
48        .bold()
49        .fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
50    let valid = Style::new()
51        .bold()
52        .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)));
53
54    clap::builder::Styles::styled()
55        .usage(header_usage)
56        .header(header_usage)
57        .literal(literal)
58        .placeholder(placeholder)
59        .error(error)
60        .invalid(invalid)
61        .valid(valid)
62}
63
64/// Top-level help template — mirrors `cargo --help`:
65///
66/// - the `Options:` block comes before the `Commands:` block
67///   so the short list of global flags is on screen first;
68/// - the section headings (`Options:`, `Commands:`) carry the
69///   same bold + bright-green styling clap applies to
70///   `Usage:`.  The embedded ANSI escapes are stripped by
71///   anstream when color is disabled (`--color never`,
72///   `NO_COLOR`, or a non-TTY stdout).
73///
74/// `{options}` renders the options block body only.  The
75/// subcommand block is omitted because the default `[aliases:
76/// x]` rendering does not match cargo's `name, alias` style;
77/// the dispatcher in `lib.rs::run` rebuilds the subcommand
78/// rows manually and feeds them in via `after_help`.
79const HELP_TEMPLATE: &str = concat!(
80    "{about-with-newline}\n",
81    "{usage-heading} {usage}\n",
82    "\n",
83    // Bold + bright green, like clap's auto `Usage:` style.
84    "\x1b[1m\x1b[92mOptions:\x1b[0m\n",
85    "{options}",
86    "{after-help}",
87);
88
89/// Top-level Cabin CLI parser.
90#[derive(Debug, Parser)]
91#[command(
92    name = "cabin",
93    about = "A package manager and build system for C/C++",
94    disable_version_flag = true,
95    styles = cli_styles(),
96    help_template = HELP_TEMPLATE,
97    // Compact, cargo-style option rows: keep the description
98    // inline with the flag name rather than dropping it to
99    // its own line for every entry.
100    next_line_help = false,
101)]
102pub struct Cli {
103    /// Use verbose output (-vv very verbose output).
104    //
105    // `ArgAction::Count` collects repeated `-v` occurrences;
106    // counts of two or more clamp to `Verbosity::VeryVerbose`.
107    #[arg(
108        short = 'v',
109        long = "verbose",
110        global = true,
111        action = clap::ArgAction::Count,
112        conflicts_with = "quiet",
113        display_order = 1,
114    )]
115    pub(crate) verbose: u8,
116
117    /// Do not print cabin log messages.
118    #[arg(
119        short = 'q',
120        long = "quiet",
121        global = true,
122        conflicts_with = "verbose",
123        display_order = 2
124    )]
125    pub(crate) quiet: bool,
126
127    /// Coloring: auto, always, never [default: auto]
128    //
129    // Single-line rustdoc keeps `cabin --help` compact.  The
130    // literal "[default: auto]" is part of the description
131    // because clap does not render a `default_value` for
132    // `Option<...>` enum flags.
133    //
134    // Precedence is `--color` > `CABIN_TERM_COLOR` >
135    // `[term] color` config > `auto`; see
136    // `docs/environment-variables.md` for the full table.
137    #[arg(
138        long,
139        value_name = "WHEN",
140        value_enum,
141        global = true,
142        hide_possible_values = true,
143        display_order = 3
144    )]
145    pub(crate) color: Option<CliColorChoice>,
146
147    /// List installed commands.
148    //
149    // The dispatcher short-circuits on this flag before
150    // touching `cli.command`, so combining it with a
151    // subcommand silently ignores the subcommand.  The flag
152    // intentionally co-exists with global flags like
153    // `--color` so `cabin --color always --list` renders the
154    // listing with the requested color treatment.
155    #[arg(long, display_order = 4)]
156    pub(crate) list: bool,
157
158    /// Print version info and exit.
159    //
160    // Replaces clap's auto `--version` so the flag can route
161    // through `cabin version`'s dispatcher: `cabin --version`
162    // prints the concise line and `cabin --version --verbose`
163    // prints the same key/value block `cabin version -v`
164    // emits.  Display order keeps the `-h, -V` pair adjacent.
165    #[arg(
166        short = 'V',
167        long = "version",
168        global = true,
169        action = clap::ArgAction::SetTrue,
170        display_order = 6,
171    )]
172    pub(crate) version: bool,
173
174    // The subcommand is `Option<...>` so `cabin --list` and
175    // `cabin --version` keep working without one.  The
176    // dispatcher prints the curated help and exits cleanly when
177    // both `--list` is unset and `command` is `None`.
178    #[command(subcommand)]
179    pub(crate) command: Option<Command>,
180}
181
182// `cabin --help` is the curated, day-to-day surface and
183// closely mirrors `cargo --help`.  Subcommands tagged
184// `#[command(hide = true)]` below stay fully functional but
185// surface only through `cabin --list`, `cabin <sub> --help`,
186// shell completions, and per-subcommand man pages.
187//
188// Curation pattern (matching cargo --help):
189// - hide inspection-only commands (`metadata`, `tree`,
190//   `explain`) — useful for scripts / CI, rarely typed
191//   day-to-day;
192// - hide low-level / scripting commands (`resolve`) —
193//   `cabin metadata` and `cabin update` are the user-facing
194//   paths;
195// - hide offline / networking helpers (`fetch`, `vendor`) —
196//   triggered automatically when needed;
197// - hide pre-publish packaging (`package`) — `publish` is
198//   the user-facing entry;
199// - hide distribution helpers (`compgen`, `mangen`) — aimed
200//   at downstream packagers.
201//
202// `version` stays visible because it is a direct user-facing
203// command; `cabin --version` and `cabin version`
204// agree on the concise wording.
205// Each subcommand's rustdoc has two paragraphs: the first is
206// the short summary clap renders in `cabin --help` / `cabin
207// --list`, and the rest becomes the long help shown by `cabin
208// <sub> --help`.  The split keeps the top-level surface
209// skimmable while preserving the existing detailed prose.
210#[derive(Debug, Subcommand)]
211pub(crate) enum Command {
212    /// Create a new cabin package in an existing directory.
213    Init(InitArgs),
214    /// Create a new cabin package.
215    ///
216    /// Scaffolds a new package at `<PATH>`.  The directory must
217    /// not already exist.
218    New(NewArgs),
219    /// Output workspace metadata as JSON.
220    ///
221    /// Prints the loaded workspace graph, selected build
222    /// configuration view, and lockfile state (if any) in
223    /// machine-readable form. Use this for tooling / scripts;
224    /// the human-facing inspection commands are `cabin tree`
225    /// and `cabin explain`.
226    #[command(hide = true)]
227    Metadata(ManifestArgs),
228    /// Compile a local package and all of its dependencies.
229    ///
230    /// Plans the build, writes `build.ninja` plus a
231    /// Clang-compatible `compile_commands.json`, and invokes
232    /// Ninja.
233    //
234    // `visible_alias = "b"` matches cargo's `build, b`
235    // rendering: clap auto-renders the alias next to the
236    // canonical name in `cabin --help` / `cabin --list`, and
237    // `cabin b` is parsed identically to `cabin build`.
238    #[command(visible_alias = "b")]
239    Build(BuildArgs),
240    /// Remove the built directory.
241    ///
242    /// Deletes Cabin-generated build artifacts under the
243    /// resolved `--build-dir`.  Source files are never
244    /// touched.
245    Clean(CleanArgs),
246    /// Run a binary of the local package.
247    ///
248    /// Builds the selected `executable` target and executes
249    /// it. Arguments after `--` are forwarded verbatim to the
250    /// executed program.
251    #[command(visible_alias = "r")]
252    Run(crate::run_glue::RunArgs),
253    /// Run the tests of a local package.
254    ///
255    /// Builds the workspace's `test` targets and executes
256    /// each one with a deterministic per-test `CABIN_*`
257    /// environment overlay.
258    #[command(visible_alias = "t")]
259    Test(crate::test_glue::TestArgs),
260    /// Resolve versioned dependencies.
261    ///
262    /// Resolves the manifest's versioned dependencies against
263    /// a local JSON package index and prints the result.
264    /// Most users prefer `cabin metadata` or `cabin update`.
265    #[command(hide = true)]
266    Resolve(ResolveArgs),
267    /// Update dependencies as recorded in `cabin.lock`.
268    Update(UpdateArgs),
269    /// Fetch registry dependencies into the artifact cache.
270    ///
271    /// Fetches, verifies, and extracts the source archives of
272    /// resolved registry dependencies. Triggered
273    /// automatically by `cabin build`, `cabin run`, and
274    /// `cabin test`; use this command to warm the cache.
275    #[command(hide = true)]
276    Fetch(FetchArgs),
277    /// Vendor external versioned dependencies locally.
278    ///
279    /// Materializes the selected external registry dependency
280    /// closure into a deterministic local file-registry directory
281    /// for offline use. Local path dependencies stay local.
282    /// Combine with `--offline --index-path <vendor-dir>` on
283    /// subsequent commands.
284    #[command(hide = true)]
285    Vendor(crate::vendor_glue::VendorArgs),
286    /// Display the dependency tree.
287    ///
288    /// Renders the loaded workspace / local-path dependency
289    /// graph as a tree (human or JSON). Workspace, feature,
290    /// kind-filter, and patch flags affect this view; option and
291    /// variant selectors are build-configuration inputs and do
292    /// not change the tree.
293    #[command(hide = true)]
294    Tree(crate::tree_glue::TreeArgs),
295    /// Explain a loaded package, target, source, or feature.
296    ///
297    /// Package, target, source, and feature subcommands map to
298    /// the typed explanation model in `cabin-explain`.
299    /// `build-config` reuses the same resolved configuration
300    /// shape as `cabin metadata`.
301    #[command(hide = true)]
302    Explain(crate::explain_glue::ExplainArgs),
303    /// Assemble the local package into a distributable archive.
304    ///
305    /// Builds a deterministic source archive plus canonical
306    /// metadata for the package at `--manifest-path`.
307    /// Typically driven by `cabin publish`.
308    #[command(hide = true)]
309    Package(PackageArgs),
310    /// Publish a package to a local file registry.
311    ///
312    /// With `--registry-dir <PATH>`, writes the archive plus
313    /// canonical metadata into a Cabin file registry. With
314    /// `--dry-run` alone, stages the same artifacts under
315    /// `--output-dir` without touching any registry. Remote
316    /// registry protocols are not supported.
317    Publish(PublishArgs),
318    /// Format codes using clang-format.
319    ///
320    /// Walks the workspace's C/C++ sources and rewrites
321    /// them in place using the user's `clang-format`.
322    Fmt(crate::fmt_glue::FmtArgs),
323    /// Run clang-tidy.
324    ///
325    /// Drives `run-clang-tidy` over the workspace's C/C++
326    /// sources using the generated `compile_commands.json`.
327    Tidy(crate::tidy_glue::TidyArgs),
328    /// List or inspect bundled foundation-port recipes.
329    Port(crate::port_subcommand::PortArgs),
330    /// Generate shell completion scripts for the `cabin` CLI.
331    #[command(hide = true)]
332    Compgen(CompgenArgs),
333    /// Generate man pages for the `cabin` CLI.
334    #[command(hide = true)]
335    Mangen(MangenArgs),
336    /// Show version information.
337    ///
338    /// Without flags, prints the concise release name (same
339    /// wording as `cabin --version`). With `-v` /
340    /// `--verbose`, prints a stable key/value block describing
341    /// the build (`release`, `commit-hash`, `commit-date`,
342    /// `host`, `os`); rows whose underlying value is unknown
343    /// are omitted.
344    Version(crate::version_glue::VersionArgs),
345}
346
347#[derive(Debug, Args)]
348pub(crate) struct InitArgs {
349    /// Package name. Defaults to the current directory name.
350    #[arg(long)]
351    pub name: Option<String>,
352
353    /// Use a binary (application) template [default].
354    ///
355    /// Conflicts with `--lib`.
356    #[arg(short = 'b', long, group = "init_scaffold_kind")]
357    pub bin: bool,
358
359    /// Use a library template.
360    ///
361    /// Conflicts with `--bin`.
362    #[arg(short = 'l', long, group = "init_scaffold_kind")]
363    pub lib: bool,
364}
365
366#[derive(Debug, Args)]
367pub(crate) struct NewArgs {
368    /// Path of the new package directory. The directory must not already exist.
369    #[arg(value_name = "PATH")]
370    pub path: PathBuf,
371
372    /// Package name. Defaults to the final component of `<PATH>`.
373    #[arg(long)]
374    pub name: Option<String>,
375
376    /// Use a binary (application) template [default].
377    ///
378    /// Conflicts with `--lib`.
379    #[arg(short = 'b', long, group = "new_scaffold_kind")]
380    pub bin: bool,
381
382    /// Use a library template.
383    ///
384    /// Conflicts with `--bin`.
385    #[arg(short = 'l', long, group = "new_scaffold_kind")]
386    pub lib: bool,
387}
388
389#[derive(Debug, Args)]
390pub(crate) struct CleanArgs {
391    /// Path to the cabin.toml manifest.  Same precedence rules
392    /// as `cabin build`.
393    #[arg(long, value_name = "PATH")]
394    pub manifest_path: Option<PathBuf>,
395
396    /// Build output directory.  Same precedence rules as
397    /// `cabin build`: `--build-dir` > `CABIN_BUILD_DIR` >
398    /// `[paths] build-dir` config setting > built-in default
399    /// `build`.
400    #[arg(long, value_name = "PATH")]
401    pub build_dir: Option<PathBuf>,
402
403    /// Compatibility alias for `--profile release`.  Cannot be
404    /// used together with `--profile`.
405    #[arg(long, conflicts_with = "profile")]
406    pub release: bool,
407
408    /// Limit the clean to the named build profile.  Without this
409    /// flag every known profile sub-tree is in scope.
410    #[arg(long, value_name = "NAME")]
411    pub profile: Option<String>,
412
413    /// Print the deletion plan without removing anything.  Output
414    /// lists the paths that would be removed in deterministic
415    /// order.
416    #[arg(long)]
417    pub dry_run: bool,
418
419    /// Workspace package-selection flags.
420    #[command(flatten)]
421    pub workspace_selection: WorkspaceSelectionArgs,
422}
423
424#[derive(Debug, Args)]
425pub(crate) struct ManifestArgs {
426    /// Path to the cabin.toml manifest. May be a single-package manifest
427    /// or a workspace root.
428    #[arg(long, value_name = "PATH")]
429    pub manifest_path: Option<PathBuf>,
430
431    /// Feature selection flags. Empty by default. When any
432    /// selection flag is passed, `cabin metadata --format json`
433    /// adds a `configuration` block to each primary package
434    /// describing the resolved configuration.
435    #[command(flatten)]
436    pub selection: ConfigSelectionArgs,
437
438    /// Workspace package-selection flags. The metadata view
439    /// always reports every loaded package; selection flags only
440    /// narrow the `selected_packages` list.
441    #[command(flatten)]
442    pub workspace_selection: WorkspaceSelectionArgs,
443
444    /// Output format. `human` is a readable summary; `json`
445    /// produces a machine-parseable document. Defaults to `json`
446    /// for back-compat with scripts that pipe the metadata output
447    /// into `jq`.
448    #[arg(long, value_name = "FORMAT", default_value = "json")]
449    pub format: ResolveFormat,
450
451    /// Profile to evaluate for the metadata view. Defaults to
452    /// `dev`. The view always lists every available profile in
453    /// the `profiles.available` array regardless of which one is
454    /// selected.
455    #[arg(long, value_name = "NAME")]
456    pub profile: Option<String>,
457
458    /// Toolchain-selection flags. Same precedence rules as
459    /// `cabin build` so the metadata view reflects exactly the
460    /// toolchain a build would use.
461    #[command(flatten)]
462    pub toolchain: ToolchainSelectionArgs,
463
464    /// Disable every active patch and source-replacement entry
465    /// for this invocation. Manifest `[patch]` tables and
466    /// config `[patch]` / `[source-replacement]` declarations
467    /// are ignored; ordinary `path = "..."` dependency edges
468    /// and dependency declarations stay active.
469    #[arg(long)]
470    pub no_patches: bool,
471
472    /// Forbid network access. `cabin metadata` rejects an HTTP
473    /// `--index-url` (or a `[registry] index-url` in the active
474    /// config) when this flag is set so the metadata view stays
475    /// fully local.
476    #[arg(long)]
477    pub offline: bool,
478}
479
480#[derive(Debug, Args)]
481pub(crate) struct BuildArgs {
482    /// Path to the cabin.toml manifest.
483    #[arg(long, value_name = "PATH")]
484    pub manifest_path: Option<PathBuf>,
485
486    /// Directory for build outputs (build.ninja, object files, binaries).
487    /// Defaults to `build/`; a config-provided `paths.build-dir`
488    /// overrides this default.
489    #[arg(long, value_name = "PATH")]
490    pub build_dir: Option<PathBuf>,
491
492    /// Build with optimizations.
493    ///
494    /// Use release flags (-O3 -DNDEBUG) instead of debug flags
495    /// (-g -O0).  Compatibility alias for `--profile release`;
496    /// cannot be used together with `--profile`.
497    #[arg(short = 'r', long, conflicts_with = "profile")]
498    pub release: bool,
499
500    /// Select the build profile (`dev`, `release`, or any custom
501    /// profile declared in `[profile.<name>]`). Defaults to `dev`.
502    /// Mutually exclusive with `--release`.
503    #[arg(long, value_name = "NAME")]
504    pub profile: Option<String>,
505
506    /// Path to a directory containing the local JSON package index.
507    /// Required when the manifest declares any versioned dependencies
508    /// and `--index-url` is not given. Mutually exclusive with
509    /// `--index-url`.
510    #[arg(long, value_name = "PATH")]
511    pub index_path: Option<PathBuf>,
512
513    /// Sparse HTTP index URL to read package metadata from.
514    /// Mutually exclusive with `--index-path`. Static sparse HTTP
515    /// serving of the file-registry layout is supported
516    /// (`<url>/config.json`, `<url>/packages/<name>.json`).
517    #[arg(long, value_name = "URL")]
518    pub index_url: Option<String>,
519
520    /// Override the default artifact cache directory.
521    #[arg(long, value_name = "PATH")]
522    pub cache_dir: Option<PathBuf>,
523
524    /// Require an existing, current `cabin.lock`. Resolution is not
525    /// allowed to choose any version that differs from the lockfile.
526    #[arg(long, conflicts_with = "frozen")]
527    pub locked: bool,
528
529    /// Like `--locked`, but also rejects state-writing side effects:
530    /// The lockfile must not change and the artifact cache will not be
531    /// populated. Already-cached artifacts may be reused.
532    #[arg(long)]
533    pub frozen: bool,
534
535    /// Forbid network access. Cabin refuses to use an HTTP index URL
536    /// (`--index-url` or a `[registry] index-url` config setting) and
537    /// expects every needed artifact to be available from a local
538    /// index (`--index-path`) or already in the artifact cache.
539    /// Combine with `cabin vendor` to consume a self-contained vendor
540    /// directory.
541    #[arg(long)]
542    pub offline: bool,
543
544    /// Enable named features. May be passed multiple times; values
545    /// may also be comma-separated (`--features simd,ssl`). The
546    /// selection applies to the root package being built.
547    #[arg(long, value_name = "FEATURES")]
548    pub features: Vec<String>,
549
550    /// Enable every feature declared by the root package. Combines
551    /// with `--features` (the union is the same as `--all-features`)
552    /// and overrides `--no-default-features`.
553    #[arg(long)]
554    pub all_features: bool,
555
556    /// Disable the package's default features. Without this flag, the
557    /// names listed under `[features].default` are enabled.
558    #[arg(long)]
559    pub no_default_features: bool,
560
561    /// Workspace package-selection flags.
562    #[command(flatten)]
563    pub workspace_selection: WorkspaceSelectionArgs,
564
565    /// Toolchain-selection flags. Each flag (when supplied)
566    /// overrides any `CC`/`CXX`/`AR` environment variable and
567    /// any `[toolchain]` table in the workspace root manifest.
568    #[command(flatten)]
569    pub toolchain: ToolchainSelectionArgs,
570
571    /// Disable every active patch and source-replacement entry
572    /// for this invocation. See `docs/patch-overrides.md`.
573    #[arg(long)]
574    pub no_patches: bool,
575
576    /// Number of parallel jobs to use for building.
577    ///
578    /// Precedence: this flag > `CABIN_BUILD_JOBS` env var >
579    /// `[build] jobs` config setting > backend default.  The
580    /// value must be a positive integer; `0` is rejected.
581    #[arg(short = 'j', long = "jobs", value_name = "N")]
582    pub jobs: Option<cabin_core::BuildJobs>,
583}
584
585/// Toolchain-selection flag bundle shared by `cabin build` and
586/// `cabin metadata`. Each flag accepts either a bare command name
587/// (`clang++`, resolved against `PATH`) or an explicit path
588/// (`/opt/llvm/bin/clang++`).
589#[derive(Debug, Args, Default)]
590pub(crate) struct ToolchainSelectionArgs {
591    /// Override the C compiler. Accepts a bare command name or a
592    /// path. Highest precedence — also overrides `CC` and
593    /// `[toolchain].cc`.
594    #[arg(long, value_name = "PATH-OR-NAME")]
595    pub cc: Option<String>,
596
597    /// Override the C++ compiler. Accepts a bare command name or
598    /// a path. Highest precedence — also overrides `CXX` and
599    /// `[toolchain].cxx`.
600    #[arg(long, value_name = "PATH-OR-NAME")]
601    pub cxx: Option<String>,
602
603    /// Override the static-library archiver. Accepts a bare
604    /// command name or a path. Highest precedence — also
605    /// overrides `AR` and `[toolchain].ar`.
606    #[arg(long, value_name = "PATH-OR-NAME")]
607    pub ar: Option<String>,
608
609    /// Select a compiler-cache wrapper that prefixes every C++
610    /// compile command. Accepts `none`, `ccache`, or `sccache`.
611    /// Highest precedence — also overrides
612    /// `CABIN_COMPILER_WRAPPER`, config `[build.cache]`, and
613    /// any manifest `[profile.cache]` or
614    /// `[target.'cfg(...)'.profile.cache]` declaration.
615    /// Mutually exclusive with `--no-compiler-wrapper`.
616    #[arg(long, value_name = "WRAPPER", conflicts_with = "no_compiler_wrapper")]
617    pub compiler_wrapper: Option<String>,
618
619    /// Disable the compiler-cache wrapper for this invocation,
620    /// regardless of any environment variable or manifest
621    /// declaration. Equivalent to `--compiler-wrapper none` but
622    /// shorter to type. Mutually exclusive with
623    /// `--compiler-wrapper`.
624    #[arg(long)]
625    pub no_compiler_wrapper: bool,
626}
627
628/// Selection-flag bundle shared by `cabin build` and `cabin metadata`.
629#[derive(Debug, Args, Default)]
630pub(crate) struct ConfigSelectionArgs {
631    /// Enable named features. May be repeated and/or comma-separated.
632    #[arg(long, value_name = "FEATURES")]
633    pub features: Vec<String>,
634
635    /// Enable every declared feature.
636    #[arg(long)]
637    pub all_features: bool,
638
639    /// Disable default features.
640    #[arg(long)]
641    pub no_default_features: bool,
642}
643
644/// Workspace selection flags for `cabin update`.
645///
646/// `cabin update` reserves `--package <name>` for its
647/// "refresh just this direct registry dep" semantic, so this
648/// bundle deliberately omits `-p / --package`. Members can still
649/// be scoped by `--workspace`, `--default-members`, and
650/// `--exclude`. Adding a separate long flag (e.g.
651/// `--scope-package`) for member-name selection is a deferred
652/// improvement.
653#[derive(Debug, Args, Default)]
654pub(crate) struct WorkspaceSelectionArgsForUpdate {
655    /// Operate on every workspace member, then apply `--exclude`.
656    #[arg(long, conflicts_with = "default_members")]
657    pub workspace: bool,
658
659    /// Operate on `[workspace.default-members]`. Errors when the
660    /// Workspace declares no default-members.
661    #[arg(long, conflicts_with = "workspace")]
662    pub default_members: bool,
663
664    /// Drop the named package from the selection. Only valid in
665    /// combination with `--workspace` or `--default-members`.
666    #[arg(long, value_name = "PACKAGE")]
667    pub exclude: Vec<String>,
668}
669
670/// Workspace package-selection flags shared across the commands
671/// that operate on a (possibly multi-member) workspace.
672///
673/// Empty by default, in which case the documented "current
674/// package" fallback applies (single-package builds keep working
675/// unchanged; workspace builds use `[workspace.default-members]`
676/// if declared, otherwise every member).
677#[derive(Debug, Args, Default)]
678pub(crate) struct WorkspaceSelectionArgs {
679    /// Operate on every workspace member, then apply `--exclude`.
680    /// Mutually exclusive with `--package` / `--default-members`.
681    #[arg(
682        long,
683        conflicts_with_all = &["package", "default_members"],
684    )]
685    pub workspace: bool,
686
687    /// Operate on the named workspace package. Repeat the flag to
688    /// select multiple packages. Errors when a name is not a workspace
689    /// member or appears twice in the workspace.
690    #[arg(long = "package", short = 'p', value_name = "PACKAGE")]
691    pub package: Vec<String>,
692
693    /// Operate on `[workspace.default-members]`. Errors when the
694    /// workspace declares no default-members.
695    #[arg(long, conflicts_with_all = &["workspace", "package"])]
696    pub default_members: bool,
697
698    /// Drop the named package from the selection. Only valid in
699    /// combination with `--workspace` or `--default-members`, or with
700    /// the no-flag default-member fallback.
701    #[arg(long, value_name = "PACKAGE")]
702    pub exclude: Vec<String>,
703}
704
705#[derive(Debug, Args)]
706pub(crate) struct FetchArgs {
707    /// Path to the cabin.toml manifest.
708    #[arg(long, value_name = "PATH")]
709    pub manifest_path: Option<PathBuf>,
710
711    /// Path to a directory containing the local JSON package index.
712    /// Required when the manifest declares any versioned dependencies
713    /// and `--index-url` is not given. Mutually exclusive with
714    /// `--index-url`.
715    #[arg(long, value_name = "PATH")]
716    pub index_path: Option<PathBuf>,
717
718    /// Sparse HTTP index URL to read package metadata from.
719    /// Mutually exclusive with `--index-path`.
720    #[arg(long, value_name = "URL")]
721    pub index_url: Option<String>,
722
723    /// Override the default artifact cache directory.
724    #[arg(long, value_name = "PATH")]
725    pub cache_dir: Option<PathBuf>,
726
727    /// Require an existing, current `cabin.lock`. Resolution is not
728    /// allowed to choose any version that differs from the lockfile.
729    #[arg(long, conflicts_with = "frozen")]
730    pub locked: bool,
731
732    /// Like `--locked`, but also rejects state-writing side effects.
733    /// The lockfile is not written and the artifact cache will not be
734    /// populated. Already-cached artifacts may be reused.
735    #[arg(long)]
736    pub frozen: bool,
737
738    /// Forbid network access. Cabin refuses to use an HTTP index
739    /// URL (`--index-url` or a `[registry] index-url` config setting)
740    /// and expects every needed input to be local or already cached.
741    #[arg(long)]
742    pub offline: bool,
743
744    /// Output format. `human` is a readable summary; `json` produces a
745    /// machine-parseable document. Defaults to `human`.
746    #[arg(long, value_name = "FORMAT", default_value = "human")]
747    pub format: ResolveFormat,
748
749    /// Workspace package-selection flags.
750    #[command(flatten)]
751    pub workspace_selection: WorkspaceSelectionArgs,
752
753    /// Disable every active patch and source-replacement entry
754    /// for this invocation. See `docs/patch-overrides.md`.
755    #[arg(long)]
756    pub no_patches: bool,
757}
758
759#[derive(Debug, Args)]
760pub(crate) struct PackageArgs {
761    /// Path to the cabin.toml manifest. Must point at a single
762    /// package; pure-workspace roots are rejected unless the
763    /// Workspace selects exactly one member with `--package`.
764    #[arg(long, value_name = "PATH")]
765    pub manifest_path: Option<PathBuf>,
766
767    /// Directory for the generated archive and metadata.
768    #[arg(long, default_value = "dist")]
769    pub output_dir: PathBuf,
770
771    /// Output format. `human` is a readable summary; `json` produces
772    /// A machine-parseable document. Defaults to `human`.
773    #[arg(long, value_name = "FORMAT", default_value = "human")]
774    pub format: ResolveFormat,
775
776    /// Workspace package-selection flags. In a workspace with
777    /// multiple members, `cabin package` requires a single
778    /// `--package <name>` selection.
779    #[command(flatten)]
780    pub workspace_selection: WorkspaceSelectionArgs,
781}
782
783#[derive(Debug, Args)]
784pub(crate) struct PublishArgs {
785    /// Path to the cabin.toml manifest. Must point at a single
786    /// package; pure-workspace roots are rejected.
787    #[arg(long, value_name = "PATH")]
788    pub manifest_path: Option<PathBuf>,
789
790    /// Directory for the dry-run's archive and metadata when
791    /// `--registry-dir` is not given. Defaults to `dist/`. Mutually
792    /// exclusive with `--registry-dir`.
793    #[arg(long, value_name = "PATH")]
794    pub output_dir: Option<PathBuf>,
795
796    /// Run a publish dry-run only. With `--registry-dir`, validates
797    /// what would happen against the registry without mutating it.
798    /// Without `--registry-dir`, runs the staging-only dry-run that
799    /// writes the archive + metadata to `--output-dir`.
800    #[arg(long)]
801    pub dry_run: bool,
802
803    /// Local file-registry root to publish into. Without
804    /// `--dry-run`, the registry is mutated; with `--dry-run`, every
805    /// pre-write check runs but the registry is left untouched.
806    #[arg(long, value_name = "PATH")]
807    pub registry_dir: Option<PathBuf>,
808
809    /// Output format for the publish or dry-run report.
810    #[arg(long, value_name = "FORMAT", default_value = "human")]
811    pub format: ResolveFormat,
812
813    /// Workspace package-selection flags. In a workspace with
814    /// multiple members, `cabin publish` requires a single
815    /// `--package <name>` selection.
816    #[command(flatten)]
817    pub workspace_selection: WorkspaceSelectionArgs,
818}
819
820#[derive(Debug, Args)]
821pub(crate) struct ResolveArgs {
822    /// Path to the cabin.toml manifest.
823    #[arg(long, value_name = "PATH")]
824    pub manifest_path: Option<PathBuf>,
825
826    /// Path to a directory containing the local JSON package index.
827    /// Required when the manifest declares any versioned dependencies
828    /// and `--index-url` is not given. Mutually exclusive with
829    /// `--index-url`.
830    #[arg(long, value_name = "PATH")]
831    pub index_path: Option<PathBuf>,
832
833    /// Sparse HTTP index URL to read package metadata from.
834    /// Mutually exclusive with `--index-path`.
835    #[arg(long, value_name = "URL")]
836    pub index_url: Option<String>,
837
838    /// Output format. `human` is a readable summary; `json` produces a
839    /// machine-parseable document. Defaults to `human`.
840    #[arg(long, value_name = "FORMAT", default_value = "human")]
841    pub format: ResolveFormat,
842
843    /// Require an existing, current `cabin.lock`. Resolution is not
844    /// allowed to choose any version that differs from the lockfile.
845    /// Implies that `cabin.lock` will not be written.
846    #[arg(long, conflicts_with = "frozen")]
847    pub locked: bool,
848
849    /// Like `--locked`, but also rejects any state-writing side
850    /// effects.
851    #[arg(long)]
852    pub frozen: bool,
853
854    /// Forbid network access. Cabin refuses to use an HTTP index
855    /// URL (`--index-url` or a `[registry] index-url` config setting)
856    /// and expects every needed input to be local or already cached.
857    #[arg(long)]
858    pub offline: bool,
859
860    /// Workspace package-selection flags. The resolver is
861    /// workspace-flat (every member shares one resolution), so
862    /// selection only narrows the diagnostic output, not the
863    /// resolution itself.
864    #[command(flatten)]
865    pub workspace_selection: WorkspaceSelectionArgs,
866
867    /// Feature names to enable on selected root packages.
868    /// Repeatable; values may also be comma-separated.
869    #[arg(long, value_name = "FEATURES")]
870    pub features: Vec<String>,
871
872    /// Enable every declared feature on selected root packages.
873    /// Combines with `--features` (the union is requested).
874    #[arg(long)]
875    pub all_features: bool,
876
877    /// Disable selected root packages' `default` feature.
878    #[arg(long)]
879    pub no_default_features: bool,
880
881    /// Disable every active patch and source-replacement entry
882    /// for this invocation. See `docs/patch-overrides.md`.
883    #[arg(long)]
884    pub no_patches: bool,
885}
886
887#[derive(Debug, Args)]
888pub(crate) struct UpdateArgs {
889    /// Path to the cabin.toml manifest.
890    #[arg(long, value_name = "PATH")]
891    pub manifest_path: Option<PathBuf>,
892
893    /// Path to a directory containing the local JSON package index.
894    /// Required when the manifest declares any versioned dependencies
895    /// and `--index-url` is not given. Mutually exclusive with
896    /// `--index-url`.
897    #[arg(long, value_name = "PATH")]
898    pub index_path: Option<PathBuf>,
899
900    /// Sparse HTTP index URL to read package metadata from.
901    /// Mutually exclusive with `--index-path`.
902    #[arg(long, value_name = "URL")]
903    pub index_url: Option<String>,
904
905    /// Update only the named **dependency** (and any of its
906    /// transitive deps that must change to satisfy the new
907    /// constraints). Without this flag every locked package is
908    /// re-resolved.
909    ///
910    /// `--package` here means "refresh this direct versioned
911    /// dependency", *not* "scope to this workspace member".
912    /// Workspace members can still be scoped through
913    /// `--workspace`, `--default-members`, and `--exclude`; the
914    /// workspace-selection bundle on `cabin update` deliberately
915    /// omits `-p` / `--package` to avoid the name collision.
916    #[arg(long, value_name = "NAME")]
917    pub package: Option<String>,
918
919    /// Output format for the resulting resolution.
920    #[arg(long, value_name = "FORMAT", default_value = "human")]
921    pub format: ResolveFormat,
922
923    /// Forbid network access. Cabin refuses to use an HTTP index
924    /// URL (`--index-url` or a `[registry] index-url` config setting)
925    /// and expects every needed input to be local or already cached.
926    #[arg(long)]
927    pub offline: bool,
928
929    /// Workspace package-selection flags scoped to
930    /// `cabin update`'s flag space (no `-p / --package`; see the
931    /// docstring on `package` above).
932    #[command(flatten)]
933    pub workspace_selection: WorkspaceSelectionArgsForUpdate,
934
935    /// Disable every active patch and source-replacement entry
936    /// for this invocation. See `docs/patch-overrides.md`.
937    #[arg(long)]
938    pub no_patches: bool,
939}
940
941#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
942pub(crate) enum ResolveFormat {
943    Human,
944    Json,
945}
946
947/// Default manifest filename used by every command.
948const MANIFEST_FILENAME: &str = scaffold::MANIFEST_FILENAME;
949
950/// Dispatch a parsed CLI invocation. Returns the exit code the
951/// process should propagate. Most commands return
952/// `ExitCode::SUCCESS` on the happy path; `cabin run` forwards
953/// the spawned program's exit status so a non-zero exit from the
954/// program becomes Cabin's own exit status.
955///
956/// The `cli.color` field carries the user's `--color` choice;
957/// the resolved [`cabin_core::ColorChoice`] for top-level
958/// error rendering is computed in `main.rs` against the env
959/// and the user-level config. Subcommands today produce
960/// uncolored status output and so do not consume the resolved
961/// color; when a subcommand learns to emit styled output, it
962/// should accept the resolved choice as an explicit argument
963/// rather than re-deriving it here.
964pub(crate) fn run(
965    cli: Cli,
966    reporter: Reporter,
967    color: cabin_core::ColorChoice,
968) -> Result<std::process::ExitCode> {
969    use std::process::ExitCode;
970    // `--version` (and the short `-V`) routes through the same
971    // formatter `cabin version` uses, so `cabin --version -v`
972    // produces the verbose key/value block instead of the
973    // concise single line clap's auto-flag would emit.  The
974    // flag wins over any subcommand and over `--list`, matching
975    // cargo's precedence.
976    if cli.version {
977        crate::version_glue::version(crate::version_glue::VersionArgs {}, reporter.verbosity())?;
978        return Ok(ExitCode::SUCCESS);
979    }
980    // `--list` is mutually exclusive with every other input;
981    // clap rejects `cabin --list <subcommand>` for us.  Print
982    // the full subcommand list and exit successfully.  The
983    // listing is written through a `termcolor::StandardStream`
984    // tuned to the caller-resolved color choice so the
985    // cargo-style palette (green heading, cyan subcommand
986    // names) appears whenever `--color` says it should.
987    if cli.list {
988        let mut stdout =
989            termcolor::StandardStream::stdout(cabin_diagnostics::termcolor_choice(color));
990        crate::command_list::print_list(&mut stdout)?;
991        return Ok(ExitCode::SUCCESS);
992    }
993    let Some(command) = cli.command else {
994        // `cabin` with no subcommand prints the curated help
995        // and exits zero, matching the prior implicit behavior
996        // (clap's auto help) but routed through the dispatcher
997        // so the exit code is documented here.
998        let mut cmd = <Cli as clap::CommandFactory>::command();
999        cmd.print_help().context("failed to print top-level help")?;
1000        // Cargo prints help and exits 0 when invoked with no
1001        // arguments.  Cabin matches that.
1002        return Ok(ExitCode::SUCCESS);
1003    };
1004    match command {
1005        Command::Init(args) => init(&args, reporter).map(|()| ExitCode::SUCCESS),
1006        Command::New(args) => new(&args, reporter).map(|()| ExitCode::SUCCESS),
1007        Command::Metadata(args) => metadata(&args, reporter).map(|()| ExitCode::SUCCESS),
1008        Command::Build(args) => build(&args, reporter).map(|()| ExitCode::SUCCESS),
1009        Command::Clean(args) => clean(&args, reporter).map(|()| ExitCode::SUCCESS),
1010        Command::Run(args) => crate::run_glue::run(&args, reporter),
1011        Command::Test(args) => crate::test_glue::test(&args, reporter).map(|()| ExitCode::SUCCESS),
1012        Command::Resolve(args) => resolve(&args, reporter).map(|()| ExitCode::SUCCESS),
1013        Command::Update(args) => update(&args, reporter).map(|()| ExitCode::SUCCESS),
1014        Command::Fetch(args) => fetch(&args, reporter).map(|()| ExitCode::SUCCESS),
1015        Command::Vendor(args) => {
1016            crate::vendor_glue::vendor(&args, reporter).map(|()| ExitCode::SUCCESS)
1017        }
1018        Command::Tree(args) => crate::tree_glue::tree(&args).map(|()| ExitCode::SUCCESS),
1019        Command::Explain(args) => {
1020            crate::explain_glue::explain(&args, reporter).map(|()| ExitCode::SUCCESS)
1021        }
1022        Command::Package(args) => package(&args, reporter).map(|()| ExitCode::SUCCESS),
1023        Command::Publish(args) => publish(&args, reporter).map(|()| ExitCode::SUCCESS),
1024        Command::Fmt(args) => crate::fmt_glue::fmt(&args, reporter),
1025        Command::Tidy(args) => crate::tidy_glue::tidy(&args, reporter),
1026        Command::Port(args) => {
1027            crate::port_subcommand::port(&args, reporter).map(|()| ExitCode::SUCCESS)
1028        }
1029        Command::Compgen(args) => crate::completions::run(&args).map(|()| ExitCode::SUCCESS),
1030        Command::Mangen(args) => crate::manpages::run(&args).map(|()| ExitCode::SUCCESS),
1031        Command::Version(args) => {
1032            crate::version_glue::version(args, reporter.verbosity()).map(|()| ExitCode::SUCCESS)
1033        }
1034    }
1035}
1036
1037fn scaffold_kind_from_flags(_bin: bool, lib: bool) -> scaffold::ScaffoldKind {
1038    // clap's `group` constraint already rejected the `--bin
1039    // --lib` combination, so `_bin` is only observed for
1040    // symmetry with the CLI surface; binary is the default
1041    // whether `--bin` was explicit or absent.
1042    if lib {
1043        scaffold::ScaffoldKind::Library
1044    } else {
1045        scaffold::ScaffoldKind::Binary
1046    }
1047}
1048
1049fn report_scaffold(reporter: Reporter, verb: &str, report: &scaffold::ScaffoldReport, dest: &Path) {
1050    // Cargo-style aligned status line: the verb (`Created` /
1051    // `Initialized`) is right-padded to column 12 by
1052    // `Reporter::status`, which keeps the banner aligned with
1053    // `Compiling` and `Finished` and styles the verb in bright
1054    // green + bold when color is enabled.  The rendered shape
1055    // is:
1056    //
1057    //     Created binary (application) `<name>` package
1058    //     Created library `<name>` package
1059    reporter.status(
1060        verb,
1061        format_args!(
1062            "{kind} `{name}` package",
1063            kind = report.kind.label(),
1064            name = report.name.as_str(),
1065        ),
1066    );
1067    for created in &report.files_created {
1068        let relative = created.strip_prefix(dest).unwrap_or(created);
1069        reporter.verbose(format_args!(
1070            "cabin: wrote {}",
1071            relative.display().to_string().replace('\\', "/")
1072        ));
1073    }
1074}
1075
1076fn init(args: &InitArgs, reporter: Reporter) -> Result<()> {
1077    let cwd = std::env::current_dir().context("failed to determine current directory")?;
1078    let kind = scaffold_kind_from_flags(args.bin, args.lib);
1079    let request = scaffold::ScaffoldRequest::new(&cwd)
1080        .with_name(args.name.as_deref())
1081        .with_kind(kind)
1082        .with_gitignore(true);
1083    let report = scaffold::scaffold(request)?;
1084    // `cabin init` and `cabin new` share the same `Created …`
1085    // status line so scripts can parse either path uniformly.
1086    report_scaffold(reporter, "Created", &report, &cwd);
1087    Ok(())
1088}
1089
1090fn new(args: &NewArgs, reporter: Reporter) -> Result<()> {
1091    let target = args.path.clone();
1092    if target.as_os_str().is_empty() {
1093        bail!("destination path must not be empty");
1094    }
1095    if target.exists() {
1096        bail!(
1097            "destination {} already exists; use `cabin init` to initialize an existing directory",
1098            target.display()
1099        );
1100    }
1101    if let Some(parent) = target.parent()
1102        && !parent.as_os_str().is_empty()
1103        && !parent.is_dir()
1104    {
1105        bail!(
1106            "parent directory {} does not exist; create it first or pass a path under an existing directory",
1107            parent.display()
1108        );
1109    }
1110
1111    std::fs::create_dir(&target)
1112        .with_context(|| format!("failed to create directory {}", target.display()))?;
1113
1114    let kind = scaffold_kind_from_flags(args.bin, args.lib);
1115    let request = scaffold::ScaffoldRequest::new(&target)
1116        .with_name(args.name.as_deref())
1117        .with_kind(kind)
1118        .with_gitignore(true);
1119    match scaffold::scaffold(request) {
1120        Ok(report) => {
1121            report_scaffold(reporter, "Created", &report, &target);
1122            Ok(())
1123        }
1124        Err(err) => {
1125            // Best-effort cleanup of the directory we just
1126            // created; surface the scaffold error regardless of
1127            // whether removal succeeds.
1128            let _ = std::fs::remove_dir_all(&target);
1129            Err(err.into())
1130        }
1131    }
1132}
1133
1134fn metadata(args: &ManifestArgs, reporter: Reporter) -> Result<()> {
1135    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1136    // `cabin metadata` reports the whole workspace; scope port
1137    // preparation accordingly so a member's port absence cannot
1138    // block emitting metadata for unrelated members.
1139    let metadata_selection = cabin_workspace::PackageSelection {
1140        mode: cabin_workspace::SelectionMode::WholeWorkspace,
1141        exclude: Vec::new(),
1142    };
1143    // Metadata generation is a network-free local introspection
1144    // command: force `offline = true` regardless of the user's
1145    // `--offline` flag so a fresh checkout that declares an
1146    // HTTP-backed port never blocks on a download. Cached
1147    // archives and `file://` ports still resolve and surface
1148    // their provenance; uncached HTTP ports gracefully degrade
1149    // to a port-less graph via the skeleton fallback below.
1150    let port_prep = crate::port_glue::prepare_ports_and_load_initial_graph(
1151        &manifest_path,
1152        None,
1153        true,
1154        false,
1155        false,
1156        &metadata_selection,
1157        args.no_patches,
1158    );
1159    let (prepared_ports, initial_graph) = match port_prep {
1160        Ok(result) => result,
1161        Err(err) if crate::port_glue::is_metadata_recoverable(&err) => (
1162            Vec::new(),
1163            cabin_workspace::load_workspace_skip_ports(&manifest_path)?,
1164        ),
1165        Err(err) => return Err(err),
1166    };
1167    let port_sources: Vec<cabin_workspace::PortPackageSource> = prepared_ports
1168        .iter()
1169        .map(crate::port_glue::workspace_source)
1170        .collect();
1171    let effective_config = crate::config_glue::load_effective_config(&initial_graph)?;
1172    // `cabin metadata` never reaches the network, but reject
1173    // `--offline` paired with a URL registry source so the
1174    // metadata view documents the same offline contract the
1175    // build / fetch / resolve commands enforce.
1176    let resolved_index_for_offline_check =
1177        crate::config_glue::resolve_index_source(None, None, &effective_config)?;
1178    let metadata_offline = crate::config_glue::effective_offline(args.offline)?;
1179    crate::config_glue::enforce_offline_index_source(
1180        metadata_offline,
1181        resolved_index_for_offline_check.as_ref(),
1182    )?;
1183    // Resolve patch policy before the rest of the pipeline.
1184    // Validation surfaces invalid / stale patches up-front.
1185    let active_patches =
1186        crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?;
1187    let patched_sources = active_patches.workspace_sources();
1188    let graph = crate::patch_glue::reload_for_patches(
1189        &manifest_path,
1190        initial_graph,
1191        &patched_sources,
1192        &port_sources,
1193    )?;
1194    let lockfile_path = lockfile_path_for(&manifest_path);
1195    let lockfile = read_optional_lockfile(&lockfile_path)?;
1196    let request = build_selection_request(
1197        &args.selection.features,
1198        args.selection.all_features,
1199        args.selection.no_default_features,
1200    );
1201    let workspace_selection = build_workspace_selection(&args.workspace_selection);
1202    let resolved_selection =
1203        cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?;
1204    // Run the cross-package feature resolver so unknown features,
1205    // `dep:` entries on non-optional deps, and other feature-graph
1206    // errors surface here too — not only in `cabin build`.
1207    let _feature_resolution = compute_feature_resolution(&graph, &resolved_selection, &request)?;
1208    let manifest_profiles = workspace_profile_definitions(&graph);
1209    let profile_selection =
1210        profile_selection_for_metadata(args.profile.as_deref(), &effective_config)?;
1211    let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles)
1212        .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1213    let host_platform = cabin_core::TargetPlatform::current();
1214    let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?;
1215    let toolchain = resolve_toolchain_layered(
1216        &graph,
1217        &toolchain_selection,
1218        &effective_config,
1219        &host_platform,
1220    )?;
1221    // Capability detection runs against the resolved tools.
1222    // `cabin metadata` is fail-soft so a misbehaving compiler
1223    // does not block users from inspecting the rest of the
1224    // workspace; the typed report is reported to the JSON view
1225    // as `null` when subprocess detection fails.
1226    let detection_report =
1227        match cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner) {
1228            Ok(report) => Some(report),
1229            Err(err) => {
1230                reporter.warning(format_args!("toolchain detection failed: {err}"));
1231                None
1232            }
1233        };
1234    // Resolve the compiler-cache wrapper. `cabin metadata` mirrors
1235    // the build-side resolution but fails soft on subprocess
1236    // errors so a missing wrapper executable cannot block
1237    // inspection of the rest of the workspace.
1238    let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph);
1239    let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?;
1240    let mut wrapper_inputs = cabin_toolchain::WrapperInputs::from_process(
1241        cli_compiler_wrapper,
1242        &manifest_compiler_wrapper,
1243        &host_platform,
1244    );
1245    if let Some(layer) = crate::config_glue::wrapper_layer(&effective_config) {
1246        wrapper_inputs = wrapper_inputs.with_config(layer);
1247    }
1248    let compiler_wrapper = match cabin_toolchain::resolve_compiler_wrapper(
1249        &wrapper_inputs,
1250        Some(&cabin_toolchain::ProcessRunner),
1251    ) {
1252        Ok(w) => w,
1253        Err(err) => {
1254            reporter.warning(format_args!("compiler-wrapper resolution failed: {err}"));
1255            None
1256        }
1257    };
1258    let toolchain_summary =
1259        cabin_core::ToolchainSummary::from_resolved_parts(&toolchain, compiler_wrapper.as_ref());
1260    let profile_build = profile.build.as_ref();
1261    let build_flags = resolve_per_package_build_flags(&graph, profile_build, &host_platform);
1262    // `cabin metadata` does not opt into dev-dep activation;
1263    // dev-kind system deps stay declaration-only here so the
1264    // probe step matches the Cabin-package activation rule.
1265    let dev_for: BTreeSet<String> = BTreeSet::new();
1266    let build_flags = augment_build_flags(&graph, &host_platform, &dev_for, build_flags, reporter)?;
1267    let configurations = resolve_build_configurations(
1268        &graph,
1269        &request,
1270        &resolved_selection.packages,
1271        &profile,
1272        &toolchain_summary,
1273        &build_flags,
1274    )?;
1275    let view = MetadataView::from_graph_and_lock(&MetadataInputs {
1276        graph: &graph,
1277        lockfile: lockfile.as_ref(),
1278        lockfile_path: &lockfile_path,
1279        configurations: &configurations,
1280        selection: &resolved_selection,
1281        profile: &profile,
1282        manifest_profiles: &manifest_profiles,
1283        toolchain: &toolchain,
1284        build_flags: &build_flags,
1285        detection: detection_report.as_ref(),
1286        compiler_wrapper: compiler_wrapper.as_ref(),
1287        config: &effective_config,
1288        active_patches: &active_patches,
1289        no_patches: args.no_patches,
1290        ports: &prepared_ports,
1291    });
1292    match args.format {
1293        ResolveFormat::Json => {
1294            crate::print_pretty_json(&view, "failed to serialize metadata as JSON")?;
1295        }
1296        ResolveFormat::Human => {
1297            // Human form is intentionally minimal — JSON is the
1298            // contract for tooling; this branch is here so users who
1299            // pass `--format human` get something readable.
1300            for pkg in &view.packages {
1301                println!(
1302                    "{} {} ({})",
1303                    pkg.name,
1304                    pkg.version,
1305                    if pkg.is_root {
1306                        "root"
1307                    } else if pkg.is_primary {
1308                        "primary"
1309                    } else {
1310                        "dep"
1311                    }
1312                );
1313            }
1314        }
1315    }
1316    Ok(())
1317}
1318
1319fn build(args: &BuildArgs, reporter: Reporter) -> Result<()> {
1320    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1321
1322    // First-pass load: needed to detect versioned dependencies
1323    // before we know whether we have to fetch anything. This load
1324    // also surfaces manifest / workspace errors before we touch
1325    // the index.
1326    let offline = crate::config_glue::effective_offline(args.offline)?;
1327    let build_selection = build_workspace_selection(&args.workspace_selection);
1328    let (prepared_ports, initial_graph) = crate::port_glue::prepare_ports_and_load_initial_graph(
1329        &manifest_path,
1330        args.cache_dir.as_deref(),
1331        offline,
1332        args.frozen,
1333        false,
1334        &build_selection,
1335        args.no_patches,
1336    )?;
1337    let port_sources: Vec<cabin_workspace::PortPackageSource> = prepared_ports
1338        .iter()
1339        .map(crate::port_glue::workspace_source)
1340        .collect();
1341    let effective_config = crate::config_glue::load_effective_config(&initial_graph)?;
1342    // Resolve patch policy before we look at the index. Patched
1343    // names are excluded from the closure / artifact pipeline
1344    // because they ship from a local working copy.
1345    let active_patches =
1346        crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?;
1347    let patched_names = active_patches.owned_patched_names();
1348    let resolved_index_source = crate::config_glue::resolve_index_source(
1349        args.index_path.as_deref(),
1350        args.index_url.as_deref(),
1351        &effective_config,
1352    )?;
1353    let build_offline = crate::config_glue::effective_offline(args.offline)?;
1354    crate::config_glue::enforce_offline_index_source(
1355        build_offline,
1356        resolved_index_source.as_ref(),
1357    )?;
1358    let resolved_cache_dir =
1359        crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config);
1360
1361    // only the *selected closure* drives the index
1362    // requirement. An unrelated workspace member's versioned dep
1363    // must not force the user to pass `--index-path` when
1364    // `cabin build -p selected` is run on a C/C++-only selection.
1365    let workspace_selection_for_pipeline = build_workspace_selection(&args.workspace_selection);
1366    let initial_resolved_selection = cabin_workspace::resolve_package_selection(
1367        &initial_graph,
1368        &workspace_selection_for_pipeline,
1369    )?;
1370    let initial_request =
1371        build_selection_request(&args.features, args.all_features, args.no_default_features);
1372    let initial_features = compute_feature_resolution(
1373        &initial_graph,
1374        &initial_resolved_selection,
1375        &initial_request,
1376    )?;
1377    let dev_for: BTreeSet<String> = BTreeSet::new();
1378    let patched_root_deps_preview =
1379        collect_patched_versioned_deps(&active_patches, &patched_names)?;
1380    let has_versioned = !patched_root_deps_preview.is_empty()
1381        || closure_has_versioned_deps_excluding_patches(
1382            &initial_graph,
1383            &initial_resolved_selection,
1384            &initial_features,
1385            &patched_names,
1386            &dev_for,
1387        );
1388
1389    let registry: Vec<RegistryPackageSource> = if has_versioned {
1390        let Some(index_source) = resolved_index_source.as_ref() else {
1391            bail!(
1392                "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting"
1393            );
1394        };
1395        let inputs = crate::config_glue::resolve_pipeline_inputs(
1396            index_source,
1397            &effective_config,
1398            &manifest_path,
1399            args.cache_dir.as_deref(),
1400            resolved_cache_dir.as_ref(),
1401            build_offline,
1402            args.locked,
1403            args.frozen,
1404            args.no_patches,
1405            false,
1406        )?;
1407        let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest {
1408            manifest_path: &manifest_path,
1409            initial_graph: &initial_graph,
1410            index_path: inputs.index_path.as_deref(),
1411            index_url: inputs.index_url.as_deref(),
1412            mode: inputs.mode,
1413            allow_write: inputs.allow_write,
1414            frozen: args.frozen,
1415            cache_dir: &inputs.cache_dir,
1416            reporter,
1417            selection: workspace_selection_for_pipeline,
1418            selection_request: &initial_request,
1419            patched_names: &patched_names,
1420            active_patches: &active_patches,
1421            source_replacements: &effective_config.source_replacements,
1422            no_patches: args.no_patches,
1423            dev_for: &dev_for,
1424        })?;
1425        pipeline.registry_sources()
1426    } else {
1427        Vec::new()
1428    };
1429
1430    // Re-load the workspace, this time stitching in the resolved
1431    // registry packages plus active patches. When both lists are
1432    // empty this is identical to the first-pass load.
1433    //
1434    // `strict_packages` controls which packages require their
1435    // versioned / port deps to be satisfied. The set is the
1436    // selection's closure on `initial_graph` plus every package
1437    // that the resolver fetched into `registry`. The closure
1438    // alone misses any package reached only after resolution —
1439    // most importantly, transitive registry packages a patched
1440    // manifest pulled in via a version dep that did not exist on
1441    // the upstream package. Without the registry extension those
1442    // packages would parent a missing-registry / missing-port
1443    // edge under the scoped policy and silently drop it, leaving
1444    // the build to fail later with a less actionable diagnostic.
1445    // `patched_names` is folded in defensively too — closure
1446    // already reaches the patched manifests now, but the explicit
1447    // add keeps the strict set correct if anything in the
1448    // chicken-and-egg loading order ever shifts.
1449    let mut strict_packages: BTreeSet<String> =
1450        initial_resolved_selection.closure_package_names(&initial_graph);
1451    strict_packages.extend(patched_names.iter().cloned());
1452    strict_packages.extend(registry.iter().map(|r| r.name.as_str().to_owned()));
1453    let patched_sources = active_patches.workspace_sources();
1454    let graph = cabin_workspace::load_workspace_with_options(
1455        &manifest_path,
1456        &cabin_workspace::WorkspaceLoadOptions {
1457            registry: &registry,
1458            patches: &patched_sources,
1459            ports: &port_sources,
1460            registry_policy: cabin_workspace::RegistryPolicy::StrictFor(&strict_packages),
1461            include_dev_for: &BTreeSet::new(),
1462            port_policy: cabin_workspace::PortPolicy::TolerateExcept(&strict_packages),
1463        },
1464    )?;
1465
1466    // Resolve the build directory. Precedence:
1467    //   `--build-dir` > `CABIN_BUILD_DIR` env var
1468    //   > `[paths] build-dir` config setting > built-in default.
1469    let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env(
1470        args.build_dir.as_deref(),
1471        &effective_config,
1472    );
1473    let build_dir = absolutise(&build_dir_input)
1474        .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?;
1475
1476    let host_platform = cabin_core::TargetPlatform::current();
1477    let toolchain_selection = toolchain_selection_from_args(&args.toolchain)?;
1478    let toolchain = resolve_toolchain_layered(
1479        &graph,
1480        &toolchain_selection,
1481        &effective_config,
1482        &host_platform,
1483    )?;
1484    // Detect compiler / archiver identity and validate that the
1485    // backend's required capabilities (GCC-style flags, depfile
1486    // emission, `-std=c++17`, ar-compatible archiving) are
1487    // available before any Ninja file is written. Fail fast and
1488    // clear here rather than letting Ninja produce a confusing
1489    // error from a broken command line.
1490    let detection_report =
1491        cabin_toolchain::detect_toolchain(&toolchain, &cabin_toolchain::ProcessRunner)
1492            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1493    cabin_build::validate_toolchain_for_backend(&toolchain, &detection_report)?;
1494    let ninja = cabin_toolchain::locate_ninja()?;
1495
1496    let manifest_compiler_wrapper = workspace_compiler_wrapper_settings(&graph);
1497    let cli_compiler_wrapper = compiler_wrapper_override_from_args(&args.toolchain)?;
1498
1499    // Translate `--profile` / `--release` into a typed selection
1500    // (clap's `conflicts_with` already rejects the two-flag form).
1501    // The workspace root manifest's `[profile.<name>]` tables are
1502    // the only source of profile definitions; a `build.profile`
1503    // setting in any active config file slots between the CLI
1504    // flag and the built-in `dev` default.
1505    let profile_selection = profile_selection_for_build(args, &effective_config)?;
1506    let manifest_profiles = workspace_profile_definitions(&graph);
1507    let profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles)
1508        .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1509
1510    // Per-package resolved build flags. Each package's own
1511    // `[profile]` / `[target.'cfg(...)'.profile]` plus the active
1512    // profile's `[profile.<name>]` block compose into a
1513    // `ResolvedProfileFlags`. Computed up-front so the planner
1514    // and metadata view see the same values.
1515    // `cabin build` does not opt into dev-dep activation; dev-kind
1516    // system deps stay declaration-only here so the probe step
1517    // matches the Cabin-package activation rule.
1518    let dev_for: BTreeSet<String> = BTreeSet::new();
1519    // Per-package build flags + the (fail-hard) compiler-cache
1520    // wrapper, folded into a toolchain summary. Shared with
1521    // `run` / `test` / `explain build-config` via `build_prep_glue`.
1522    let prep =
1523        crate::build_prep_glue::resolve_build_prep(crate::build_prep_glue::BuildConfigInputs {
1524            graph: &graph,
1525            host_platform: &host_platform,
1526            toolchain: &toolchain,
1527            cli_compiler_wrapper,
1528            manifest_compiler_wrapper: &manifest_compiler_wrapper,
1529            effective_config: &effective_config,
1530            profile: &profile,
1531            dev_for: &dev_for,
1532            reporter,
1533        })?;
1534
1535    // resolve the workspace package selection up-front.
1536    // The planner consumes the selected indices through
1537    // `PlanRequest::selected_packages` so default-target enumeration
1538    // narrows to the picked packages instead of every primary.
1539    let workspace_selection = build_workspace_selection(&args.workspace_selection);
1540    let resolved_selection =
1541        cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?;
1542
1543    // resolve features for the root package before doing anything
1544    // else, so the planner observes the selected configuration.
1545    let selection_request =
1546        build_selection_request(&args.features, args.all_features, args.no_default_features);
1547    let configurations = resolve_build_configurations(
1548        &graph,
1549        &selection_request,
1550        &resolved_selection.packages,
1551        &profile,
1552        &prep.toolchain_summary,
1553        &prep.build_flags,
1554    )?;
1555    let feature_resolution =
1556        compute_feature_resolution(&graph, &resolved_selection, &selection_request)?;
1557
1558    let root_configuration = graph
1559        .root_package
1560        .and_then(|i| configurations.get(&i))
1561        .cloned();
1562    let plan_graph = plan(&PlanRequest {
1563        graph: &graph,
1564        toolchain: &toolchain,
1565        build_flags: &prep.build_flags,
1566        build_dir: build_dir.clone(),
1567        profile: profile.clone(),
1568        selected: None,
1569        configuration: root_configuration.as_ref(),
1570        selected_packages: Some(&resolved_selection.packages),
1571        compiler_wrapper: prep.compiler_wrapper.as_ref(),
1572    })?;
1573
1574    // Profile-aware Ninja root: `build/<profile>/build.ninja`
1575    // and `build/<profile>/compile_commands.json`. Keeps dev /
1576    // release / custom builds from overwriting each other and
1577    // matches the per-package output tree the planner emits.
1578    let profile_build_root = build_dir.join(profile.name.as_str());
1579    std::fs::create_dir_all(&profile_build_root).with_context(|| {
1580        format!(
1581            "failed to create build directory {}",
1582            profile_build_root.display()
1583        )
1584    })?;
1585
1586    let ninja_file = profile_build_root.join("build.ninja");
1587    cabin_ninja::write_build_ninja(&ninja_file, &plan_graph)?;
1588
1589    let ccmd_file = profile_build_root.join("compile_commands.json");
1590    cabin_ninja::write_compile_commands(&ccmd_file, &plan_graph)?;
1591
1592    reporter.verbose(format_args!("cabin: profile = {}", profile.name.as_str()));
1593    reporter.verbose(format_args!("cabin: build dir = {}", build_dir.display()));
1594    reporter.verbose(format_args!(
1595        "cabin: c++ compiler = {}",
1596        toolchain.cxx.path.display()
1597    ));
1598    if let Some(cc) = &toolchain.cc {
1599        reporter.very_verbose(format_args!("cabin: c compiler = {}", cc.path.display()));
1600    }
1601    reporter.very_verbose(format_args!(
1602        "cabin: archiver = {}",
1603        toolchain.ar.path.display()
1604    ));
1605    // Implementation-detail status (which files Cabin wrote
1606    // before handing the build off to Ninja, the exact Ninja
1607    // argv) is verbose-only so the default surface stays terse.
1608    reporter.verbose(format_args!("cabin: wrote {}", ninja_file.display()));
1609    reporter.verbose(format_args!("cabin: wrote {}", ccmd_file.display()));
1610    let jobs = crate::config_glue::resolve_build_jobs(args.jobs, &effective_config)?;
1611    reporter.verbose(format_args!(
1612        "cabin: invoking {} {}-C {}",
1613        ninja.display(),
1614        crate::ninja_glue::ninja_jobs_echo(jobs),
1615        profile_build_root.display()
1616    ));
1617
1618    let mut ninja_cmd = std::process::Command::new(&ninja);
1619    if let Some(jobs) = jobs {
1620        ninja_cmd.arg(crate::ninja_glue::ninja_jobs_arg(jobs));
1621    }
1622    let build_started = std::time::Instant::now();
1623    let run = crate::ninja_glue::run_ninja(
1624        ninja_cmd.arg("-C").arg(&profile_build_root),
1625        reporter,
1626        &graph,
1627    )
1628    .with_context(|| format!("failed to invoke ninja at {}", ninja.display()))?;
1629
1630    if !run.status.success() {
1631        crate::ninja_glue::emit_link_diagnostic_if_applicable(
1632            &run,
1633            &graph,
1634            &feature_resolution,
1635            &dev_for,
1636            reporter,
1637        );
1638        bail!("ninja exited with {}", run.status);
1639    }
1640
1641    // Cargo-style `Finished` summary: profile name, the resolved
1642    // optimization / debuginfo descriptor, and the wall-clock
1643    // duration the Ninja invocation took.
1644    let elapsed = build_started.elapsed();
1645    reporter.status(
1646        "Finished",
1647        format_args!(
1648            "`{}` profile [{}] target(s) in {:.2}s",
1649            profile.name.as_str(),
1650            profile_descriptor(&profile),
1651            elapsed.as_secs_f64(),
1652        ),
1653    );
1654
1655    Ok(())
1656}
1657
1658/// Render the optimization / debuginfo descriptor that follows
1659/// the profile name in the `Finished` status line, matching
1660/// cargo's own banner:
1661///
1662/// - `unoptimized + debuginfo` for `dev` and any other `O0` +
1663///   debug build,
1664/// - `optimized` for `release` and other non-zero opt levels,
1665/// - `optimized + debuginfo` when both flags are on.
1666pub(crate) fn profile_descriptor(profile: &cabin_core::ResolvedProfile) -> String {
1667    let opt = if matches!(profile.opt_level, cabin_core::OptLevel::O0) {
1668        "unoptimized"
1669    } else {
1670        "optimized"
1671    };
1672    if profile.debug {
1673        format!("{opt} + debuginfo")
1674    } else {
1675        opt.to_owned()
1676    }
1677}
1678
1679fn clean(args: &CleanArgs, reporter: Reporter) -> Result<()> {
1680    use cabin_build::clean::{CleanRequest, CleanScope, execute_clean, plan_clean};
1681
1682    // Manifest discovery, build-dir resolution, and profile
1683    // selection share helpers with `cabin build` so the user
1684    // sees the same precedence rules across both commands.
1685    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1686    // must never reach the network. Foundation-port edges are
1687    // skipped so a fresh checkout with an HTTP-backed port (no
1688    // archive cached yet) still cleans without erroring.
1689    let graph = cabin_workspace::load_workspace_skip_ports(&manifest_path)?;
1690    let effective_config = crate::config_glue::load_effective_config(&graph)?;
1691
1692    let (build_dir_input, _build_dir_source) = crate::config_glue::resolve_build_dir_with_env(
1693        args.build_dir.as_deref(),
1694        &effective_config,
1695    );
1696    let build_dir = absolutise(&build_dir_input)
1697        .with_context(|| format!("failed to resolve build dir {}", build_dir_input.display()))?;
1698
1699    let workspace_root = graph.root_dir.clone();
1700    let package_roots: Vec<PathBuf> = graph
1701        .packages
1702        .iter()
1703        .map(|pkg| pkg.manifest_dir.clone())
1704        .collect();
1705    let protected_source_paths = clean_protected_source_paths(&graph);
1706
1707    let workspace_selection = build_workspace_selection(&args.workspace_selection);
1708    let resolved_selection =
1709        cabin_workspace::resolve_package_selection(&graph, &workspace_selection)?;
1710    let selected_explicitly = !args.workspace_selection.package.is_empty()
1711        || !args.workspace_selection.exclude.is_empty();
1712
1713    let profile_selection =
1714        profile_selection_from_flags(args.profile.as_deref(), args.release, &effective_config)?;
1715    let manifest_profiles = workspace_profile_definitions(&graph);
1716    let resolved_profile = cabin_core::resolve_profile(&profile_selection, &manifest_profiles)
1717        .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1718    let profile_was_chosen = args.profile.is_some() || args.release;
1719
1720    let scope = if selected_explicitly {
1721        let packages: Vec<cabin_core::PackageName> = resolved_selection
1722            .packages
1723            .iter()
1724            .map(|&idx| graph.packages[idx].package.name.clone())
1725            .collect();
1726        let profiles = if profile_was_chosen {
1727            vec![resolved_profile.name]
1728        } else {
1729            known_profile_names(&manifest_profiles)
1730        };
1731        CleanScope::Packages { profiles, packages }
1732    } else if profile_was_chosen {
1733        CleanScope::Profile(resolved_profile.name)
1734    } else {
1735        CleanScope::Whole
1736    };
1737
1738    let plan = plan_clean(&CleanRequest {
1739        build_dir: &build_dir,
1740        workspace_root: &workspace_root,
1741        package_roots: &package_roots,
1742        protected_source_paths: &protected_source_paths,
1743        scope,
1744    })
1745    .map_err(|err| anyhow::anyhow!(err.to_string()))?;
1746
1747    if plan.removals.is_empty() {
1748        if args.dry_run {
1749            reporter.status(
1750                "Removed",
1751                format_args!("nothing under {} (dry-run)", build_dir.display()),
1752            );
1753        } else {
1754            reporter.status(
1755                "Removed",
1756                format_args!(
1757                    "nothing under {} (build directory does not exist)",
1758                    build_dir.display()
1759                ),
1760            );
1761        }
1762        return Ok(());
1763    }
1764
1765    if args.dry_run {
1766        reporter.status(
1767            "Removed",
1768            format_args!(
1769                "{} path{} under {} (dry-run; re-run without --dry-run to apply)",
1770                plan.removals.len(),
1771                crate::plural(plan.removals.len()),
1772                build_dir.display(),
1773            ),
1774        );
1775        print_plan_paths(&plan, reporter);
1776        return Ok(());
1777    }
1778
1779    let report = execute_clean(&plan).map_err(|err| anyhow::anyhow!(err.to_string()))?;
1780    reporter.status(
1781        "Removed",
1782        format_args!(
1783            "{} path{} under {}",
1784            report.removed.len(),
1785            crate::plural(report.removed.len()),
1786            build_dir.display()
1787        ),
1788    );
1789    Ok(())
1790}
1791
1792fn clean_protected_source_paths(graph: &cabin_workspace::PackageGraph) -> Vec<PathBuf> {
1793    let mut paths = Vec::new();
1794    for pkg in &graph.packages {
1795        for target in &pkg.package.targets {
1796            paths.extend(
1797                target
1798                    .sources
1799                    .iter()
1800                    .map(|source| pkg.manifest_dir.join(source)),
1801            );
1802            paths.extend(
1803                target
1804                    .include_dirs
1805                    .iter()
1806                    .map(|include_dir| pkg.manifest_dir.join(include_dir)),
1807            );
1808        }
1809    }
1810    paths.sort();
1811    paths.dedup();
1812    paths
1813}
1814
1815fn print_plan_paths(plan: &cabin_build::clean::CleanPlan, reporter: Reporter) {
1816    // Dry-run plan enumeration is the user-requested payload of
1817    // `cabin clean --dry-run`.  Routed through `Reporter::note`
1818    // so it stays visible at default verbosity, paired with the
1819    // `Removed … (dry-run)` banner above, and disappears
1820    // alongside the banner under `--quiet`.
1821    for path in &plan.removals {
1822        reporter.note(format_args!("  {}", path.display()));
1823    }
1824}
1825
1826/// Names of every profile this workspace knows about: the two
1827/// built-ins (`dev`, `release`) plus every user-declared
1828/// `[profile.<name>]` table on the workspace root manifest.
1829/// The set is sorted and deduplicated so the resulting clean
1830/// scope is stable across invocations.
1831fn known_profile_names(
1832    manifest_profiles: &BTreeMap<cabin_core::ProfileName, cabin_core::ProfileDefinition>,
1833) -> Vec<cabin_core::ProfileName> {
1834    let mut out: BTreeSet<cabin_core::ProfileName> = BTreeSet::new();
1835    for builtin in cabin_core::BuiltinProfile::all() {
1836        out.insert(cabin_core::ProfileName::builtin(builtin));
1837    }
1838    for name in manifest_profiles.keys() {
1839        out.insert(name.clone());
1840    }
1841    out.into_iter().collect()
1842}
1843
1844fn resolve(args: &ResolveArgs, reporter: Reporter) -> Result<()> {
1845    let mode = lock_mode_for_flags(args.locked, args.frozen);
1846    // Both --locked and --frozen forbid writing the lockfile. The
1847    // distinction becomes meaningful once a fetcher / cache exists for
1848    // `--frozen` to refuse to populate; today they behave the same.
1849    let allow_write = !(args.locked || args.frozen);
1850    if args.frozen && args.index_url.is_some() {
1851        bail!(
1852            "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"
1853        );
1854    }
1855    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1856    let workspace_selection = build_workspace_selection(&args.workspace_selection);
1857    let selection_request =
1858        build_selection_request(&args.features, args.all_features, args.no_default_features);
1859    run_resolution(
1860        &ResolutionRequest {
1861            manifest_path: &manifest_path,
1862            index_path: args.index_path.as_deref(),
1863            index_url: args.index_url.as_deref(),
1864            format: args.format,
1865            mode,
1866            allow_write,
1867            frozen: args.frozen,
1868            update_package: None,
1869            selection: workspace_selection,
1870            selection_request,
1871            no_patches: args.no_patches,
1872            offline: args.offline,
1873        },
1874        reporter,
1875    )
1876}
1877
1878fn update(args: &UpdateArgs, reporter: Reporter) -> Result<()> {
1879    let mode = match &args.package {
1880        Some(name) => LockMode::UpdatePackage(name.clone()),
1881        None => LockMode::UpdateAll,
1882    };
1883    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1884    // `cabin update` keeps its `--package <name>` flag for the
1885    // dep-targeted-update meaning. Workspace member scoping uses
1886    // the dedicated bundle without `-p`.
1887    let workspace_selection = build_update_workspace_selection(&args.workspace_selection);
1888    run_resolution(
1889        &ResolutionRequest {
1890            manifest_path: &manifest_path,
1891            index_path: args.index_path.as_deref(),
1892            index_url: args.index_url.as_deref(),
1893            format: args.format,
1894            mode,
1895            allow_write: true,
1896            frozen: false,
1897            update_package: args.package.as_deref(),
1898            selection: workspace_selection,
1899            selection_request: cabin_core::SelectionRequest::default(),
1900            no_patches: args.no_patches,
1901            offline: args.offline,
1902        },
1903        reporter,
1904    )
1905}
1906
1907/// Convert `WorkspaceSelectionArgsForUpdate` (the
1908/// `cabin update`-specific bundle without `-p / --package`) into
1909/// the same `PackageSelection` shape every other workspace-aware
1910/// command consumes.
1911fn build_update_workspace_selection(
1912    args: &WorkspaceSelectionArgsForUpdate,
1913) -> cabin_workspace::PackageSelection {
1914    use cabin_workspace::SelectionMode;
1915    let mode = if args.workspace {
1916        SelectionMode::WholeWorkspace
1917    } else if args.default_members {
1918        SelectionMode::DefaultMembers
1919    } else {
1920        SelectionMode::CurrentPackage
1921    };
1922    cabin_workspace::PackageSelection {
1923        mode,
1924        exclude: args.exclude.clone(),
1925    }
1926}
1927
1928fn fetch(args: &FetchArgs, reporter: Reporter) -> Result<()> {
1929    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
1930    let offline_pre = crate::config_glue::effective_offline(args.offline)?;
1931    let fetch_selection = build_workspace_selection(&args.workspace_selection);
1932    let (_port_sources, initial_graph) = crate::port_glue::prepare_ports_and_load_initial_graph(
1933        &manifest_path,
1934        args.cache_dir.as_deref(),
1935        offline_pre,
1936        args.frozen,
1937        false,
1938        &fetch_selection,
1939        args.no_patches,
1940    )?;
1941    let effective_config = crate::config_glue::load_effective_config(&initial_graph)?;
1942    let active_patches =
1943        crate::patch_glue::load_active_patches(&initial_graph, &effective_config, args.no_patches)?;
1944    let patched_names = active_patches.owned_patched_names();
1945    // validate the workspace selection up-front so a typo
1946    // like `--package missing` fails even when there are no
1947    // versioned deps to fetch.
1948    let workspace_selection = build_workspace_selection(&args.workspace_selection);
1949    let resolved_selection =
1950        cabin_workspace::resolve_package_selection(&initial_graph, &workspace_selection)?;
1951    // `cabin fetch` does not currently expose feature flags,
1952    // so feature resolution runs with the documented defaults
1953    // (each selected root's `default` feature, no extras). This
1954    // still excludes disabled optional dependencies from the
1955    // index-requirement check below — the user opts into them
1956    // via `cabin build --features ...` / `cabin resolve
1957    // --features ...`.
1958    let initial_features = compute_feature_resolution(
1959        &initial_graph,
1960        &resolved_selection,
1961        &cabin_core::SelectionRequest::default(),
1962    )?;
1963
1964    // scope the index requirement to the selected
1965    // closure. Unrelated members' versioned deps no longer force a
1966    // user who passed `--package <selected>` to also pass
1967    // `--index-path`. Patched manifests contribute their own
1968    // versioned deps too, so a workspace whose only versioned
1969    // edge comes from `[patch]` still needs the index.
1970    let dev_for: BTreeSet<String> = BTreeSet::new();
1971    let patched_root_deps_preview =
1972        collect_patched_versioned_deps(&active_patches, &patched_names)?;
1973    if patched_root_deps_preview.is_empty()
1974        && !closure_has_versioned_deps_excluding_patches(
1975            &initial_graph,
1976            &resolved_selection,
1977            &initial_features,
1978            &patched_names,
1979            &dev_for,
1980        )
1981    {
1982        emit_fetch_output(
1983            &[],
1984            args.format,
1985            &cache_dir_for(&manifest_path, args.cache_dir.as_deref()).unwrap_or_default(),
1986            &manifest_path,
1987        )?;
1988        return Ok(());
1989    }
1990
1991    let resolved_index_source = crate::config_glue::resolve_index_source(
1992        args.index_path.as_deref(),
1993        args.index_url.as_deref(),
1994        &effective_config,
1995    )?;
1996    let fetch_offline = crate::config_glue::effective_offline(args.offline)?;
1997    crate::config_glue::enforce_offline_index_source(
1998        fetch_offline,
1999        resolved_index_source.as_ref(),
2000    )?;
2001    let resolved_cache_dir =
2002        crate::config_glue::resolve_cache_dir(args.cache_dir.as_deref(), &effective_config);
2003    let Some(index_source) = resolved_index_source.as_ref() else {
2004        bail!(
2005            "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting"
2006        );
2007    };
2008    let inputs = crate::config_glue::resolve_pipeline_inputs(
2009        index_source,
2010        &effective_config,
2011        &manifest_path,
2012        args.cache_dir.as_deref(),
2013        resolved_cache_dir.as_ref(),
2014        fetch_offline,
2015        args.locked,
2016        args.frozen,
2017        args.no_patches,
2018        false,
2019    )?;
2020
2021    let fetch_request = cabin_core::SelectionRequest::default();
2022    let pipeline = run_artifact_pipeline(&ArtifactPipelineRequest {
2023        manifest_path: &manifest_path,
2024        initial_graph: &initial_graph,
2025        index_path: inputs.index_path.as_deref(),
2026        index_url: inputs.index_url.as_deref(),
2027        mode: inputs.mode,
2028        allow_write: inputs.allow_write,
2029        frozen: args.frozen,
2030        cache_dir: &inputs.cache_dir,
2031        reporter,
2032        selection: workspace_selection,
2033        selection_request: &fetch_request,
2034        patched_names: &patched_names,
2035        active_patches: &active_patches,
2036        source_replacements: &effective_config.source_replacements,
2037        no_patches: args.no_patches,
2038        dev_for: &dev_for,
2039    })?;
2040
2041    emit_fetch_output(
2042        &pipeline.fetched,
2043        args.format,
2044        &inputs.cache_dir,
2045        &manifest_path,
2046    )?;
2047    Ok(())
2048}
2049
2050fn package(args: &PackageArgs, _reporter: Reporter) -> Result<()> {
2051    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
2052    let target =
2053        select_single_package_manifest(&manifest_path, &args.workspace_selection, "package")?;
2054    let output_dir = absolutise(&args.output_dir)
2055        .with_context(|| format!("failed to resolve {}", args.output_dir.display()))?;
2056    let artifact = cabin_package::package_with_project(
2057        cabin_package::PackageRequest {
2058            manifest_path: &target.manifest_path,
2059            output_dir: &output_dir,
2060        },
2061        target.resolved_project,
2062    )?;
2063    emit_package_output(&artifact, args.format)?;
2064    Ok(())
2065}
2066
2067fn publish(args: &PublishArgs, _reporter: Reporter) -> Result<()> {
2068    // `--output-dir` is for the staging-only `dist/` flow; combining
2069    // it with `--registry-dir` is meaningless and almost always
2070    // means the user picked the wrong flag, so refuse loudly.
2071    if args.output_dir.is_some() && args.registry_dir.is_some() {
2072        bail!("--output-dir is not compatible with --registry-dir; pick one");
2073    }
2074
2075    let manifest_path = resolve_invocation_manifest(args.manifest_path.as_deref())?;
2076    let target =
2077        select_single_package_manifest(&manifest_path, &args.workspace_selection, "publish")?;
2078
2079    match (args.registry_dir.as_deref(), args.dry_run) {
2080        (Some(registry_dir), true) => {
2081            let registry_dir = absolutise(registry_dir)
2082                .with_context(|| format!("failed to resolve {}", registry_dir.display()))?;
2083            let report = cabin_publish::dry_run_against_file_registry(
2084                cabin_publish::RegistryPublishWorkflow {
2085                    manifest_path: &target.manifest_path,
2086                    registry_dir: &registry_dir,
2087                    resolved_project: target.resolved_project.clone(),
2088                },
2089            )?;
2090            emit_registry_publish_output(&report, args.format)?;
2091        }
2092        (Some(registry_dir), false) => {
2093            let registry_dir = absolutise(registry_dir)
2094                .with_context(|| format!("failed to resolve {}", registry_dir.display()))?;
2095            let report =
2096                cabin_publish::publish_to_file_registry(cabin_publish::RegistryPublishWorkflow {
2097                    manifest_path: &target.manifest_path,
2098                    registry_dir: &registry_dir,
2099                    resolved_project: target.resolved_project.clone(),
2100                })?;
2101            emit_registry_publish_output(&report, args.format)?;
2102        }
2103        (None, true) => {
2104            let output_dir = args
2105                .output_dir
2106                .clone()
2107                .unwrap_or_else(|| PathBuf::from("dist"));
2108            let output_dir = absolutise(&output_dir)
2109                .with_context(|| format!("failed to resolve {}", output_dir.display()))?;
2110            let report = cabin_publish::dry_run(cabin_publish::DryRunRequest {
2111                manifest_path: &target.manifest_path,
2112                output_dir: &output_dir,
2113                resolved_project: target.resolved_project.clone(),
2114            })?;
2115            emit_dry_run_output(&report, args.format)?;
2116        }
2117        (None, false) => {
2118            return Err(cabin_publish::PublishError::DryRunRequired.into());
2119        }
2120    }
2121    Ok(())
2122}
2123
2124fn emit_package_output(
2125    artifact: &cabin_package::PackagedArtifact,
2126    format: ResolveFormat,
2127) -> Result<()> {
2128    match format {
2129        ResolveFormat::Human => {
2130            print_package_human(artifact);
2131            Ok(())
2132        }
2133        ResolveFormat::Json => print_package_json(artifact),
2134    }
2135}
2136
2137fn print_package_human(artifact: &cabin_package::PackagedArtifact) {
2138    println!("Packaged {} {}", artifact.name.as_str(), artifact.version);
2139    println!("  archive: {}", artifact.archive_path.display());
2140    println!("  metadata: {}", artifact.metadata_path.display());
2141    println!("  checksum: {}", artifact.checksum);
2142}
2143
2144fn print_package_json(artifact: &cabin_package::PackagedArtifact) -> Result<()> {
2145    let value = serde_json::json!({
2146        "name": artifact.name.as_str(),
2147        "version": artifact.version.to_string(),
2148        "archive_path": artifact.archive_path,
2149        "metadata_path": artifact.metadata_path,
2150        "checksum": artifact.checksum,
2151    });
2152    crate::print_pretty_json(&value, "failed to serialize package output as JSON")
2153}
2154
2155fn emit_dry_run_output(report: &cabin_publish::DryRunReport, format: ResolveFormat) -> Result<()> {
2156    match format {
2157        ResolveFormat::Human => {
2158            print_dry_run_human(report);
2159            Ok(())
2160        }
2161        ResolveFormat::Json => print_dry_run_json(report),
2162    }
2163}
2164
2165fn print_dry_run_human(report: &cabin_publish::DryRunReport) {
2166    println!(
2167        "Publish dry-run for {} {}",
2168        report.name.as_str(),
2169        report.version
2170    );
2171    println!();
2172    println!("Generated:");
2173    println!("  archive: {}", report.archive_path.display());
2174    println!("  metadata: {}", report.metadata_path.display());
2175    println!("  checksum: {}", report.checksum);
2176    println!();
2177    println!("This was a dry run. No registry was modified.");
2178}
2179
2180fn print_dry_run_json(report: &cabin_publish::DryRunReport) -> Result<()> {
2181    let value = serde_json::json!({
2182        "dry_run": true,
2183        "name": report.name.as_str(),
2184        "version": report.version.to_string(),
2185        "archive_path": report.archive_path,
2186        "metadata_path": report.metadata_path,
2187        "checksum": report.checksum,
2188        "registry_modified": report.registry_modified,
2189    });
2190    crate::print_pretty_json(&value, "failed to serialize publish dry-run output as JSON")
2191}
2192
2193fn emit_registry_publish_output(
2194    report: &cabin_publish::RegistryPublishReport,
2195    format: ResolveFormat,
2196) -> Result<()> {
2197    match format {
2198        ResolveFormat::Human => {
2199            print_registry_publish_human(report);
2200            Ok(())
2201        }
2202        ResolveFormat::Json => print_registry_publish_json(report),
2203    }
2204}
2205
2206fn print_registry_publish_human(report: &cabin_publish::RegistryPublishReport) {
2207    if report.dry_run {
2208        println!(
2209            "Publish dry-run for {} {} against file registry",
2210            report.name.as_str(),
2211            report.version
2212        );
2213    } else {
2214        println!(
2215            "Published {} {} to file registry",
2216            report.name.as_str(),
2217            report.version
2218        );
2219    }
2220    println!("  registry: {}", report.registry_dir.display());
2221    println!("  package index: {}", report.package_index_path.display());
2222    println!("  artifact: {}", report.artifact_path.display());
2223    println!("  checksum: {}", report.checksum);
2224    if report.dry_run {
2225        println!();
2226        if report.registry_initialized {
2227            println!("Registry would be initialized at this path.");
2228        }
2229        println!("This was a dry run. No registry was modified.");
2230    } else if report.registry_initialized {
2231        println!();
2232        println!("Registry was initialized at this path.");
2233    }
2234}
2235
2236fn print_registry_publish_json(report: &cabin_publish::RegistryPublishReport) -> Result<()> {
2237    let value = serde_json::json!({
2238        "published": !report.dry_run,
2239        "dry_run": report.dry_run,
2240        "name": report.name.as_str(),
2241        "version": report.version.to_string(),
2242        "registry_dir": report.registry_dir,
2243        "package_index_path": report.package_index_path,
2244        "artifact_path": report.artifact_path,
2245        "checksum": report.checksum,
2246        "source_path": report.source_path,
2247        "registry_modified": report.registry_modified,
2248        "registry_initialized": report.registry_initialized,
2249    });
2250    crate::print_pretty_json(&value, "failed to serialize publish output as JSON")
2251}
2252
2253/// Translate `cabin build`'s `--profile` / `--release` flags into
2254/// a typed [`cabin_core::ProfileSelection`].
2255///
2256/// `--release` is preserved as a compatibility alias for
2257/// `--profile release`. clap's `conflicts_with` already rejects
2258/// the both-set combination so this helper only sees one of the
2259/// three possible inputs.
2260fn profile_selection_for_build(
2261    args: &BuildArgs,
2262    config: &cabin_config::EffectiveConfig,
2263) -> Result<cabin_core::ProfileSelection> {
2264    profile_selection_from_flags(args.profile.as_deref(), args.release, config)
2265}
2266
2267/// Shared profile-selection precedence: explicit `--profile NAME`
2268/// wins, then the legacy `--release` alias, then any config-
2269/// supplied default, then the built-in `dev` profile. Used by
2270/// `cabin build` and `cabin test`.
2271pub(crate) fn profile_selection_from_flags(
2272    profile: Option<&str>,
2273    release: bool,
2274    config: &cabin_config::EffectiveConfig,
2275) -> Result<cabin_core::ProfileSelection> {
2276    if let Some(name) = profile {
2277        let pname = cabin_core::ProfileName::new(name.to_owned())
2278            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
2279        return Ok(cabin_core::ProfileSelection::from_name(pname));
2280    }
2281    if release {
2282        return Ok(cabin_core::ProfileSelection::release_alias());
2283    }
2284    if let Some((selection, _source)) = crate::config_glue::config_profile_selection(config)? {
2285        return Ok(selection);
2286    }
2287    Ok(cabin_core::ProfileSelection::default_dev())
2288}
2289
2290/// `cabin metadata` accepts a `--profile` flag but no `--release`
2291/// alias (metadata is read-only and doesn't need the legacy spelling).
2292/// Falls back to a config-supplied default when the user did not
2293/// pass `--profile`; otherwise the built-in `dev` profile applies.
2294pub(crate) fn profile_selection_for_metadata(
2295    name: Option<&str>,
2296    config: &cabin_config::EffectiveConfig,
2297) -> Result<cabin_core::ProfileSelection> {
2298    if let Some(n) = name {
2299        let pname = cabin_core::ProfileName::new(n.to_owned())
2300            .map_err(|err| anyhow::anyhow!(err.to_string()))?;
2301        return Ok(cabin_core::ProfileSelection::from_name(pname));
2302    }
2303    if let Some((selection, _source)) = crate::config_glue::config_profile_selection(config)? {
2304        return Ok(selection);
2305    }
2306    Ok(cabin_core::ProfileSelection::default_dev())
2307}
2308
2309/// Look up the profile-definition table that should drive
2310/// resolution. Profiles are workspace-wide: only the entry-point
2311/// manifest's `[profile.*]` tables count, so we read them off the
2312/// graph's root package (workspace root or single-package root).
2313pub(crate) fn workspace_profile_definitions(
2314    graph: &PackageGraph,
2315) -> BTreeMap<cabin_core::ProfileName, cabin_core::ProfileDefinition> {
2316    graph.root_settings.profiles.clone()
2317}
2318
2319/// Workspace-root manifest's `[toolchain]` plus any
2320/// `[target.'cfg(...)'.toolchain]` overrides. Workspace member
2321/// manifests cannot declare a `[toolchain]` table — the workspace
2322/// loader rejects them — so reading off the root is sufficient.
2323pub(crate) fn workspace_toolchain_settings(graph: &PackageGraph) -> cabin_core::ToolchainSettings {
2324    graph.root_settings.toolchain.clone()
2325}
2326
2327/// Translate `cabin build`'s / `cabin metadata`'s tool-selection
2328/// CLI flags into a typed [`cabin_core::ToolchainSelection`].
2329pub(crate) fn toolchain_selection_from_args(
2330    args: &ToolchainSelectionArgs,
2331) -> Result<cabin_core::ToolchainSelection> {
2332    let mut sel = cabin_core::ToolchainSelection::default();
2333    if let Some(raw) = &args.cc {
2334        sel = sel.with_cli(cabin_core::ToolKind::CCompiler, parse_cli_tool(raw)?);
2335    }
2336    if let Some(raw) = &args.cxx {
2337        sel = sel.with_cli(cabin_core::ToolKind::CxxCompiler, parse_cli_tool(raw)?);
2338    }
2339    if let Some(raw) = &args.ar {
2340        sel = sel.with_cli(cabin_core::ToolKind::Archiver, parse_cli_tool(raw)?);
2341    }
2342    Ok(sel)
2343}
2344
2345fn parse_cli_tool(raw: &str) -> Result<cabin_core::ToolSpec> {
2346    let trimmed = raw.trim();
2347    if trimmed.is_empty() {
2348        bail!("tool argument must be a non-empty path or command name");
2349    }
2350    Ok(cabin_core::ToolSpec::parse(trimmed.to_owned()))
2351}
2352
2353/// Resolve a toolchain by layering manifest settings, the
2354/// optional `[toolchain]` config layer, and process-discovered
2355/// defaults on top of `selection` (already-parsed CLI overrides
2356/// or `ToolchainSelection::default()`).
2357pub(crate) fn resolve_toolchain_layered(
2358    graph: &PackageGraph,
2359    selection: &cabin_core::ToolchainSelection,
2360    effective_config: &cabin_config::EffectiveConfig,
2361    host_platform: &cabin_core::TargetPlatform,
2362) -> Result<cabin_core::ResolvedToolchain> {
2363    let manifest_toolchain_settings = workspace_toolchain_settings(graph);
2364    let config_toolchain_layer = crate::config_glue::toolchain_layer(effective_config);
2365    let mut toolchain_inputs = cabin_toolchain::ResolveInputs::from_process(
2366        selection,
2367        &manifest_toolchain_settings,
2368        host_platform,
2369    );
2370    if let Some(layer) = config_toolchain_layer.as_ref() {
2371        toolchain_inputs = toolchain_inputs.with_config(layer);
2372    }
2373    Ok(cabin_toolchain::resolve_toolchain(&toolchain_inputs)?)
2374}
2375
2376/// Translate the `--compiler-wrapper` / `--no-compiler-wrapper`
2377/// CLI flag pair into a typed
2378/// [`cabin_core::CompilerWrapperRequest`] override. Clap already
2379/// rejects passing both flags simultaneously; this helper only
2380/// validates the value passed to `--compiler-wrapper`.
2381pub(crate) fn compiler_wrapper_override_from_args(
2382    args: &ToolchainSelectionArgs,
2383) -> Result<Option<cabin_core::CompilerWrapperRequest>> {
2384    if args.no_compiler_wrapper {
2385        return Ok(Some(cabin_core::CompilerWrapperRequest::Disabled));
2386    }
2387    let Some(raw) = args.compiler_wrapper.as_deref() else {
2388        return Ok(None);
2389    };
2390    let parsed = cabin_core::CompilerWrapperRequest::parse(raw)
2391        .with_context(|| format!("invalid --compiler-wrapper value `{raw}`"))?;
2392    Ok(Some(parsed))
2393}
2394
2395/// Resolve the compiler-cache wrapper by layering the CLI
2396/// override (`--compiler-wrapper` / `--no-compiler-wrapper`), the
2397/// manifest's `[build.cache]` settings, the optional config
2398/// `[build.cache.compiler-wrapper]` layer, and process-detected
2399/// version metadata. Returns the typed resolution on success;
2400/// callers that want fail-soft behavior (e.g. `cabin metadata`)
2401/// call `resolve_compiler_wrapper` directly.
2402pub(crate) fn resolve_compiler_wrapper_layered(
2403    cli_override: Option<cabin_core::CompilerWrapperRequest>,
2404    manifest_settings: &cabin_core::CompilerWrapperManifestSettings,
2405    effective_config: &cabin_config::EffectiveConfig,
2406    host_platform: &cabin_core::TargetPlatform,
2407) -> Result<Option<cabin_core::ResolvedCompilerWrapper>> {
2408    let mut wrapper_inputs = cabin_toolchain::WrapperInputs::from_process(
2409        cli_override,
2410        manifest_settings,
2411        host_platform,
2412    );
2413    if let Some(layer) = crate::config_glue::wrapper_layer(effective_config) {
2414        wrapper_inputs = wrapper_inputs.with_config(layer);
2415    }
2416    cabin_toolchain::resolve_compiler_wrapper(
2417        &wrapper_inputs,
2418        Some(&cabin_toolchain::ProcessRunner),
2419    )
2420    .map_err(|err| anyhow::anyhow!(err.to_string()))
2421}
2422
2423/// Workspace-root manifest's compiler-wrapper settings. Mirrors
2424/// [`workspace_toolchain_settings`] — the workspace loader rejects
2425/// non-empty declarations on member manifests so reading the root
2426/// is sufficient.
2427pub(crate) fn workspace_compiler_wrapper_settings(
2428    graph: &PackageGraph,
2429) -> cabin_core::CompilerWrapperManifestSettings {
2430    graph.root_settings.compiler_wrapper.clone()
2431}
2432
2433/// Compute per-package `ResolvedProfileFlags` for every package in
2434/// the graph. The result is keyed by package index so callers
2435/// (planner, metadata view) can read them without rerunning the
2436/// merge per package.
2437pub(crate) fn resolve_per_package_build_flags(
2438    graph: &PackageGraph,
2439    profile_build: Option<&cabin_core::ProfileFlags>,
2440    host_platform: &cabin_core::TargetPlatform,
2441) -> HashMap<usize, cabin_core::ResolvedProfileFlags> {
2442    let mut out = HashMap::with_capacity(graph.packages.len());
2443    for (idx, pkg) in graph.packages.iter().enumerate() {
2444        // A registry/downloaded dependency's own `[profile]` build flags are
2445        // untrusted: only local packages (the workspace root, its members, and
2446        // `path` dependencies) may contribute raw compiler/linker flags.
2447        // `resolve_build_flags` drops the dependency's cflags/cxxflags/ldflags
2448        // when this is false, so a malicious dependency cannot smuggle a
2449        // code-executing compiler flag (e.g. `-fplugin=`) onto its build line.
2450        let package_trusted = matches!(pkg.kind, cabin_workspace::PackageKind::Local);
2451        let resolved = cabin_core::resolve_build_flags(
2452            &pkg.package.build,
2453            profile_build,
2454            host_platform,
2455            package_trusted,
2456        );
2457        out.insert(idx, resolved);
2458    }
2459    out
2460}
2461
2462/// Apply the documented post-profile build-flag layers — `pkg-config`
2463/// probes for active system dependencies, then `CPPFLAGS` / `CFLAGS`
2464/// / `CXXFLAGS` / `LDFLAGS` from the process environment — in the
2465/// order both layers must run for the resulting
2466/// `BuildConfiguration::fingerprint` to stay stable across commands.
2467/// Reports from both layers are intentionally discarded; callers that
2468/// need them invoke the individual `crate::system_deps_glue` /
2469/// `crate::env_flags_glue` helpers directly.
2470pub(crate) fn augment_build_flags(
2471    graph: &PackageGraph,
2472    host_platform: &cabin_core::TargetPlatform,
2473    dev_for: &BTreeSet<String>,
2474    build_flags: HashMap<usize, cabin_core::ResolvedProfileFlags>,
2475    reporter: Reporter,
2476) -> Result<HashMap<usize, cabin_core::ResolvedProfileFlags>> {
2477    let (build_flags, _system_dep_reports) =
2478        crate::system_deps_glue::augment_build_flags_with_system_deps(
2479            graph,
2480            host_platform,
2481            dev_for,
2482            build_flags,
2483            reporter,
2484        )?;
2485    let (build_flags, _env_build_flags) = crate::env_flags_glue::augment_build_flags_with_env(
2486        graph,
2487        build_flags,
2488        |k| std::env::var_os(k),
2489        reporter,
2490    )?;
2491    Ok(build_flags)
2492}
2493
2494/// Convert raw `--features` flag values into a `SelectionRequest`.
2495/// Validation against package declarations happens later in
2496/// `BuildConfiguration::resolve`.
2497pub(crate) fn build_selection_request(
2498    feature_args: &[String],
2499    all_features: bool,
2500    no_default_features: bool,
2501) -> cabin_core::SelectionRequest {
2502    let mut features: BTreeSet<String> = BTreeSet::new();
2503    for raw in feature_args {
2504        for token in raw.split(',') {
2505            let trimmed = token.trim();
2506            if trimmed.is_empty() {
2507                continue;
2508            }
2509            features.insert(trimmed.to_owned());
2510        }
2511    }
2512    cabin_core::SelectionRequest {
2513        features,
2514        all_features,
2515        no_default_features,
2516    }
2517}
2518
2519/// Resolve a `BuildConfiguration` for every package in the graph.
2520/// CLI feature selection requests apply to primary packages only —
2521/// non-primary packages (transitive path / registry deps) fall back
2522/// to their declared defaults until per-dependency feature requests
2523/// land.
2524pub(crate) fn resolve_build_configurations(
2525    graph: &PackageGraph,
2526    request: &cabin_core::SelectionRequest,
2527    selected: &[usize],
2528    profile: &cabin_core::ResolvedProfile,
2529    toolchain: &cabin_core::ToolchainSummary,
2530    build_flags: &HashMap<usize, cabin_core::ResolvedProfileFlags>,
2531) -> Result<HashMap<usize, cabin_core::BuildConfiguration>> {
2532    use HashMap;
2533    let selected_set: HashSet<usize> = selected.iter().copied().collect();
2534    let mut out: HashMap<usize, cabin_core::BuildConfiguration> = HashMap::new();
2535    for (idx, pkg) in graph.packages.iter().enumerate() {
2536        // CLI feature requests apply only to *selected* packages.
2537        // Non-selected packages — including workspace siblings the
2538        // user did not pick — fall back to their declared defaults
2539        // so an unrelated package's missing feature does not fail
2540        // an unrelated build.
2541        let pkg_request = if selected_set.contains(&idx) {
2542            request.clone()
2543        } else {
2544            cabin_core::SelectionRequest::default()
2545        };
2546        let pkg_flags = build_flags.get(&idx).cloned().unwrap_or_default();
2547        let cfg = cabin_core::BuildConfiguration::resolve(cabin_core::BuildConfigurationInput {
2548            package: pkg.package.name.as_str(),
2549            features: &pkg.package.features,
2550            request: &pkg_request,
2551            profile: profile.clone(),
2552            toolchain: toolchain.clone(),
2553            build_flags: pkg_flags,
2554        })
2555        .with_context(|| {
2556            format!(
2557                "invalid configuration selection for package `{}`",
2558                pkg.package.name.as_str()
2559            )
2560        })?;
2561        out.insert(idx, cfg);
2562    }
2563    Ok(out)
2564}
2565
2566/// Resolve the manifest the user is operating on. When the
2567/// user did not pass `--manifest-path` (the option is `None`), walk
2568/// upward from the current directory looking for a workspace root
2569/// and prefer it. When the user passed `--manifest-path`
2570/// Explicitly — even with the value `cabin.toml` — the supplied
2571/// Path is honored as-is so callers can intentionally target a
2572/// specific manifest from any directory.
2573pub(crate) fn resolve_invocation_manifest(args_path: Option<&Path>) -> Result<PathBuf> {
2574    let cwd = std::env::current_dir().context("failed to determine current directory")?;
2575    match args_path {
2576        Some(path) => {
2577            if path.is_absolute() {
2578                Ok(path.to_path_buf())
2579            } else {
2580                Ok(cwd.join(path))
2581            }
2582        }
2583        None => {
2584            if let Some(found) = cabin_workspace::discover_workspace_root(&cwd)? {
2585                Ok(found.manifest_path)
2586            } else {
2587                Ok(cwd.join(MANIFEST_FILENAME))
2588            }
2589        }
2590    }
2591}
2592
2593/// Convert CLI workspace-selection flags into a
2594/// `cabin_workspace::PackageSelection`. The mode mirrors the order
2595/// of `WorkspaceSelectionArgs`'s field comments.
2596pub(crate) fn build_workspace_selection(
2597    args: &WorkspaceSelectionArgs,
2598) -> cabin_workspace::PackageSelection {
2599    use cabin_workspace::SelectionMode;
2600    let mode = if args.workspace {
2601        SelectionMode::WholeWorkspace
2602    } else if !args.package.is_empty() {
2603        SelectionMode::ExplicitPackages(args.package.clone())
2604    } else if args.default_members {
2605        SelectionMode::DefaultMembers
2606    } else {
2607        SelectionMode::CurrentPackage
2608    };
2609    cabin_workspace::PackageSelection {
2610        mode,
2611        exclude: args.exclude.clone(),
2612    }
2613}
2614
2615/// Build the selection's closure once and adapt a
2616/// [`cabin_feature::FeatureResolution`] handle into the
2617/// `Fn(usize, &str) -> bool` optional-dep filter the workspace
2618/// versioned-dep helpers consume. Shared by the collect / has shims
2619/// below so the closure build + filter adapter live in one place.
2620fn closure_and_optional_filter<'a>(
2621    graph: &PackageGraph,
2622    selection: &cabin_workspace::ResolvedSelection,
2623    features: &'a cabin_feature::FeatureResolution,
2624) -> (BTreeSet<usize>, impl Fn(usize, &str) -> bool + 'a) {
2625    (selection.closure(graph), move |idx, name| {
2626        features.is_optional_dep_enabled(idx, name)
2627    })
2628}
2629
2630/// Collect every versioned dependency reachable from `selection`
2631/// after dropping patched names. Thin shim around the typed API
2632/// in `cabin-workspace`.
2633pub(crate) fn collect_closure_versioned_deps_excluding_patches(
2634    graph: &PackageGraph,
2635    selection: &cabin_workspace::ResolvedSelection,
2636    features: &cabin_feature::FeatureResolution,
2637    patched_names: &BTreeSet<String>,
2638    dev_for: &BTreeSet<String>,
2639) -> Result<BTreeMap<PackageName, semver::VersionReq>> {
2640    let (closure, is_optional_dep_enabled) =
2641        closure_and_optional_filter(graph, selection, features);
2642    cabin_workspace::collect_closure_versioned_deps_excluding_with_dev(
2643        graph,
2644        &closure,
2645        is_optional_dep_enabled,
2646        patched_names,
2647        dev_for,
2648    )
2649    .map_err(Into::into)
2650}
2651
2652/// Merge `extra` into `into`, joining version requirements for
2653/// names that appear in both so the resolver sees a single
2654/// requirement per package. Mirrors the join-and-reparse pattern
2655/// the workspace closure walker uses.
2656fn merge_versioned_deps(
2657    into: &mut BTreeMap<PackageName, semver::VersionReq>,
2658    extra: BTreeMap<PackageName, semver::VersionReq>,
2659) -> Result<()> {
2660    for (name, req) in extra {
2661        match into.entry(name.clone()) {
2662            std::collections::btree_map::Entry::Vacant(slot) => {
2663                slot.insert(req);
2664            }
2665            std::collections::btree_map::Entry::Occupied(mut slot) => {
2666                let parsed = cabin_workspace::combine_version_reqs(&[
2667                    slot.get().to_string(),
2668                    req.to_string(),
2669                ])
2670                .map_err(|(joined, err)| {
2671                    anyhow::anyhow!(
2672                        "conflicting dependency requirements for {}: {}: {}",
2673                        name.as_str(),
2674                        joined,
2675                        err
2676                    )
2677                })?;
2678                slot.insert(parsed);
2679            }
2680        }
2681    }
2682    Ok(())
2683}
2684
2685/// Whether the selected closure carries any versioned
2686/// (registry-bound) dependency that the artifact pipeline would
2687/// need to fetch. Thin shim around the typed API in
2688/// `cabin-workspace`.
2689pub(crate) fn closure_has_versioned_deps_excluding_patches(
2690    graph: &PackageGraph,
2691    selection: &cabin_workspace::ResolvedSelection,
2692    features: &cabin_feature::FeatureResolution,
2693    patched_names: &BTreeSet<String>,
2694    dev_for: &BTreeSet<String>,
2695) -> bool {
2696    let (closure, is_optional_dep_enabled) =
2697        closure_and_optional_filter(graph, selection, features);
2698    cabin_workspace::closure_has_versioned_deps_excluding_with_dev(
2699        graph,
2700        &closure,
2701        is_optional_dep_enabled,
2702        patched_names,
2703        dev_for,
2704    )
2705}
2706
2707/// Resolve features for the selected closure. Roots receive the
2708/// caller-provided request; non-root reachable packages inherit
2709/// requests through dependency edges per the documented feature
2710/// model. The returned [`cabin_feature::FeatureResolution`] is
2711/// then threaded into the dependency-iteration helpers so
2712/// disabled optional dependencies disappear from the resolver /
2713/// fetch / build planning.
2714pub(crate) fn compute_feature_resolution(
2715    graph: &PackageGraph,
2716    selection: &cabin_workspace::ResolvedSelection,
2717    request: &cabin_core::SelectionRequest,
2718) -> Result<cabin_feature::FeatureResolution> {
2719    let root_request: cabin_feature::RootFeatureRequest = request.into();
2720    let platform = cabin_core::TargetPlatform::current();
2721    cabin_feature::resolve_features(graph, &selection.packages, &root_request, &platform)
2722        .map_err(|e| anyhow::anyhow!(e.to_string()))
2723}
2724
2725/// Pick the primary packages that contribute versioned
2726/// deps to a resolve / fetch / update run. When the user passed
2727/// workspace-selection flags, only their selected packages
2728/// contribute. Otherwise the documented default applies (root
2729/// package or every primary).
2730fn selected_resolution_packages(
2731    graph: &PackageGraph,
2732    selection: &cabin_workspace::PackageSelection,
2733) -> Result<cabin_workspace::ResolvedSelection> {
2734    cabin_workspace::resolve_package_selection(graph, selection).map_err(std::convert::Into::into)
2735}
2736
2737/// Pick the single package manifest path that
2738/// `cabin package` / `cabin publish` should operate on. When the
2739/// invocation manifest is a workspace root, the user must supply
2740/// exactly one explicit `--package <name>` selection. Otherwise we
2741/// honor the existing single-package contract.
2742/// Result of selecting a single package manifest for a
2743/// workspace-aware `cabin package` / `cabin publish` invocation.
2744/// Carries both the manifest path and the pre-resolved `Package`,
2745/// so member manifests with `dep = { workspace = true }` reach
2746/// `cabin-package` after the workspace loader has substituted the
2747/// inherited requirement.
2748struct SinglePackageSelection {
2749    manifest_path: PathBuf,
2750    /// `Some` when the manifest was loaded through a workspace
2751    /// (so `cabin-workspace` resolved any `workspace = true` deps).
2752    /// `None` when the user passed a standalone manifest path; in
2753    /// that case `cabin-package`'s own validator decides what to do
2754    /// with any unresolved workspace dep it sees.
2755    resolved_project: Option<cabin_core::Package>,
2756}
2757
2758fn select_single_package_manifest(
2759    invocation: &Path,
2760    selection: &WorkspaceSelectionArgs,
2761    command: &'static str,
2762) -> Result<SinglePackageSelection> {
2763    let parsed = cabin_manifest::load_manifest(invocation)
2764        .with_context(|| format!("failed to load manifest at {}", invocation.display()))?;
2765    if parsed.workspace.is_none() {
2766        // Single-package manifest: the existing behavior applies
2767        // unchanged. Reject workspace-selection flags so the user
2768        // never gets the impression Cabin honored them silently.
2769        if selection.workspace
2770            || selection.default_members
2771            || !selection.package.is_empty()
2772            || !selection.exclude.is_empty()
2773        {
2774            bail!(
2775                "workspace package-selection flags are not valid for `cabin {command}` against a non-workspace manifest"
2776            );
2777        }
2778        return Ok(SinglePackageSelection {
2779            manifest_path: invocation.to_path_buf(),
2780            resolved_project: None,
2781        });
2782    }
2783    if selection.package.len() != 1 || selection.workspace || selection.default_members {
2784        bail!(
2785            "`cabin {command}` requires a single `--package <name>` selection inside a workspace; use `--package <name>` to pick the package to {command}"
2786        );
2787    }
2788    if !selection.exclude.is_empty() {
2789        bail!(
2790            "`--exclude` is not valid for `cabin {command}`; pass exactly one `--package <name>`"
2791        );
2792    }
2793    // Package / publish only need to identify the selected
2794    // workspace member; foundation-port edges are skipped so the
2795    // selection works without network access on workspaces with
2796    // HTTP-backed ports that have never been cached.
2797    let graph = cabin_workspace::load_workspace_skip_ports(invocation)?;
2798    let name = &selection.package[0];
2799    let idx = graph
2800        .index_of(name)
2801        .ok_or_else(|| anyhow::anyhow!("package `{name}` is not a member of this workspace"))?;
2802    if !graph.primary_packages.contains(&idx) {
2803        bail!("package `{name}` is not a member of this workspace");
2804    }
2805    Ok(SinglePackageSelection {
2806        manifest_path: graph.packages[idx].manifest_path.clone(),
2807        resolved_project: Some(graph.packages[idx].package.clone()),
2808    })
2809}
2810
2811pub(crate) fn lock_mode_for_flags(locked: bool, frozen: bool) -> LockMode {
2812    if locked || frozen {
2813        LockMode::Locked
2814    } else {
2815        LockMode::PreferLocked
2816    }
2817}
2818
2819/// Resolve the cache directory using --cache-dir,
2820/// `$CABIN_CACHE_DIR`, or the user-global XDG fallback.
2821///
2822/// Precedence: `--cache-dir` ▶ `$CABIN_CACHE_DIR` ▶
2823/// `$CABIN_CACHE_HOME` ▶ the user XDG cache home (resolved by
2824/// the `xdg` crate with the `cabin` application prefix). The
2825/// fallback shape mirrors `cabin_config::discovery` so the
2826/// cache home and config home follow the same rule.
2827///
2828/// The cache is content-addressed (e.g. foundation-port archives
2829/// land at `<cache>/ports/archives/sha256/<hex>.tar.gz`), so the
2830/// user-global default lets two projects on the same machine
2831/// share a single download.
2832pub(crate) fn cache_dir_for(manifest_path: &Path, override_dir: Option<&Path>) -> Result<PathBuf> {
2833    let xdg_cache_home = xdg::BaseDirectories::with_prefix("cabin").get_cache_home();
2834    cache_dir_for_with_env(
2835        manifest_path,
2836        override_dir,
2837        &|key| std::env::var_os(key),
2838        xdg_cache_home.as_deref(),
2839    )
2840}
2841
2842/// Inner form of [`cache_dir_for`] with the env lookup and the
2843/// xdg-resolved user cache home injected so tests can drive the
2844/// precedence chain without touching real process env. Production
2845/// code calls [`cache_dir_for`].
2846fn cache_dir_for_with_env(
2847    manifest_path: &Path,
2848    override_dir: Option<&Path>,
2849    env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
2850    xdg_cache_home: Option<&Path>,
2851) -> Result<PathBuf> {
2852    if let Some(p) = override_dir {
2853        return absolutise(p)
2854            .with_context(|| format!("failed to resolve cache dir {}", p.display()));
2855    }
2856    if let Some(val) = env("CABIN_CACHE_DIR").filter(|v| !v.is_empty()) {
2857        let p = PathBuf::from(val);
2858        return absolutise(&p)
2859            .with_context(|| format!("failed to resolve cache dir {}", p.display()));
2860    }
2861    // `manifest_path` is unused on the XDG default arm — it was
2862    // only consulted by the prior project-local fallback. Keep
2863    // it in the signature so call sites don't churn for what is
2864    // logically an internal refactor; reference it once so clippy
2865    // doesn't flag it as unused.
2866    let _ = manifest_path;
2867    user_cache_default(env, xdg_cache_home).ok_or_else(|| {
2868        anyhow::anyhow!(
2869            "no cache directory: set --cache-dir, CABIN_CACHE_DIR, CABIN_CACHE_HOME, XDG_CACHE_HOME, or HOME"
2870        )
2871    })
2872}
2873
2874/// User-global cache root: `$CABIN_CACHE_HOME` if set, otherwise
2875/// the xdg-resolved user cache home with the `cabin` application
2876/// prefix already applied (`$XDG_CACHE_HOME/cabin`, falling back
2877/// to `$HOME/.cache/cabin` per the XDG Base Directory spec). The
2878/// `CABIN_CACHE_HOME` override is Cabin-specific and resolves
2879/// directly to its value with no extra `cabin` component.
2880fn user_cache_default(
2881    env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
2882    xdg_cache_home: Option<&Path>,
2883) -> Option<PathBuf> {
2884    if let Some(d) = env("CABIN_CACHE_HOME").filter(|v| !v.is_empty()) {
2885        return Some(PathBuf::from(d));
2886    }
2887    xdg_cache_home.map(Path::to_path_buf)
2888}
2889
2890pub(crate) struct ArtifactPipelineRequest<'a> {
2891    pub(crate) manifest_path: &'a Path,
2892    pub(crate) initial_graph: &'a PackageGraph,
2893    pub(crate) index_path: Option<&'a Path>,
2894    pub(crate) index_url: Option<&'a str>,
2895    pub(crate) mode: LockMode,
2896    pub(crate) allow_write: bool,
2897    pub(crate) frozen: bool,
2898    pub(crate) cache_dir: &'a Path,
2899    pub(crate) reporter: Reporter,
2900    /// Workspace selection that contributes versioned deps
2901    /// to the resolution. Defaults to every primary package when
2902    /// the user passes no selection flags.
2903    pub(crate) selection: cabin_workspace::PackageSelection,
2904    /// Feature flags from the CLI. Drives optional-dependency
2905    /// inclusion.
2906    pub(crate) selection_request: &'a cabin_core::SelectionRequest,
2907    /// Names of patched packages — the pipeline must skip them
2908    /// because they ship from a local working copy and never need
2909    /// to be fetched from the index.
2910    pub(crate) patched_names: &'a BTreeSet<String>,
2911    /// Active patches recorded into the new lockfile and
2912    /// compared against the existing lockfile under `--locked`.
2913    pub(crate) active_patches: &'a cabin_workspace::ActivePatchSet,
2914    /// Active source-replacement entries (post-merge) recorded
2915    /// into the new lockfile.
2916    pub(crate) source_replacements: &'a cabin_core::SourceReplacementSettings,
2917    /// Whether `--no-patches` was supplied — suppresses
2918    /// source-replacement records on the lockfile to match the
2919    /// "no local override policy" semantics.
2920    pub(crate) no_patches: bool,
2921    /// Names of packages whose `[dev-dependencies]` should be
2922    /// activated for this invocation. Empty for `cabin build`;
2923    /// `cabin test` passes the selected primary packages' names
2924    /// so the resolver / fetch path picks up dev-deps the test
2925    /// executables need.
2926    pub(crate) dev_for: &'a BTreeSet<String>,
2927}
2928
2929pub(crate) struct ArtifactPipeline {
2930    pub(crate) fetched: Vec<FetchedPackage>,
2931}
2932
2933impl ArtifactPipeline {
2934    /// Project each fetched package into the
2935    /// [`RegistryPackageSource`] the workspace loader consumes,
2936    /// pinning every manifest at `<source_dir>/cabin.toml`. Shared
2937    /// by `build` / `run` / `test`, which all feed the fetched
2938    /// closure back into a strict workspace reload.
2939    pub(crate) fn registry_sources(&self) -> Vec<RegistryPackageSource> {
2940        self.fetched
2941            .iter()
2942            .map(|p| RegistryPackageSource {
2943                name: p.name.clone(),
2944                version: p.version.clone(),
2945                manifest_path: p.source_dir.join("cabin.toml"),
2946            })
2947            .collect()
2948    }
2949}
2950
2951/// Resolved index access: either a directory on disk we already
2952/// turned into a [`PackageIndex`], or a live HTTP client we will use
2953/// to download artifacts.
2954enum IndexAccess {
2955    Local,
2956    Http(cabin_index_http::HttpClient),
2957}
2958
2959/// Run the resolve → lockfile → fetch pipeline used by both
2960/// `cabin fetch` and `cabin build`.
2961pub(crate) fn run_artifact_pipeline(
2962    request: &ArtifactPipelineRequest<'_>,
2963) -> Result<ArtifactPipeline> {
2964    let manifest_path = request.manifest_path;
2965    let graph = request.initial_graph;
2966    let resolved_selection = selected_resolution_packages(graph, &request.selection)?;
2967    let features =
2968        compute_feature_resolution(graph, &resolved_selection, request.selection_request)?;
2969    let mut root_deps = collect_closure_versioned_deps_excluding_patches(
2970        graph,
2971        &resolved_selection,
2972        &features,
2973        request.patched_names,
2974        request.dev_for,
2975    )?;
2976    // Patched manifests are not part of the workspace graph at
2977    // this point, so their own `[dependencies]` never appeared
2978    // in the closure walk. Fold them in so a workspace whose only
2979    // versioned dep is patched still resolves and fetches the
2980    // patched manifest's transitive registry edges.
2981    let patched_root_deps =
2982        collect_patched_versioned_deps(request.active_patches, request.patched_names)?;
2983    merge_versioned_deps(&mut root_deps, patched_root_deps)?;
2984    // short-circuit when neither the selected closure nor the
2985    // active patch set introduces a versioned dependency.
2986    // Loading an index, walking the lockfile, and downloading
2987    // artifacts are all unnecessary in that case.
2988    if root_deps.is_empty() {
2989        return Ok(ArtifactPipeline {
2990            fetched: Vec::new(),
2991        });
2992    }
2993    // pick a stable synthetic root identity for pure
2994    // workspace roots; fall back to the [package] root otherwise.
2995    let (root_name, root_version) = match graph.root_package {
2996        Some(idx) => (
2997            graph.packages[idx].package.name.clone(),
2998            graph.packages[idx].package.version.clone(),
2999        ),
3000        None => cabin_workspace::synthetic_root_identity(graph),
3001    };
3002
3003    let lockfile_path = lockfile_path_for(manifest_path);
3004
3005    let existing_lockfile: Option<Lockfile> = if lockfile_path.is_file() {
3006        Some(
3007            cabin_lockfile::read_lockfile(&lockfile_path)
3008                .with_context(|| format!("failed to read {}", lockfile_path.display()))?,
3009        )
3010    } else {
3011        if matches!(request.mode, LockMode::Locked) {
3012            bail!(
3013                "cannot resolve with --locked because {} does not exist",
3014                lockfile_path.display()
3015            );
3016        }
3017        None
3018    };
3019
3020    let (index, access) = load_index_for_pipeline(
3021        request.index_path,
3022        request.index_url,
3023        request.frozen,
3024        &root_deps,
3025    )?;
3026
3027    let resolver_mode = match &request.mode {
3028        LockMode::PreferLocked => ResolveMode::PreferLocked,
3029        LockMode::Locked => ResolveMode::Locked,
3030        LockMode::UpdateAll => ResolveMode::UpdateAll,
3031        LockMode::UpdatePackage(name) => ResolveMode::UpdatePackage(
3032            PackageName::new(name.clone())
3033                .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?,
3034        ),
3035    };
3036
3037    let mut input = ResolveInput::new(root_name, root_version, root_deps);
3038    if let Some(lock) = &existing_lockfile {
3039        for pkg in &lock.packages {
3040            input.locked.insert(
3041                pkg.name.clone(),
3042                LockedVersion {
3043                    version: pkg.version.clone(),
3044                    checksum: pkg.checksum.clone(),
3045                },
3046            );
3047        }
3048    }
3049    input.mode = resolver_mode;
3050
3051    // Patch / source-replacement state recorded into the new
3052    // lockfile and compared against the existing lockfile under
3053    // `--locked`.
3054    let active_patch_records = crate::patch_glue::lockfile_patches(request.active_patches);
3055    let active_replacement_records = crate::patch_glue::lockfile_source_replacements(
3056        request.source_replacements,
3057        request.no_patches,
3058    );
3059    if matches!(request.mode, LockMode::Locked)
3060        && let Some(prev) = &existing_lockfile
3061        && !prev.matches_patch_state(&active_patch_records, &active_replacement_records)
3062    {
3063        bail!(
3064            "--locked cannot be used because active patch / source-replacement policy differs from {}; re-run without --locked to refresh the lockfile",
3065            lockfile_path.display()
3066        );
3067    }
3068
3069    let output = cabin_resolver::resolve(&input, &index).context("dependency resolution failed")?;
3070
3071    let mut new_lockfile = lockfile_from_resolution(&output, &index);
3072    new_lockfile.patches = active_patch_records;
3073    new_lockfile.source_replacements = active_replacement_records;
3074
3075    if request.allow_write {
3076        let needs_write = match &existing_lockfile {
3077            Some(prev) => prev != &new_lockfile,
3078            None => true,
3079        };
3080        if needs_write {
3081            cabin_lockfile::write_lockfile(&lockfile_path, &new_lockfile)
3082                .with_context(|| format!("failed to write {}", lockfile_path.display()))?;
3083            request
3084                .reporter
3085                .aux_verbose(format_args!("cabin: wrote {}", lockfile_path.display()));
3086        } else {
3087            request.reporter.aux_verbose(format_args!(
3088                "cabin: {} is up to date",
3089                lockfile_path.display()
3090            ));
3091        }
3092    }
3093
3094    let plan = build_fetch_plan(&output, &index, &access)?;
3095    let cache = ArtifactCache::new(request.cache_dir);
3096    let result = cabin_artifact::fetch(
3097        &plan,
3098        &cache,
3099        FetchOptions {
3100            frozen: request.frozen,
3101        },
3102    )?;
3103    Ok(ArtifactPipeline {
3104        fetched: result.packages,
3105    })
3106}
3107
3108/// Pick the right index source for a fetch / build run, validate
3109/// CLI flag combinations, and return both the [`PackageIndex`] the
3110/// Resolver consumes and a tag describing which access mode the
3111/// fetch plan should use.
3112fn load_index_for_pipeline(
3113    index_path: Option<&Path>,
3114    index_url: Option<&str>,
3115    frozen: bool,
3116    root_deps: &BTreeMap<PackageName, semver::VersionReq>,
3117) -> Result<(PackageIndex, IndexAccess)> {
3118    match (index_path, index_url) {
3119        (Some(_), Some(_)) => bail!("use either --index-path or --index-url, not both"),
3120        (None, None) => {
3121            bail!("versioned dependencies require --index-path or --index-url")
3122        }
3123        (Some(path), None) => {
3124            let index_path = absolutise(path)
3125                .with_context(|| format!("failed to resolve {}", path.display()))?;
3126            let index = cabin_index::load_index(&index_path)
3127                .with_context(|| format!("failed to load index at {}", index_path.display()))?;
3128            Ok((index, IndexAccess::Local))
3129        }
3130        (None, Some(url)) => {
3131            if frozen {
3132                bail!(
3133                    "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"
3134                );
3135            }
3136            let client = cabin_index_http::HttpClient::new();
3137            let http_index = cabin_index_http::HttpIndex::open(url, client.clone())?;
3138            let names: Vec<PackageName> = root_deps.keys().cloned().collect();
3139            let index = http_index.load_package_index(&names)?;
3140            Ok((index, IndexAccess::Http(client)))
3141        }
3142    }
3143}
3144
3145/// Build a [`FetchPlan`] from a resolver output and the index it ran
3146/// against. Each resolved registry package contributes exactly one
3147/// fetch entry; the index is the source of truth for `source` and
3148/// `checksum`.
3149///
3150/// `access` decides whether HTTP-resolved sources get downloaded
3151/// here (so `cabin-artifact` stays HTTP-free) or whether the source
3152/// Path is handed straight through as a local file.
3153fn build_fetch_plan(
3154    output: &ResolveOutput,
3155    index: &PackageIndex,
3156    access: &IndexAccess,
3157) -> Result<FetchPlan> {
3158    let mut entries = Vec::new();
3159    for resolved in &output.packages {
3160        if resolved.source != ResolvedSource::Index {
3161            continue;
3162        }
3163        let entry = index.package(&resolved.name).ok_or_else(|| {
3164            anyhow::anyhow!(
3165                "resolver chose `{} {}`, but it is not in the index",
3166                resolved.name.as_str(),
3167                resolved.version
3168            )
3169        })?;
3170        let meta = entry.versions.get(&resolved.version).ok_or_else(|| {
3171            anyhow::anyhow!(
3172                "resolver chose `{} {}`, but the index has no entry for this version",
3173                resolved.name.as_str(),
3174                resolved.version
3175            )
3176        })?;
3177        let source = meta.source.as_ref().ok_or_else(|| {
3178            anyhow::anyhow!(
3179                "package `{} {}` has no source artifact in the index",
3180                resolved.name.as_str(),
3181                resolved.version
3182            )
3183        })?;
3184        let checksum = meta.checksum.clone().ok_or_else(|| {
3185            anyhow::anyhow!(
3186                "missing checksum for `{} {}`; cabin fetch requires a sha256:<hex> entry in the index",
3187                resolved.name.as_str(),
3188                resolved.version
3189            )
3190        })?;
3191        let fetch_source = match (&source.location, access) {
3192            (cabin_index::SourceLocation::LocalPath(p), _) => {
3193                cabin_artifact::FetchSource::LocalArchive(p.clone())
3194            }
3195            (cabin_index::SourceLocation::HttpUrl(url), IndexAccess::Http(client)) => {
3196                let label = format!("{} {}", resolved.name.as_str(), resolved.version);
3197                let bytes = client.download(url, &label).with_context(|| {
3198                    format!(
3199                        "failed to download source archive for `{} {}`",
3200                        resolved.name.as_str(),
3201                        resolved.version
3202                    )
3203                })?;
3204                cabin_artifact::FetchSource::InMemoryArchive(bytes)
3205            }
3206            (cabin_index::SourceLocation::HttpUrl(_), IndexAccess::Local) => {
3207                bail!(
3208                    "package `{} {}` has an HTTP source URL but the run is using a local index",
3209                    resolved.name.as_str(),
3210                    resolved.version
3211                );
3212            }
3213        };
3214        entries.push(FetchEntry {
3215            name: resolved.name.clone(),
3216            version: resolved.version.clone(),
3217            checksum,
3218            source: fetch_source,
3219        });
3220    }
3221    Ok(FetchPlan { entries })
3222}
3223
3224/// What kind of resolution the CLI is asking for.
3225#[derive(Debug, Clone)]
3226pub(crate) enum LockMode {
3227    PreferLocked,
3228    Locked,
3229    UpdateAll,
3230    UpdatePackage(String),
3231}
3232
3233struct ResolutionRequest<'a> {
3234    manifest_path: &'a Path,
3235    index_path: Option<&'a Path>,
3236    index_url: Option<&'a str>,
3237    format: ResolveFormat,
3238    mode: LockMode,
3239    allow_write: bool,
3240    /// Whether the original invocation was `cabin resolve --frozen`.
3241    /// `LockMode::Locked` intentionally covers both `--locked` and
3242    /// `--frozen`, so keep this bit to enforce frozen-only network
3243    /// restrictions after config and source replacement are applied.
3244    frozen: bool,
3245    /// Used only by `cabin update --package <name>` to validate that the
3246    /// named package actually exists in the manifest's dependency
3247    /// graph.
3248    update_package: Option<&'a str>,
3249    /// Workspace selection that contributes versioned deps
3250    /// to the resolution.
3251    selection: cabin_workspace::PackageSelection,
3252    /// Feature flags from the CLI. Drives optional-dependency
3253    /// inclusion.
3254    selection_request: cabin_core::SelectionRequest,
3255    /// Whether `--no-patches` was supplied for this command.
3256    no_patches: bool,
3257    /// Whether `--offline` was supplied for this command.
3258    offline: bool,
3259}
3260
3261fn run_resolution(request: &ResolutionRequest<'_>, reporter: Reporter) -> Result<()> {
3262    let manifest_path = absolutise(request.manifest_path)
3263        .with_context(|| format!("failed to resolve {}", request.manifest_path.display()))?;
3264    let offline = crate::config_glue::effective_offline(request.offline)?;
3265    let (_port_sources, graph) = crate::port_glue::prepare_ports_and_load_initial_graph(
3266        &manifest_path,
3267        None,
3268        offline,
3269        request.frozen,
3270        false,
3271        &request.selection,
3272        request.no_patches,
3273    )?;
3274    // CLI flags win; otherwise consult the merged effective
3275    // config for a `[registry]` default. The orchestration layer
3276    // owns the final reconciliation; cabin-resolver / cabin-index
3277    // see only a concrete index source.
3278    let effective_config = crate::config_glue::load_effective_config(&graph)?;
3279    let active_patches =
3280        crate::patch_glue::load_active_patches(&graph, &effective_config, request.no_patches)?;
3281    let patched_names = active_patches.owned_patched_names();
3282    let resolved_index_source = crate::config_glue::resolve_index_source(
3283        request.index_path,
3284        request.index_url,
3285        &effective_config,
3286    )?;
3287    let resolution_offline = crate::config_glue::effective_offline(request.offline)?;
3288    crate::config_glue::enforce_offline_index_source(
3289        resolution_offline,
3290        resolved_index_source.as_ref(),
3291    )?;
3292    let (config_index_path, config_index_url): (Option<PathBuf>, Option<String>) =
3293        match resolved_index_source.as_ref() {
3294            Some(source) => {
3295                let initial = crate::config_glue::index_source_kind_to_locator(&source.kind);
3296                let resolved = crate::patch_glue::apply_source_replacement(
3297                    initial,
3298                    &effective_config,
3299                    request.no_patches,
3300                )?;
3301                crate::config_glue::enforce_offline_post_replacement(
3302                    resolution_offline,
3303                    &resolved,
3304                )?;
3305                crate::patch_glue::locator_to_index_inputs(&resolved.resolved)
3306            }
3307            None => (None, None),
3308        };
3309    let effective_index_path = config_index_path.as_deref();
3310    let effective_index_url = config_index_url.as_deref();
3311    if request.frozen && effective_index_url.is_some() {
3312        bail!(
3313            "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"
3314        );
3315    }
3316
3317    // gather versioned deps from the selected primary
3318    // packages, not just the workspace root. Pure-workspace roots
3319    // (no `[package]`) work too — they take a synthetic identity.
3320    let resolved_selection = selected_resolution_packages(&graph, &request.selection)?;
3321    let features =
3322        compute_feature_resolution(&graph, &resolved_selection, &request.selection_request)?;
3323    let dev_for: BTreeSet<String> = BTreeSet::new();
3324    let mut root_deps = collect_closure_versioned_deps_excluding_patches(
3325        &graph,
3326        &resolved_selection,
3327        &features,
3328        &patched_names,
3329        &dev_for,
3330    )?;
3331    // Patched manifests live outside the workspace graph, so
3332    // their own versioned deps never reached the closure walk.
3333    // Fold them in so `cabin resolve` (and `--package` validation
3334    // below) sees the same root set the artifact pipeline does.
3335    let patched_root_deps = collect_patched_versioned_deps(&active_patches, &patched_names)?;
3336    merge_versioned_deps(&mut root_deps, patched_root_deps)?;
3337    let (root_name, root_version) = match graph.root_package {
3338        Some(idx) => (
3339            graph.packages[idx].package.name.clone(),
3340            graph.packages[idx].package.version.clone(),
3341        ),
3342        None => cabin_workspace::synthetic_root_identity(&graph),
3343    };
3344
3345    let lockfile_path = lockfile_path_for(&manifest_path);
3346
3347    // validate `--package` (the dep-targeted-update
3348    // flag on `cabin update`) before short-circuiting on an
3349    // empty resolution. Otherwise an unknown name like
3350    // `cabin update --package missing` silently succeeds when
3351    // the workspace happens to have no versioned deps.
3352    if let Some(name) = request.update_package
3353        && !root_deps.contains_key(
3354            &PackageName::new(name)
3355                .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?,
3356        )
3357    {
3358        // `cabin update --package <name>` targets a *direct*
3359        // versioned dependency only. The matching set is the
3360        // resolver's input — any name declared under
3361        // `[dependencies]` (the
3362        // kinds that participate in ordinary resolution).
3363        // Refreshing a transitive locked package requires
3364        // re-running `cabin update` without `--package`, or
3365        // scoping with `--workspace` / `--default-members`.
3366        bail!(
3367            "package {name:?} is not a direct versioned dependency of `{}`; `cabin update --package` only refreshes direct dependencies declared under `[dependencies]`",
3368            root_name.as_str(),
3369        );
3370    }
3371
3372    // Read the lockfile up-front so the patch / source-replacement
3373    // staleness check below can apply even when the active patch
3374    // set covers every versioned dep (and the resolver itself has
3375    // nothing to do).
3376    let existing_lockfile: Option<Lockfile> = if lockfile_path.is_file() {
3377        Some(
3378            cabin_lockfile::read_lockfile(&lockfile_path)
3379                .with_context(|| format!("failed to read {}", lockfile_path.display()))?,
3380        )
3381    } else {
3382        None
3383    };
3384
3385    // Patch / source-replacement state recorded into the new
3386    // lockfile and compared against the existing lockfile under
3387    // `--locked`. Computed early so the no-versioned-deps fast
3388    // path below can still enforce the staleness check: if the
3389    // user added or removed a patch since the lockfile was
3390    // written, `--locked` must refuse, even though the resolver
3391    // itself would otherwise have nothing to do.
3392    let active_patch_records = crate::patch_glue::lockfile_patches(&active_patches);
3393    let active_replacement_records = crate::patch_glue::lockfile_source_replacements(
3394        &effective_config.source_replacements,
3395        request.no_patches,
3396    );
3397    if matches!(request.mode, LockMode::Locked)
3398        && let Some(prev) = &existing_lockfile
3399        && !prev.matches_patch_state(&active_patch_records, &active_replacement_records)
3400    {
3401        bail!(
3402            "--locked cannot be used because active patch / source-replacement policy differs from {}; re-run without --locked to refresh the lockfile",
3403            lockfile_path.display()
3404        );
3405    }
3406
3407    if root_deps.is_empty() {
3408        // No versioned deps to resolve. Print a clear empty result
3409        // and never touch the lockfile. The patch-staleness check
3410        // above already ran, so `--locked` will already have bailed
3411        // if the patch set diverged from the lockfile's record.
3412        let output = ResolveOutput {
3413            packages: vec![ResolvedPackage {
3414                name: root_name,
3415                version: root_version,
3416                source: ResolvedSource::Root,
3417            }],
3418        };
3419        emit_resolve_output(&output, request.format)?;
3420        return Ok(());
3421    }
3422
3423    // Locked mode (with versioned deps) still requires an existing
3424    // lockfile — the staleness check above is a no-op when one is
3425    // missing.
3426    if existing_lockfile.is_none() && matches!(request.mode, LockMode::Locked) {
3427        bail!(
3428            "cannot resolve with --locked because {} does not exist",
3429            lockfile_path.display()
3430        );
3431    }
3432
3433    let index = match (effective_index_path, effective_index_url) {
3434        (None, None) => {
3435            bail!(
3436                "versioned dependencies require --index-path, --index-url, or a `[registry]` config setting"
3437            )
3438        }
3439        (Some(path), None) => {
3440            let index_path = absolutise(path)
3441                .with_context(|| format!("failed to resolve {}", path.display()))?;
3442            cabin_index::load_index(&index_path)
3443                .with_context(|| format!("failed to load index at {}", index_path.display()))?
3444        }
3445        (None, Some(url)) => {
3446            let client = cabin_index_http::HttpClient::new();
3447            let http_index = cabin_index_http::HttpIndex::open(url, client)?;
3448            let names: Vec<PackageName> = root_deps.keys().cloned().collect();
3449            http_index.load_package_index(&names)?
3450        }
3451        (Some(_), Some(_)) => {
3452            unreachable!("config_glue::resolve_index_source guarantees only one variant is set")
3453        }
3454    };
3455
3456    let resolver_mode = match &request.mode {
3457        LockMode::PreferLocked => ResolveMode::PreferLocked,
3458        LockMode::Locked => ResolveMode::Locked,
3459        LockMode::UpdateAll => ResolveMode::UpdateAll,
3460        LockMode::UpdatePackage(name) => ResolveMode::UpdatePackage(
3461            PackageName::new(name.clone())
3462                .map_err(|err| anyhow::anyhow!("invalid --package value {name:?}: {err}"))?,
3463        ),
3464    };
3465
3466    let mut input = ResolveInput::new(root_name, root_version, root_deps);
3467    if let Some(lock) = &existing_lockfile {
3468        for pkg in &lock.packages {
3469            input.locked.insert(
3470                pkg.name.clone(),
3471                LockedVersion {
3472                    version: pkg.version.clone(),
3473                    checksum: pkg.checksum.clone(),
3474                },
3475            );
3476        }
3477    }
3478    input.mode = resolver_mode;
3479
3480    let output = cabin_resolver::resolve(&input, &index).context("dependency resolution failed")?;
3481
3482    let mut new_lockfile = lockfile_from_resolution(&output, &index);
3483    new_lockfile.patches = active_patch_records;
3484    new_lockfile.source_replacements = active_replacement_records;
3485
3486    if request.allow_write {
3487        let needs_write = match &existing_lockfile {
3488            Some(prev) => prev != &new_lockfile,
3489            None => true,
3490        };
3491        if needs_write {
3492            cabin_lockfile::write_lockfile(&lockfile_path, &new_lockfile)
3493                .with_context(|| format!("failed to write {}", lockfile_path.display()))?;
3494            reporter.aux_verbose(format_args!("cabin: wrote {}", lockfile_path.display()));
3495        } else {
3496            reporter.aux_verbose(format_args!(
3497                "cabin: {} is up to date",
3498                lockfile_path.display()
3499            ));
3500        }
3501    } else if matches!(request.mode, LockMode::Locked)
3502        && let Some(prev) = &existing_lockfile
3503        && prev != &new_lockfile
3504    {
3505        // We allowed PreferLocked-style search inside the
3506        // resolver but Locked mode forces selection to come
3507        // from the lockfile; this branch is a defensive
3508        // fallback if a future change loosens that.
3509        bail!(
3510            "{} is stale; run `cabin resolve` or `cabin update` to refresh it",
3511            lockfile_path.display()
3512        );
3513    }
3514
3515    emit_resolve_output(&output, request.format)?;
3516    Ok(())
3517}
3518
3519pub(crate) fn lockfile_path_for(manifest_path: &Path) -> PathBuf {
3520    manifest_path
3521        .parent()
3522        .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf)
3523        .join("cabin.lock")
3524}
3525
3526/// Read the lockfile at `lockfile_path` if it exists, attaching a
3527/// read-error context that names the path. Returns `Ok(None)` when
3528/// the file is absent. Shared by the read-only inspection commands
3529/// (`metadata` / `tree` / `explain`); the commands that enforce
3530/// `--locked` keep their own bespoke read so the missing-lockfile
3531/// case stays a hard error there.
3532pub(crate) fn read_optional_lockfile(lockfile_path: &Path) -> Result<Option<Lockfile>> {
3533    if lockfile_path.is_file() {
3534        Ok(Some(
3535            cabin_lockfile::read_lockfile(lockfile_path)
3536                .with_context(|| format!("failed to read {}", lockfile_path.display()))?,
3537        ))
3538    } else {
3539        Ok(None)
3540    }
3541}
3542
3543fn lockfile_from_resolution(output: &ResolveOutput, index: &cabin_index::PackageIndex) -> Lockfile {
3544    // We need each resolved package's transitive deps to write the
3545    // lockfile's `dependencies = [...]` field. The resolver doesn't
3546    // surface the dep edges directly, so we read them off the index
3547    // entry for the chosen version.
3548    let resolved_names: BTreeSet<&str> = output
3549        .packages
3550        .iter()
3551        .filter(|p| p.source == ResolvedSource::Index)
3552        .map(|p| p.name.as_str())
3553        .collect();
3554    let mut packages: Vec<LockedPackage> = Vec::new();
3555    for pkg in &output.packages {
3556        if pkg.source != ResolvedSource::Index {
3557            continue;
3558        }
3559        let entry = index
3560            .package(&pkg.name)
3561            .expect("index has every resolved package");
3562        let meta = entry
3563            .versions
3564            .get(&pkg.version)
3565            .expect("index has the resolved version");
3566        // Filter to only dep names that are also resolved (defensive).
3567        let mut deps: Vec<PackageName> = meta
3568            .dependencies
3569            .keys()
3570            .filter(|n| resolved_names.contains(n.as_str()))
3571            .cloned()
3572            .collect();
3573        deps.sort();
3574        packages.push(LockedPackage {
3575            name: pkg.name.clone(),
3576            version: pkg.version.clone(),
3577            source: LockedSource::Index,
3578            checksum: meta.checksum.clone(),
3579            dependencies: deps,
3580        });
3581    }
3582    packages.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
3583    Lockfile {
3584        version: cabin_lockfile::LOCKFILE_VERSION,
3585        packages,
3586        patches: Vec::new(),
3587        source_replacements: Vec::new(),
3588    }
3589}
3590
3591fn emit_resolve_output(output: &ResolveOutput, format: ResolveFormat) -> Result<()> {
3592    match format {
3593        ResolveFormat::Human => print_resolve_human(output),
3594        ResolveFormat::Json => print_resolve_json(output),
3595    }
3596}
3597
3598fn print_resolve_human(output: &ResolveOutput) -> Result<()> {
3599    let root = output
3600        .packages
3601        .iter()
3602        .find(|p| p.source == ResolvedSource::Root)
3603        .ok_or_else(|| anyhow::anyhow!("resolver output is missing a root package"))?;
3604    println!(
3605        "Resolved dependencies for {} {}:",
3606        root.name.as_str(),
3607        root.version
3608    );
3609    let mut others: Vec<&cabin_resolver::ResolvedPackage> = output
3610        .packages
3611        .iter()
3612        .filter(|p| p.source != ResolvedSource::Root)
3613        .collect();
3614    others.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
3615    if others.is_empty() {
3616        println!("  (no versioned dependencies)");
3617    } else {
3618        for pkg in others {
3619            println!("  {} {}", pkg.name.as_str(), pkg.version);
3620        }
3621    }
3622    Ok(())
3623}
3624
3625fn print_resolve_json(output: &ResolveOutput) -> Result<()> {
3626    let root = output
3627        .packages
3628        .iter()
3629        .find(|p| p.source == ResolvedSource::Root)
3630        .ok_or_else(|| anyhow::anyhow!("resolver output is missing a root package"))?;
3631    let json_root = serde_json::json!({
3632        "name": root.name.as_str(),
3633        "version": root.version.to_string(),
3634    });
3635    let json_packages: Vec<_> = output
3636        .packages
3637        .iter()
3638        .filter(|p| p.source != ResolvedSource::Root)
3639        .map(|p| {
3640            serde_json::json!({
3641                "name": p.name.as_str(),
3642                "version": p.version.to_string(),
3643                "source": p.source.as_str(),
3644            })
3645        })
3646        .collect();
3647    let value = serde_json::json!({
3648        "root": json_root,
3649        "packages": json_packages,
3650    });
3651    crate::print_pretty_json(&value, "failed to serialize resolve output as JSON")
3652}
3653
3654/// Resolve a path to an absolute one without requiring it to exist.
3655pub(crate) fn absolutise(path: &Path) -> std::io::Result<PathBuf> {
3656    if path.is_absolute() {
3657        Ok(path.to_path_buf())
3658    } else {
3659        Ok(std::env::current_dir()?.join(path))
3660    }
3661}
3662
3663#[cfg(test)]
3664mod tests {
3665    use super::*;
3666
3667    #[test]
3668    fn rendered_binary_template_round_trips_through_parser() {
3669        let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Binary);
3670        let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap();
3671        let package = parsed.package.expect("template should parse as a package");
3672        assert_eq!(package.name.as_str(), "hello");
3673        assert_eq!(package.targets.len(), 1);
3674        assert_eq!(package.targets[0].name.as_str(), "hello");
3675    }
3676
3677    #[test]
3678    fn rendered_library_template_round_trips_through_parser() {
3679        let manifest = scaffold::render_manifest("hello", scaffold::ScaffoldKind::Library);
3680        let parsed = cabin_manifest::parse_manifest_str(&manifest).unwrap();
3681        let package = parsed.package.expect("template should parse as a package");
3682        assert_eq!(package.name.as_str(), "hello");
3683        assert_eq!(package.targets.len(), 1);
3684        assert_eq!(package.targets[0].name.as_str(), "hello");
3685    }
3686
3687    #[test]
3688    fn registry_dependency_build_flags_are_dropped_but_local_kept() {
3689        use cabin_core::{Package, Target};
3690        use cabin_workspace::{PackageKind, WorkspacePackage};
3691        use std::path::PathBuf;
3692
3693        fn dep_with_command_flags(name: &str, kind: PackageKind) -> WorkspacePackage {
3694            let mut package = Package::new(
3695                PackageName::new(name).unwrap(),
3696                semver::Version::parse("0.1.0").unwrap(),
3697                Vec::<Target>::new(),
3698                Vec::new(),
3699            )
3700            .unwrap();
3701            package.build.general.cflags = vec!["-fplugin=evil.so".into()];
3702            package.build.general.cxxflags = vec!["-B.".into()];
3703            package.build.general.ldflags = vec!["-fuse-ld=/tmp/evil".into()];
3704            WorkspacePackage {
3705                package,
3706                manifest_dir: PathBuf::from("/tmp"),
3707                manifest_path: PathBuf::from("/tmp/cabin.toml"),
3708                kind,
3709                deps: Vec::new(),
3710            }
3711        }
3712
3713        let graph = PackageGraph {
3714            root_manifest_path: PathBuf::from("/tmp/cabin.toml"),
3715            root_dir: PathBuf::from("/tmp"),
3716            is_workspace_root: false,
3717            root_package: Some(0),
3718            root_settings: Default::default(),
3719            primary_packages: vec![0],
3720            default_members: vec![0],
3721            excluded_members: Vec::new(),
3722            packages: vec![
3723                dep_with_command_flags("local_dep", PackageKind::Local),
3724                dep_with_command_flags("registry_dep", PackageKind::Registry),
3725            ],
3726        };
3727
3728        let host = cabin_core::TargetPlatform::current();
3729        let resolved = resolve_per_package_build_flags(&graph, None, &host);
3730
3731        // A local package (workspace member / path dependency) is trusted:
3732        // its declared compiler and linker flags are preserved.
3733        let local = resolved.get(&0).expect("local package flags");
3734        assert_eq!(local.cflags, vec!["-fplugin=evil.so".to_owned()]);
3735        assert_eq!(local.cxxflags, vec!["-B.".to_owned()]);
3736        assert_eq!(local.ldflags, vec!["-fuse-ld=/tmp/evil".to_owned()]);
3737
3738        // A registry dependency is untrusted: its compiler and linker flags
3739        // are dropped so it cannot execute code at build time.
3740        let registry = resolved.get(&1).expect("registry package flags");
3741        assert!(registry.cflags.is_empty());
3742        assert!(registry.cxxflags.is_empty());
3743        assert!(registry.ldflags.is_empty());
3744    }
3745
3746    // -------------------------------------------------------------
3747    // cache_dir_for precedence (XDG user-global default)
3748    // -------------------------------------------------------------
3749
3750    type EnvFn = Box<dyn Fn(&str) -> Option<std::ffi::OsString>>;
3751
3752    /// Build an env-lookup closure backed by a fixed map. Mirrors
3753    /// the `env_with` helper in `cabin-config::discovery::tests`
3754    /// so the cache-dir tests look like the sibling config-dir
3755    /// tests they parallel.
3756    fn env_with(items: &[(&'static str, &str)]) -> EnvFn {
3757        let map: std::collections::HashMap<&'static str, std::ffi::OsString> = items
3758            .iter()
3759            .map(|(k, v)| (*k, std::ffi::OsString::from(*v)))
3760            .collect();
3761        Box::new(move |k| map.get(k).cloned())
3762    }
3763
3764    fn fake_manifest() -> &'static Path {
3765        // The XDG default arm does not consult manifest_path; pass
3766        // a placeholder so callers don't have to construct one.
3767        Path::new("/abs/ws/cabin.toml")
3768    }
3769
3770    /// The directory `xdg::BaseDirectories::with_prefix("cabin")`
3771    /// would return as `get_cache_home()` when `HOME` is `home` and
3772    /// `XDG_CACHE_HOME` is unset. Tests use this to inject an
3773    /// xdg-equivalent path without mutating the process environment.
3774    fn home_xdg_cache_home(home: &str) -> PathBuf {
3775        PathBuf::from(home).join(".cache").join("cabin")
3776    }
3777
3778    #[test]
3779    fn cache_dir_flag_wins_over_everything() {
3780        let env = env_with(&[
3781            ("CABIN_CACHE_DIR", "/tmp/from-env"),
3782            ("CABIN_CACHE_HOME", "/tmp/cabin-home"),
3783        ]);
3784        let xdg = PathBuf::from("/tmp/xdg/cabin");
3785        let out = cache_dir_for_with_env(
3786            fake_manifest(),
3787            Some(Path::new("/tmp/from-flag")),
3788            &env,
3789            Some(&xdg),
3790        )
3791        .unwrap();
3792        assert_eq!(out, PathBuf::from("/tmp/from-flag"));
3793    }
3794
3795    #[test]
3796    fn cabin_cache_dir_env_wins_over_xdg() {
3797        let env = env_with(&[
3798            ("CABIN_CACHE_DIR", "/tmp/from-env"),
3799            ("CABIN_CACHE_HOME", "/tmp/cabin-home"),
3800        ]);
3801        let xdg = PathBuf::from("/tmp/xdg/cabin");
3802        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3803        assert_eq!(out, PathBuf::from("/tmp/from-env"));
3804    }
3805
3806    #[test]
3807    fn cabin_cache_home_used_when_cabin_cache_dir_unset() {
3808        // CABIN_CACHE_HOME is a Cabin-specific override: it
3809        // resolves directly to its value with no `cabin`
3810        // segment appended, and it wins over the xdg-resolved
3811        // path.
3812        let env = env_with(&[("CABIN_CACHE_HOME", "/tmp/cabin-home")]);
3813        let xdg = PathBuf::from("/tmp/xdg/cabin");
3814        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3815        assert_eq!(out, PathBuf::from("/tmp/cabin-home"));
3816    }
3817
3818    #[test]
3819    fn xdg_cache_home_appends_cabin_segment() {
3820        // The injected `xdg_cache_home` represents what
3821        // `xdg::BaseDirectories::with_prefix("cabin").get_cache_home()`
3822        // returns: the `cabin` application prefix is already
3823        // applied. Cabin uses it verbatim.
3824        let env = env_with(&[]);
3825        let xdg = PathBuf::from("/tmp/xdg/cabin");
3826        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3827        assert_eq!(out, PathBuf::from("/tmp/xdg/cabin"));
3828    }
3829
3830    #[test]
3831    fn home_cache_fallback_used_when_xdg_unset() {
3832        // When `XDG_CACHE_HOME` is unset, `xdg` falls back to
3833        // `$HOME/.cache`; the injected path simulates that.
3834        let env = env_with(&[]);
3835        let xdg = home_xdg_cache_home("/tmp/home");
3836        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3837        assert_eq!(out, PathBuf::from("/tmp/home/.cache/cabin"));
3838    }
3839
3840    #[test]
3841    fn empty_cabin_cache_dir_value_falls_through() {
3842        // Empty-as-unset rule for CABIN_CACHE_DIR so a shell
3843        // that exports the variable as empty doesn't
3844        // short-circuit the XDG fallback.
3845        let env = env_with(&[("CABIN_CACHE_DIR", "")]);
3846        let xdg = home_xdg_cache_home("/tmp/home");
3847        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3848        assert_eq!(out, PathBuf::from("/tmp/home/.cache/cabin"));
3849    }
3850
3851    #[test]
3852    fn empty_cabin_cache_home_value_falls_through_to_xdg() {
3853        // Same empty-as-unset rule for CABIN_CACHE_HOME so a
3854        // shell that exports the variable as empty doesn't
3855        // short-circuit the XDG fallback.
3856        let env = env_with(&[("CABIN_CACHE_HOME", "")]);
3857        let xdg = PathBuf::from("/tmp/xdg/cabin");
3858        let out = cache_dir_for_with_env(fake_manifest(), None, &env, Some(&xdg)).unwrap();
3859        assert_eq!(out, PathBuf::from("/tmp/xdg/cabin"));
3860    }
3861
3862    #[test]
3863    fn all_envs_unset_returns_error() {
3864        // No CABIN_CACHE_HOME, no CABIN_CACHE_DIR, no xdg-resolved
3865        // path (e.g. HOME and XDG_CACHE_HOME both unset on the host).
3866        let env = env_with(&[]);
3867        let err = cache_dir_for_with_env(fake_manifest(), None, &env, None).unwrap_err();
3868        let msg = err.to_string();
3869        assert!(
3870            msg.contains("no cache directory"),
3871            "expected diagnostic mentioning 'no cache directory', got: {msg}"
3872        );
3873    }
3874}