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