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
Use --verify - to read the saved plan from standard input.";
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
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)]
Plan {
#[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: Option<PathBuf>,
#[arg(
long,
value_name = "PATH",
conflicts_with_all = ["since", "from", "to", "explain", "explain_work", "all", "evidence", "schema"]
)]
verify: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["since", "from", "to", "explain", "explain_work", "all", "evidence", "verify"])]
schema: bool,
},
#[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 { .. } => 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(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,
},
}
#[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', requires = "publication")]
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, .. },
),
} => text_json_protocol(format.is_json()),
Commands::Completions { .. } => OutputProtocol::Raw,
_ => OutputProtocol::Text,
}
}
#[doc(hidden)]
pub fn retains_redirected_progress(&self) -> bool {
matches!(
self,
Commands::Surface {
output: Some(_),
schema: false,
..
}
)
}
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"),
_ => 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,
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::Parser as _;
#[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 surface_redirected_output_is_the_only_redirected_progress_contract() {
let redirected =
RailCli::try_parse_from(["cargo-rail", "surface", "--format", "json", "--output", "surface.json"])
.expect("redirected Surface JSON must parse");
assert!(redirected.command.retains_redirected_progress());
let stdout = RailCli::try_parse_from(["cargo-rail", "surface", "--format", "json"])
.expect("stdout Surface JSON must parse");
assert!(!stdout.command.retains_redirected_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}");
}
}
}