use super::common::{
ChangeOutputFormat, SplitOutputFormat, SurfaceOutputFormat, TextJsonOutputFormat, UnifyOutputFormat,
};
use crate::output::OutputProtocol;
use crate::sync::ConflictStrategy;
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
use clap_complete::Shell;
use std::path::PathBuf;
const MAIN_HELP: &str = "\
Plan workspace work, check dependencies and visibility, and manage releases and compiler reuse.
Start here:
cargo rail init # Create repository policy
cargo rail plan # Decide required work
cargo rail unify --check # Check dependency changes
Docs: https://github.com/loadingalias/cargo-rail";
#[derive(Debug, Parser)]
#[command(name = "cargo-rail")]
#[command(bin_name = "cargo-rail")]
#[command(version)]
#[command(about = "Plan workspace work, check dependencies and visibility, and manage releases and compiler reuse")]
#[command(long_about = MAIN_HELP)]
#[command(propagate_version = true)]
#[command(styles = get_styles())]
pub struct RailCli {
#[arg(
long,
short,
global = true,
conflicts_with = "verbose",
help_heading = "Global Options"
)]
pub quiet: bool,
#[arg(
long,
short = 'v',
global = true,
conflicts_with = "quiet",
help_heading = "Global Options"
)]
pub verbose: bool,
#[arg(long, global = true, help_heading = "Global Options")]
pub json: bool,
#[arg(long, global = true, value_name = "PATH", help_heading = "Global Options")]
pub config: Option<PathBuf>,
#[arg(long, global = true, value_name = "PATH", help_heading = "Global Options")]
pub workspace_root: Option<PathBuf>,
#[arg(long, global = true, value_name = "PATH", hide = true)]
pub diagnostics_file: Option<PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
const PLAN_HELP: &str = "\
Without comparison flags, use changes since the default-branch merge base.
cargo rail plan --from abc --to def # Compare two commits
cargo rail plan --explain-work cargo.test # Explain one decision, even when skipped
cargo rail plan --json > plan.json # Save the exact plan
cargo rail plan --verify plan.json # Validate the saved plan and checkout without execution
cargo rail plan --cases routes.toml # Compare reviewed path cases with planner decisions
Use --verify - to read the saved plan from standard input.";
const PLAN_EVIDENCE_HELP: &str = "\
Record the build the job runs, with the same packages, targets, features, and profile,
on the commit that later changes compare against. Units that are already fresh are read
from the dep-info Cargo keeps for them, so the build can run on a warm target directory.
Evidence stays incomplete when a workspace member or a unit's inputs are not observed.
The command writes no evidence when the build fails or tracked files differ from HEAD.
Cargo diagnostics and program output go to standard error.
cargo rail plan evidence --work cargo.test -o planning-evidence.json -- test --workspace --no-run
cargo rail plan evidence --work cargo.clippy -o planning-evidence.json -- clippy --workspace --all-targets
cargo rail plan --evidence planning-evidence.json --explain-work cargo.test";
const SURFACE_HELP: &str = "\
Set `[surface] enabled = true` to include this gate in planner-selected CI.
Use `consumer_scope = \"workspace\"` only when each closed compiler crate has no
consumers outside the captured workspace. That requires reviewed evidence for
published APIs, downstream repositories, plugins, generated code, and build or
proc-macro consumers; Cargo metadata from this checkout alone is insufficient.
Surface analyzes only the selected host by default. Top-level `targets` do not
expand Surface. Set `surface.targets = \"workspace\"` to multiply every automatic
product, feature-profile, and doctest view across the host and top-level targets.
cargo rail surface --check --explain # Check without modifying source
cargo rail surface --check --json --output report.json # Keep bounded progress on stderr
cargo rail surface --fix --dry-run # Preview visibility edits
cargo rail surface --fix --backup # Apply with recovery evidence
cargo rail surface --resume MANIFEST --json # Resume partial compiler acquisition";
const UNIFY_HELP: &str = "\
Without a subcommand, preview dependency changes without modifying manifests.
Use --check to exit 1 when changes are pending.
Top-level `targets` add dependency-resolution views. They do not create a
cross-target compiler or linker execution matrix.
cargo rail unify --show-diff # Inspect manifest changes
cargo rail unify apply --backup # Apply with a backup
cargo rail unify undo --list # Find a backup to restore";
const SPLIT_HELP: &str = "\
Extract crates to standalone repositories while preserving Git history.
cargo rail split init my-crate --dry-run # Preview configuration
cargo rail split run my-crate --check # Check for pending changes (exit 1)
cargo rail split run my-crate # Apply the split";
const SYNC_HELP: &str = "\
Requires split configuration. Check mode reports pending changes without
modifying either repository. Apply is bidirectional unless a direction is selected.
cargo rail sync my-crate --check # Inspect pending work (exit 1)
cargo rail sync my-crate --to-remote # Push monorepo changes to the split repo
cargo rail sync my-crate --from-remote # Bring remote changes to a review branch
Use --resume RECEIPT after resolving a recorded conflict.";
const RELEASE_HELP: &str = "\
Publication requires explicit --publish authority. Interrupted transactions
retain a state path for `release resume`; `release status` shows recovery actions.
cargo rail release check --all --publication # Validate publication authority
cargo rail release run my-crate --bump minor # Execute a release transaction
cargo rail release run --all --publish --yes # Submit a configured hosted release";
const CHANGE_HELP: &str = "\
Record a user-facing entry and its release intent:
cargo rail change add my-crate --bump patch --message \"Fixed release notes\"
cargo rail change check --merge-base
Omit --message in a terminal to use $VISUAL or $EDITOR.
Release deletes consumed files in its commit. A plan covering only some of a
file's crates is rejected; pending intent is never partially consumed.";
const INIT_HELP: &str = "\
By default, write .config/rail.toml. Target detection requires --detect-targets;
use repeated --target flags to select supported dependency-resolution views.
These targets do not define a compiler/linker execution matrix. Each execution
lane still needs its own installed Rust target, SDK, linker, and host tooling.
cargo rail init --dry-run # Preview policy
cargo rail init --target wasm32-wasip1 # Declare a target";
const CLEAN_HELP: &str = "\
Select an artifact class explicitly. --all covers eligible workspace artifacts;
--release-journal selects one terminal transaction by ID or state path.
cargo rail clean --all --check # Preview workspace cleanup (exit 1 if pending)
cargo rail clean --release-journal ID # Delete one terminal journal";
const CACHE_HELP: &str = "\
Cache setup enrolls this workspace for ordinary Cargo commands.
Remote storage and distributed workers require explicit machine-owned authority;
repository policy cannot enable them.
`--local-only` removes remote activation from an existing profile. Omit it when
creating a fresh local profile; local reuse is already the default.
cargo rail cache setup --check # Preview enrollment (exit 1 if pending)
cargo rail cache setup # Install or repair the wrapper
cargo rail cache clean --scope local --check # Preview selected-profile cleanup
Detach preserves the profile and CAS. Uninstall removes the global wrapper while
preserving all profiles and their CAS data.";
const CONFIG_HELP: &str = "\
Bare `cargo rail config` explains configured overrides. Unknown fields and retired
configuration spellings are rejected.
`config explain NODE` expands effective child fields. Top-level `targets` are
resolution views; command-specific execution matrices remain separate policy.
cargo rail config explain --all # Inspect all effective values and sources
cargo rail config print # Emit reusable effective configuration
cargo rail config validate # Check configuration";
const COMPLETIONS_HELP: &str = "\
Add the matching command to your shell startup file:
# Bash (~/.bashrc)
eval \"$(cargo rail completions bash)\"
# Zsh (~/.zshrc)
eval \"$(cargo rail completions zsh)\"
# Fish (~/.config/fish/config.fish)
cargo rail completions fish | source
# PowerShell ($PROFILE)
cargo rail completions powershell | Out-String | Invoke-Expression";
#[derive(Debug, Subcommand)]
pub enum Commands {
Doctor {
#[command(subcommand)]
command: DoctorCommand,
},
#[command(after_long_help = CACHE_HELP)]
Cache {
#[command(subcommand)]
command: CacheCommand,
},
#[command(after_long_help = PLAN_HELP, args_conflicts_with_subcommands = true)]
Plan {
#[command(subcommand)]
command: Option<PlanCommand>,
#[arg(long)]
since: Option<String>,
#[arg(long, conflicts_with = "since", requires = "to")]
from: Option<String>,
#[arg(long, requires = "from")]
to: Option<String>,
#[arg(skip)]
json: bool,
#[arg(long)]
explain: bool,
#[arg(long, value_name = "WORK_ID")]
explain_work: Option<String>,
#[arg(long)]
all: bool,
#[arg(long, value_name = "PATH")]
evidence: Vec<PathBuf>,
#[arg(
long,
value_name = "PATH",
conflicts_with_all = ["since", "from", "to", "explain", "explain_work", "all", "evidence", "schema", "cases"]
)]
verify: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["since", "from", "to", "explain", "explain_work", "all", "evidence", "verify", "cases"])]
schema: bool,
#[arg(
long,
value_name = "PATH",
conflicts_with_all = ["since", "from", "to", "explain", "explain_work", "all", "evidence"]
)]
cases: Option<PathBuf>,
},
#[command(after_long_help = SURFACE_HELP)]
Surface {
#[arg(
long,
conflicts_with_all = ["check", "fix", "dry_run", "backup", "explain", "only", "schema"]
)]
prepare: bool,
#[arg(long, conflicts_with = "fix")]
check: bool,
#[arg(long, conflicts_with = "check")]
fix: bool,
#[arg(
long,
value_name = "MANIFEST",
conflicts_with_all = ["prepare", "fix", "dry_run", "backup", "schema"]
)]
resume: Option<PathBuf>,
#[arg(long, requires = "fix", conflicts_with = "backup")]
dry_run: bool,
#[arg(long, requires = "fix")]
backup: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: SurfaceOutputFormat,
#[arg(long, short = 'o', value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long)]
explain: bool,
#[arg(
long,
value_name = "LINT",
value_delimiter = ',',
value_parser = [
"dead-public",
"unnecessary-public",
"unnecessary-restricted-visibility",
"unnecessary-crate-visibility"
]
)]
only: Vec<String>,
#[arg(
long,
conflicts_with_all = ["prepare", "check", "fix", "resume", "dry_run", "backup", "output", "explain", "only"]
)]
schema: bool,
},
#[command(after_long_help = UNIFY_HELP)]
Unify {
#[command(subcommand)]
command: Option<UnifyCommand>,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: UnifyOutputFormat,
#[arg(long)]
report: bool,
#[arg(long, value_name = "PATH", requires = "report")]
report_path: Option<PathBuf>,
#[arg(long, short = 'o', value_name = "PATH", requires = "check")]
output: Option<PathBuf>,
#[arg(long)]
show_diff: bool,
#[arg(long)]
explain: bool,
},
#[command(after_long_help = INIT_HELP)]
Init {
#[arg(long, short, default_value = ".config/rail.toml")]
output: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long = "target", value_name = "TRIPLE", conflicts_with = "detect_targets")]
targets: Vec<String>,
#[arg(long, conflicts_with = "targets")]
detect_targets: bool,
},
#[command(after_long_help = SPLIT_HELP)]
Split {
#[command(subcommand)]
command: SplitCommand,
},
#[command(after_long_help = SYNC_HELP)]
Sync {
#[arg(conflicts_with = "all")]
crate_name: Option<String>,
#[arg(short, long)]
all: bool,
#[arg(long)]
remote: Option<String>,
#[arg(long)]
from_remote: bool,
#[arg(long)]
to_remote: bool,
#[arg(long, default_value_t, value_enum)]
strategy: ConflictStrategy,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, value_name = "PATH", conflicts_with = "check")]
plan: Option<PathBuf>,
#[arg(
long,
value_name = "RECEIPT",
conflicts_with_all = ["crate_name", "all", "remote", "from_remote", "to_remote", "check", "plan"]
)]
resume: Option<PathBuf>,
#[arg(long)]
allow_dirty: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
#[command(after_long_help = RELEASE_HELP)]
Release {
#[command(subcommand)]
command: ReleaseCommand,
},
#[command(after_long_help = CHANGE_HELP)]
Change {
#[command(subcommand)]
command: ChangeCommand,
},
#[command(after_long_help = CLEAN_HELP)]
Clean {
#[arg(long, conflicts_with_all = ["cache", "prune_backups", "all_backups", "reports", "release_journal"])]
all: bool,
#[arg(long)]
cache: bool,
#[arg(long, conflicts_with = "all_backups")]
prune_backups: bool,
#[arg(long, conflicts_with = "prune_backups")]
all_backups: bool,
#[arg(long)]
reports: bool,
#[arg(long, value_name = "ID_OR_PATH")]
release_journal: Option<String>,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
#[command(name = "config")]
#[command(after_long_help = CONFIG_HELP)]
Config {
#[command(subcommand)]
command: Option<ConfigCommand>,
},
#[command(after_long_help = COMPLETIONS_HELP)]
Completions {
#[arg(value_enum, value_name = "SHELL")]
shell: Shell,
},
}
impl Commands {
#[doc(hidden)]
pub fn requires_workspace_snapshot(&self) -> bool {
match self {
Self::Doctor { .. }
| Self::Unify { .. }
| Self::Split { .. }
| Self::Sync { .. }
| Self::Surface { schema: false, .. }
| Self::Release { .. }
| Self::Change { .. }
| Self::Config {
command: Some(ConfigCommand::Migrate { .. }),
} => true,
Self::Plan { .. } => false,
_ => false,
}
}
#[doc(hidden)]
pub fn requires_planning_source_capture(&self) -> bool {
match self {
Self::Plan { from, to, schema, .. } => !(*schema || from.is_some() && to.is_some()),
_ => false,
}
}
#[doc(hidden)]
pub fn requires_worktree_source_capture(&self) -> bool {
match self {
Self::Doctor { .. } | Self::Surface { .. } => false,
_ => false,
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum PlanCommand {
#[command(after_long_help = PLAN_EVIDENCE_HELP)]
Evidence {
#[arg(long, value_name = "WORK_ID")]
work: String,
#[arg(long, short = 'o', value_name = "PATH")]
output: PathBuf,
#[arg(last = true, required = true, value_name = "CARGO_ARGS")]
cargo_args: Vec<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum DoctorCommand {
NativeCache {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum CacheScope {
Workspace,
Local,
All,
}
impl CacheScope {
pub(crate) const fn includes_workspace(self) -> bool {
matches!(self, Self::Workspace | Self::All)
}
pub(crate) const fn includes_local(self) -> bool {
matches!(self, Self::Local | Self::All)
}
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Local => "local",
Self::All => "all",
}
}
}
#[derive(Debug, Args)]
pub struct CacheSetupArgs {
#[arg(long, value_name = "PROFILE_ID")]
pub profile: Option<String>,
#[arg(long, value_name = "PATH")]
pub local_dir: Option<PathBuf>,
#[arg(long, value_name = "SIZE", value_parser = parse_cache_size)]
pub max_size: Option<u64>,
#[arg(long, value_name = "URL", conflicts_with = "local_only")]
pub remote: Option<String>,
#[arg(long, value_name = "MODE", value_parser = ["read", "read-write"], requires = "remote")]
pub remote_mode: Option<String>,
#[arg(long = "remote-environment", value_name = "NAME", requires = "remote")]
pub remote_environment: Vec<String>,
#[arg(long, value_name = "MODE", value_parser = ["physical", "remap"])]
pub root_portability: Option<String>,
#[arg(long, conflicts_with_all = ["remote", "remote_mode", "remote_environment"])]
pub local_only: bool,
#[arg(long, conflicts_with = "distributed_endpoint")]
pub distributed_local: bool,
#[arg(
long,
value_name = "IP:PORT",
conflicts_with = "distributed_local",
requires_all = [
"distributed_server_name",
"distributed_capability",
"distributed_authority",
"distributed_client_certificate",
"distributed_client_private_key"
]
)]
pub distributed_endpoint: Option<String>,
#[arg(long, value_name = "NAME", requires = "distributed_endpoint")]
pub distributed_server_name: Option<String>,
#[arg(long, value_name = "IDENTITY", requires = "distributed_endpoint")]
pub distributed_capability: Option<String>,
#[arg(long, value_name = "PATH", requires = "distributed_endpoint")]
pub distributed_authority: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "distributed_endpoint")]
pub distributed_client_certificate: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "distributed_endpoint")]
pub distributed_client_private_key: Option<PathBuf>,
#[arg(
long,
value_name = "MODE",
value_parser = ["automatic", "qualification"],
conflicts_with = "distributed_local"
)]
pub distributed_policy: Option<String>,
#[arg(long, short = 'c')]
pub check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
pub format: TextJsonOutputFormat,
}
#[derive(Debug, Subcommand)]
pub enum CacheCommand {
Setup(Box<CacheSetupArgs>),
Ready {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Report {
#[arg(long, conflicts_with = "finish", required_unless_present = "finish")]
start: Option<PathBuf>,
#[arg(long, conflicts_with = "start", required_unless_present = "start")]
finish: Option<PathBuf>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Normalize {
#[arg(value_name = "URL")]
url: String,
#[arg(long, value_name = "MODE", value_parser = ["read", "read-write"])]
mode: Option<String>,
#[arg(long = "environment", value_name = "NAME")]
environment: Vec<String>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Probe {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Status {
#[arg(long, value_enum, default_value = "all")]
scope: CacheScope,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Profiles {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Detach {
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
DropProfile {
#[arg(long)]
profile: String,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Recover {
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Clean {
#[arg(long, value_enum)]
scope: CacheScope,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Uninstall {
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
}
fn parse_cache_size(value: &str) -> Result<u64, String> {
let split = value
.find(|character: char| !character.is_ascii_digit())
.ok_or_else(|| "cache size requires an explicit unit: B, KiB, MiB, GiB, or TiB".to_string())?;
let (number, unit) = value.split_at(split);
if number.is_empty() || number.starts_with('0') && number != "0" {
return Err("cache size must be a canonical positive integer".to_string());
}
let number = number
.parse::<u64>()
.map_err(|_| "cache size integer is invalid or overflowing".to_string())?;
let multiplier = match unit {
"B" => 1_u64,
"KiB" => 1024,
"MiB" => 1024 * 1024,
"GiB" => 1024 * 1024 * 1024,
"TiB" => 1024 * 1024 * 1024 * 1024,
_ => return Err("cache size unit must be B, KiB, MiB, GiB, or TiB".to_string()),
};
let bytes = number
.checked_mul(multiplier)
.filter(|bytes| *bytes > 0)
.ok_or_else(|| "cache size must be positive and fit in u64".to_string())?;
if bytes > usize::MAX as u64 {
return Err("cache size exceeds this platform's supported limit".to_string());
}
Ok(bytes)
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Locate {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Print {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Validate {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
#[arg(long, conflicts_with = "no_strict")]
strict: bool,
#[arg(long, conflicts_with = "strict")]
no_strict: bool,
},
Explain {
#[arg(value_name = "FIELD", conflicts_with = "all")]
fields: Vec<String>,
#[arg(long, conflicts_with = "fields")]
all: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Migrate {
#[command(subcommand)]
command: Option<ConfigMigrateCommand>,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
}
#[derive(Debug, Subcommand)]
pub enum ConfigMigrateCommand {
Apply {
#[arg(long)]
plan: Option<PathBuf>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
}
#[derive(Debug, Subcommand)]
pub enum UnifyCommand {
Apply {
#[arg(long, value_name = "PATH")]
plan: Option<PathBuf>,
#[arg(long)]
backup: bool,
#[arg(long)]
report: bool,
#[arg(long, value_name = "PATH", requires = "report")]
report_path: Option<PathBuf>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: UnifyOutputFormat,
},
Doctor {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: UnifyOutputFormat,
},
Undo {
#[arg(long)]
list: bool,
#[arg(long = "backup-id")]
backup_id: Option<String>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
}
#[derive(Debug, Subcommand)]
pub enum SplitCommand {
Init {
#[arg(value_name = "CRATE")]
crate_names: Vec<String>,
#[arg(long)]
dry_run: bool,
},
Run {
#[arg(conflicts_with = "all", value_name = "CRATE")]
crate_name: Option<String>,
#[arg(short, long)]
all: bool,
#[arg(long)]
remote: Option<String>,
#[arg(long, short = 'c')]
check: bool,
#[arg(long, value_name = "PATH", conflicts_with = "check")]
plan: Option<PathBuf>,
#[arg(long)]
allow_dirty: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: SplitOutputFormat,
},
}
#[derive(Debug, Subcommand)]
pub enum ReleaseCommand {
#[command(hide = true)]
Record {
#[command(subcommand)]
command: ReleaseRecordCommand,
},
Init {
#[arg(value_name = "CRATE")]
crate_names: Vec<String>,
#[arg(long)]
dry_run: bool,
},
Run {
#[arg(conflicts_with = "all", value_name = "CRATE")]
crate_names: Vec<String>,
#[arg(short, long)]
all: bool,
#[arg(long, default_value = "auto")]
bump: String,
#[arg(long, value_name = "PATH")]
plan: Option<PathBuf>,
#[arg(long)]
publish: bool,
#[arg(long)]
skip_tag: bool,
#[arg(long)]
pr: bool,
#[arg(long, hide = true, conflicts_with = "pr")]
prepare: bool,
#[arg(long, hide = true)]
retain_remote: bool,
#[arg(long, conflicts_with = "executor")]
local: bool,
#[arg(long, hide = true)]
executor: bool,
#[arg(long)]
include_dependents: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
allow_non_default_branch: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Check {
#[arg(conflicts_with = "all", value_name = "CRATE")]
crate_names: Vec<String>,
#[arg(short, long)]
all: bool,
#[arg(long, default_value = "auto")]
bump: String,
#[arg(long)]
publication: bool,
#[arg(long, short = 'e')]
extended: bool,
#[arg(long)]
skip_tag: bool,
#[arg(long)]
include_dependents: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Resume {
#[arg(value_name = "TRANSACTION")]
transaction: Option<String>,
#[arg(long, hide = true)]
executor: bool,
},
Status {
#[arg(value_name = "STATE")]
state: Option<PathBuf>,
#[arg(long)]
history: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: TextJsonOutputFormat,
},
Abort {
#[arg(value_name = "TRANSACTION")]
transaction: Option<String>,
#[arg(long)]
retain_preparation: bool,
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum ReleaseRecordCommand {
Inspect {
transaction: String,
},
Store {
transaction: String,
},
Fetch {
transaction: Option<String>,
},
Export {
transaction: String,
directory: PathBuf,
},
Import {
directory: PathBuf,
#[arg(long)]
intent: String,
#[arg(long)]
source: String,
},
}
#[derive(Debug, Subcommand)]
pub enum ChangeCommand {
Add {
#[arg(value_name = "CRATE")]
crate_names: Vec<String>,
#[arg(long)]
bump: String,
#[arg(long, short = 'm')]
message: Option<String>,
#[arg(long, value_name = "SLUG")]
name: Option<String>,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: ChangeOutputFormat,
},
Status {
#[arg(long, short = 'f', default_value_t, value_enum)]
format: ChangeOutputFormat,
},
Check {
#[arg(long, conflicts_with_all = ["merge_base", "all"], value_name = "REF")]
since: Option<String>,
#[arg(long, conflicts_with_all = ["since", "all"])]
merge_base: bool,
#[arg(long, conflicts_with_all = ["since", "merge_base"])]
all: bool,
#[arg(long, short = 'f', default_value_t, value_enum)]
format: ChangeOutputFormat,
},
}
fn get_styles() -> clap::builder::Styles {
clap::builder::Styles::styled()
}
fn text_json_protocol(json: bool) -> OutputProtocol {
if json {
OutputProtocol::Json
} else {
OutputProtocol::Text
}
}
impl Commands {
#[doc(hidden)]
pub fn output_protocol(&self) -> OutputProtocol {
match self {
Commands::Doctor {
command: DoctorCommand::NativeCache { format },
} => text_json_protocol(format.is_json()),
Commands::Cache { command } => match command {
CacheCommand::Setup(setup) => text_json_protocol(setup.format.is_json()),
CacheCommand::Ready { format }
| CacheCommand::Normalize { format, .. }
| CacheCommand::Probe { format }
| CacheCommand::Status { format, .. }
| CacheCommand::Report { format, .. }
| CacheCommand::Recover { format, .. }
| CacheCommand::Clean { format, .. }
| CacheCommand::Profiles { format }
| CacheCommand::Detach { format, .. }
| CacheCommand::DropProfile { format, .. }
| CacheCommand::Uninstall { format, .. } => text_json_protocol(format.is_json()),
},
Commands::Sync { format, .. } | Commands::Clean { format, .. } => text_json_protocol(format.is_json()),
Commands::Plan { schema: true, .. } => OutputProtocol::Raw,
Commands::Plan { json, .. } => text_json_protocol(*json),
Commands::Surface { schema: true, .. } => OutputProtocol::Raw,
Commands::Surface { format, .. } => match format {
SurfaceOutputFormat::Text => OutputProtocol::Text,
SurfaceOutputFormat::Json => OutputProtocol::Json,
SurfaceOutputFormat::GitHub => OutputProtocol::Raw,
},
Commands::Unify {
command: Some(UnifyCommand::Doctor { format } | UnifyCommand::Apply { format, .. }),
..
} => text_json_protocol(format.is_json()),
Commands::Unify {
command: Some(UnifyCommand::Undo { format, .. }),
..
} => text_json_protocol(format.is_json()),
Commands::Unify { format, .. } => text_json_protocol(format.is_json()),
Commands::Split { command } => match command {
SplitCommand::Init { .. } => OutputProtocol::Text,
SplitCommand::Run { format, .. } => match format {
SplitOutputFormat::Text => OutputProtocol::Text,
SplitOutputFormat::Json => OutputProtocol::Json,
SplitOutputFormat::NamesOnly | SplitOutputFormat::JsonLines => OutputProtocol::Raw,
},
},
Commands::Release { command } => match command {
ReleaseCommand::Record { .. } => OutputProtocol::Json,
ReleaseCommand::Init { .. } | ReleaseCommand::Resume { .. } | ReleaseCommand::Abort { .. } => {
OutputProtocol::Text
}
ReleaseCommand::Status { format, .. } => text_json_protocol(format.is_json()),
ReleaseCommand::Run { format, .. } | ReleaseCommand::Check { format, .. } => {
text_json_protocol(format.is_json())
}
},
Commands::Change { command } => match command {
ChangeCommand::Add { format, .. }
| ChangeCommand::Status { format }
| ChangeCommand::Check { format, .. } => match format {
ChangeOutputFormat::Text => OutputProtocol::Text,
ChangeOutputFormat::Json => OutputProtocol::Json,
ChangeOutputFormat::NamesOnly => OutputProtocol::Raw,
},
},
Commands::Config {
command:
Some(
ConfigCommand::Locate { format }
| ConfigCommand::Print { format }
| ConfigCommand::Explain { format, .. }
| ConfigCommand::Validate { format, .. }
| ConfigCommand::Migrate {
command: Some(ConfigMigrateCommand::Apply { format, .. }),
..
}
| ConfigCommand::Migrate {
command: None, format, ..
},
),
} => text_json_protocol(format.is_json()),
Commands::Completions { .. } => OutputProtocol::Raw,
_ => OutputProtocol::Text,
}
}
#[doc(hidden)]
pub fn retains_machine_progress(&self) -> bool {
matches!(
self,
Commands::Surface {
output: Some(_),
schema: false,
..
} | Commands::Unify { .. }
)
}
pub fn is_json_format(&self) -> bool {
self.output_protocol() == OutputProtocol::Json
}
pub fn apply_json_override(&mut self) -> Result<(), clap::Error> {
let unsupported = match self {
Commands::Split {
command: SplitCommand::Init { .. },
} => Some("split init"),
Commands::Release {
command: ReleaseCommand::Init { .. },
} => Some("release init"),
Commands::Release {
command: ReleaseCommand::Resume { .. },
} => Some("release resume"),
Commands::Release {
command: ReleaseCommand::Abort { .. },
} => Some("release abort"),
Commands::Completions { .. } => Some("completions"),
Commands::Plan { cases: Some(_), .. } => Some("plan --cases"),
_ => None,
};
if let Some(command) = unsupported {
return Err(RailCli::command().error(
ErrorKind::ArgumentConflict,
format!("--json is not supported by 'cargo rail {command}'"),
));
}
let incompatible_stream = match self {
Commands::Split {
command: SplitCommand::Run { format, .. },
} => match format {
SplitOutputFormat::NamesOnly => Some("names-only"),
SplitOutputFormat::JsonLines => Some("jsonl"),
SplitOutputFormat::Text | SplitOutputFormat::Json => None,
},
Commands::Surface { format, .. } => match format {
SurfaceOutputFormat::GitHub => Some("github"),
SurfaceOutputFormat::Text | SurfaceOutputFormat::Json => None,
},
Commands::Change { command } => match command {
ChangeCommand::Add { format, .. }
| ChangeCommand::Status { format }
| ChangeCommand::Check { format, .. } => match format {
ChangeOutputFormat::NamesOnly => Some("names-only"),
ChangeOutputFormat::Text | ChangeOutputFormat::Json => None,
},
},
_ => None,
};
if let Some(format) = incompatible_stream {
return Err(RailCli::command().error(
ErrorKind::ArgumentConflict,
format!("--json conflicts with the distinct '--format {format}' stream protocol"),
));
}
match self {
Commands::Doctor {
command: DoctorCommand::NativeCache { format },
} => *format = TextJsonOutputFormat::Json,
Commands::Cache { command } => match command {
CacheCommand::Setup(setup) => setup.format = TextJsonOutputFormat::Json,
CacheCommand::Ready { format }
| CacheCommand::Normalize { format, .. }
| CacheCommand::Probe { format }
| CacheCommand::Status { format, .. }
| CacheCommand::Report { format, .. }
| CacheCommand::Recover { format, .. }
| CacheCommand::Clean { format, .. }
| CacheCommand::Profiles { format }
| CacheCommand::Detach { format, .. }
| CacheCommand::DropProfile { format, .. }
| CacheCommand::Uninstall { format, .. } => {
*format = TextJsonOutputFormat::Json;
}
},
Commands::Sync { format, .. } | Commands::Clean { format, .. } => *format = TextJsonOutputFormat::Json,
Commands::Plan { json, .. } => *json = true,
Commands::Surface { format, .. } => *format = SurfaceOutputFormat::Json,
Commands::Unify {
command: Some(UnifyCommand::Doctor { format } | UnifyCommand::Apply { format, .. }),
..
} => *format = UnifyOutputFormat::Json,
Commands::Unify {
command: Some(UnifyCommand::Undo { format, .. }),
..
} => *format = TextJsonOutputFormat::Json,
Commands::Unify { format, .. } => *format = UnifyOutputFormat::Json,
Commands::Split {
command: SplitCommand::Run { format, .. },
} => *format = SplitOutputFormat::Json,
Commands::Split { .. } => {}
Commands::Release {
command:
ReleaseCommand::Run { format, .. }
| ReleaseCommand::Check { format, .. }
| ReleaseCommand::Status { format, .. },
} => *format = TextJsonOutputFormat::Json,
Commands::Release { .. } => {}
Commands::Change {
command:
ChangeCommand::Add { format, .. }
| ChangeCommand::Status { format }
| ChangeCommand::Check { format, .. },
} => *format = ChangeOutputFormat::Json,
Commands::Config { command } => match command {
Some(
ConfigCommand::Locate { format }
| ConfigCommand::Print { format }
| ConfigCommand::Explain { format, .. }
| ConfigCommand::Validate { format, .. },
) => *format = TextJsonOutputFormat::Json,
Some(ConfigCommand::Migrate { command, format, .. }) => {
*format = TextJsonOutputFormat::Json;
if let Some(ConfigMigrateCommand::Apply { format, .. }) = command {
*format = TextJsonOutputFormat::Json;
}
}
None => {
*command = Some(ConfigCommand::Explain {
fields: Vec::new(),
all: false,
format: TextJsonOutputFormat::Json,
})
}
},
_ => {}
}
Ok(())
}
}
pub fn generate_completions(shell: Shell) {
let mut cmd = RailCli::command();
clap_complete::generate(shell, &mut cmd, "cargo-rail", &mut std::io::stdout());
}
#[cfg(test)]
mod tests {
use super::{RailCli, parse_cache_size};
use crate::output::OutputProtocol;
use clap::{CommandFactory as _, Parser as _};
#[test]
fn cache_setup_help_states_the_pending_preview_exit_code() {
let mut command = RailCli::command();
let cache = command.find_subcommand_mut("cache").expect("cache command");
let setup = cache.find_subcommand_mut("setup").expect("cache setup command");
let help = setup.render_long_help().to_string();
assert!(
help.contains("exits 1 when changes are pending"),
"cache setup help omitted its preview exit contract:\n{help}"
);
}
#[test]
fn surface_formats_select_distinct_output_protocols() {
for (format, expected) in [
("text", OutputProtocol::Text),
("json", OutputProtocol::Json),
("github", OutputProtocol::Raw),
] {
let cli = RailCli::try_parse_from(["cargo-rail", "surface", "--format", format])
.expect("surface format must parse");
assert_eq!(
cli.command.output_protocol(),
expected,
"surface --format {format} selected the wrong transport"
);
assert_eq!(
cli.command.is_json_format(),
expected == OutputProtocol::Json,
"surface --format {format} selected the wrong JSON compatibility mode"
);
}
}
#[test]
fn machine_progress_is_kept_only_for_redirected_surface_and_unify() {
let redirected =
RailCli::try_parse_from(["cargo-rail", "surface", "--format", "json", "--output", "surface.json"])
.expect("redirected Surface JSON must parse");
assert!(redirected.command.retains_machine_progress());
let stdout = RailCli::try_parse_from(["cargo-rail", "surface", "--format", "json"])
.expect("stdout Surface JSON must parse");
assert!(!stdout.command.retains_machine_progress());
for arguments in [
&["cargo-rail", "unify", "--check", "--format", "json"][..],
&["cargo-rail", "unify", "apply", "--format", "json"][..],
] {
let unify = RailCli::try_parse_from(arguments).expect("Unify JSON must parse");
assert!(unify.command.retains_machine_progress(), "{arguments:?}");
}
let config = RailCli::try_parse_from(["cargo-rail", "config", "print", "--format", "json"])
.expect("config JSON must parse");
assert!(!config.command.retains_machine_progress());
}
#[test]
fn command_owned_streams_select_the_raw_output_protocol() {
let cases: &[&[&str]] = &[
&["cargo-rail", "plan", "--schema"],
&["cargo-rail", "surface", "--schema"],
&["cargo-rail", "surface", "--format", "github"],
&["cargo-rail", "split", "run", "crate-a", "--format", "names-only"],
&["cargo-rail", "split", "run", "crate-a", "--format", "jsonl"],
&["cargo-rail", "change", "status", "--format", "names-only"],
&["cargo-rail", "completions", "bash"],
];
for arguments in cases {
let cli = RailCli::try_parse_from(arguments.iter().copied()).expect("raw stream command must parse");
assert_eq!(
cli.command.output_protocol(),
OutputProtocol::Raw,
"{} must retain its command-owned raw stream",
arguments[1..].join(" ")
);
assert!(
!cli.command.is_json_format(),
"{} must not select JSON error rendering",
arguments[1..].join(" ")
);
}
}
#[test]
fn transparent_cache_size_grammar_is_exact_and_bounded() {
assert_eq!(parse_cache_size("1B"), Ok(1));
assert_eq!(parse_cache_size("10GiB"), Ok(10 * 1024 * 1024 * 1024));
for invalid in [
"0B",
"01GiB",
"10",
"1GB",
"1.5GiB",
"-1GiB",
"1gib",
"18446744073709551615TiB",
] {
assert!(parse_cache_size(invalid).is_err(), "accepted invalid size {invalid}");
}
}
}