use anyhow::{Context, bail};
use clap::Args;
use indicatif::{ProgressBar, ProgressStyle};
use inquire::{Confirm, Select};
use owo_colors::OwoColorize;
use tracing::{debug, instrument};
use scrat_core::config::Config;
use scrat_core::ship::{self, PhaseOutcome, ShipEvent, ShipOptions, ShipPlan};
#[derive(Args, Debug, Default)]
pub struct ShipArgs {
#[arg(long, value_name = "VERSION")]
pub version: Option<String>,
#[arg(long)]
pub no_changelog: bool,
#[arg(long)]
pub no_publish: bool,
#[arg(long)]
pub no_push: bool,
#[arg(long)]
pub no_release: bool,
#[arg(long)]
pub no_deps: bool,
#[arg(long)]
pub no_stats: bool,
#[arg(long)]
pub no_notes: bool,
#[arg(long)]
pub no_test: bool,
#[arg(long)]
pub no_tag: bool,
#[arg(long)]
pub no_git: bool,
#[arg(long)]
pub no_fetch: bool,
#[arg(long, conflicts_with = "no_draft")]
pub draft: bool,
#[arg(long, conflicts_with = "draft")]
pub no_draft: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, short = 'y')]
pub yes: bool,
}
#[instrument(name = "cmd_ship", skip_all)]
pub fn cmd_ship(
args: ShipArgs,
global_json: bool,
config: &Config,
cwd: &camino::Utf8Path,
) -> anyhow::Result<()> {
debug!(
json_output = global_json,
dry_run = args.dry_run,
"executing ship command"
);
let skip_confirm = args.yes;
let draft_override = if args.draft {
Some(true)
} else if args.no_draft {
Some(false)
} else {
None
};
let ship_cfg = config.ship.as_ref();
let options = ShipOptions {
explicit_version: args.version,
no_changelog: args.no_changelog || ship_cfg.and_then(|s| s.no_changelog).unwrap_or(false),
no_publish: args.no_publish || ship_cfg.and_then(|s| s.no_publish).unwrap_or(false),
no_push: args.no_push || ship_cfg.and_then(|s| s.no_push).unwrap_or(false),
no_release: args.no_release || ship_cfg.and_then(|s| s.no_release).unwrap_or(false),
no_deps: args.no_deps || ship_cfg.and_then(|s| s.no_deps).unwrap_or(false),
no_stats: args.no_stats || ship_cfg.and_then(|s| s.no_stats).unwrap_or(false),
no_notes: args.no_notes || ship_cfg.and_then(|s| s.no_notes).unwrap_or(false),
dry_run: args.dry_run,
no_test: args.no_test || ship_cfg.and_then(|s| s.no_test).unwrap_or(false),
no_tag: args.no_tag || ship_cfg.and_then(|s| s.no_tag).unwrap_or(false),
no_git: args.no_git || ship_cfg.and_then(|s| s.no_git).unwrap_or(false),
no_fetch: args.no_fetch || ship_cfg.and_then(|s| s.no_fetch).unwrap_or(false),
draft_override,
};
let is_dry = options.dry_run;
let mut plan = ship::plan_ship(cwd, config, options).context("ship planning failed")?;
if let ShipPlan::NeedsEcosystemSelection(selection) = plan {
let ecosystem =
super::prompt_ecosystem_selection().context("ecosystem selection failed")?;
plan = ship::resolve_ecosystem_selection(selection, ecosystem)
.context("re-planning with selected ecosystem failed")?;
}
let ready = match plan {
ShipPlan::Ready(r) => r,
ShipPlan::NeedsInteraction(interactive) => {
let chosen = prompt_interactive_version(&interactive)
.context("interactive version selection failed")?;
ship::resolve_ship_interaction(interactive, chosen)
}
ShipPlan::NeedsEcosystemSelection(_) => {
bail!("ecosystem selection returned NeedsEcosystemSelection again — this is a bug");
}
};
let validation_failures = ready.validate();
if !validation_failures.is_empty() {
if global_json {
let json = serde_json::to_string_pretty(&validation_failures)?;
println!("{json}");
} else {
for check in &validation_failures {
let hint = check
.skip_flag
.as_ref()
.map(|f| format!(" (skip with {f})"))
.unwrap_or_default();
eprintln!(
" {} {}: {}{}",
"✗".red(),
check.name.bold(),
check.message,
hint.dimmed(),
);
}
}
bail!("validation failed — fix issues above before releasing");
}
if !global_json {
if is_dry {
println!("\n{}", "DRY RUN — no changes will be made".yellow().bold());
}
println!(
"\n{}: {} → {}",
"Ship".bold(),
ready.bump.previous.to_string().dimmed(),
ready.bump.next.to_string().green().bold(),
);
println!(
"{}: {} | {}: {}",
"Strategy".dimmed(),
ready.bump.strategy,
"Ecosystem".dimmed(),
ready.detection.ecosystem,
);
println!();
}
if !is_dry && !global_json {
let config_confirm = config.ship.as_ref().and_then(|s| s.confirm).unwrap_or(true);
if config_confirm && !skip_confirm {
print_phase_summary(&ready.options, config);
let confirmed = Confirm::new("Proceed with release?")
.with_default(true)
.prompt()
.context("confirmation prompt failed")?;
if !confirmed {
println!("{}", "Ship cancelled.".yellow());
return Ok(());
}
println!();
}
}
let outcome = ready
.execute(cwd, |event| {
if !global_json {
handle_event(event, is_dry);
}
})
.context("ship failed")?;
if global_json {
println!("{}", serde_json::to_string_pretty(&outcome)?);
} else {
println!();
if is_dry {
println!(
"{} Dry run complete — {} phases previewed, {} hooks would run",
"✓".green(),
outcome.phases.len(),
outcome.hooks_run,
);
} else {
print_shipit_squirrel();
println!(
"{} Shipped {} ({} phases, {} hooks)",
"✓".green().bold(),
outcome.tag.green().bold(),
outcome.phases.len(),
outcome.hooks_run,
);
}
}
Ok(())
}
fn handle_event(event: ShipEvent, is_dry: bool) {
match event {
ShipEvent::PhaseStarted(phase) => {
let spinner = ProgressBar::new_spinner();
#[allow(clippy::literal_string_with_formatting_args)]
let spinner_style = ProgressStyle::with_template(" {spinner:.cyan} {msg}")
.expect("indicatif must accept literal template ' {spinner:.cyan} {msg}'")
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
spinner.set_style(spinner_style);
spinner.set_message(format!("{phase}..."));
spinner.finish_and_clear();
}
ShipEvent::PhaseCompleted(phase, outcome) => match outcome {
PhaseOutcome::Success { message } => {
let prefix = if is_dry { "○" } else { "✓" };
println!(
" {} {} {}",
prefix.green(),
format!("{phase}").bold(),
message.dimmed(),
);
}
PhaseOutcome::Skipped { reason } => {
println!(
" {} {} {}",
"–".yellow(),
format!("{phase}").bold(),
format!("skipped: {reason}").dimmed(),
);
}
},
ShipEvent::HooksStarted {
phase,
count,
commands,
will_execute,
} => {
if will_execute {
debug!(%phase, count, "running hooks");
} else {
for cmd in &commands {
println!(" {} {}", "hook →".dimmed(), cmd.cyan(),);
}
}
}
ShipEvent::HooksCompleted { phase, count } => {
debug!(%phase, count, "hooks completed");
}
}
}
fn prompt_interactive_version(
plan: &ship::InteractiveShip,
) -> anyhow::Result<scrat_core::semver::Version> {
let ctx = &plan.bump.context;
if ctx.recent_commits.is_empty() {
println!("{}", "No commits since last tag.".yellow());
} else {
println!("{}", "Recent commits:".bold().underline());
let display_count = ctx.recent_commits.len().min(10);
for (hash, subject) in ctx.recent_commits.iter().take(display_count) {
println!(" {} {}", hash.dimmed(), subject);
}
let remaining = ctx.recent_commits.len().saturating_sub(display_count);
if remaining > 0 {
println!(" {} ... and {remaining} more", "".dimmed());
}
println!();
}
if let Some(ref v) = ctx.current_version {
println!("{}: {}", "Current version".dimmed(), v);
} else {
println!(
"{}: {}",
"Current version".dimmed(),
"none (first release)".yellow()
);
}
let options: Vec<String> = ctx
.candidates
.iter()
.map(|c| format!("{} ({})", c.version, c.level))
.collect();
if options.is_empty() {
bail!("no version candidates available");
}
let selection = Select::new("Select version:", options)
.prompt()
.context("version selection cancelled")?;
let version_str = selection
.split_once(' ')
.map(|(v, _)| v)
.unwrap_or(&selection);
scrat_core::version::parse_version(version_str).context("failed to parse selected version")
}
fn print_phase_summary(options: &ShipOptions, config: &Config) {
let phases: &[(&str, bool)] = &[
("test", !options.no_test),
("bump", true),
("publish", !options.no_publish),
("git", !options.no_git),
("release", !options.no_release),
];
let active: Vec<&str> = phases
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| *n)
.collect();
let skipped: Vec<&str> = phases
.iter()
.filter(|(_, on)| !*on)
.map(|(n, _)| *n)
.collect();
print!(" {}: {}", "Phases".dimmed(), active.join(", ").bold());
if !skipped.is_empty() {
print!(" {}", format!("(skip: {})", skipped.join(", ")).dimmed());
}
println!();
let hook_count = count_hooks(config);
if hook_count > 0 {
println!(
" {}: {} hook command{}",
"Hooks".dimmed(),
hook_count,
if hook_count == 1 { "" } else { "s" }
);
}
println!();
}
fn count_hooks(config: &Config) -> usize {
let Some(hooks) = config.hooks.as_ref() else {
return 0;
};
[
hooks.pre_ship.as_ref(),
hooks.post_ship.as_ref(),
hooks.pre_test.as_ref(),
hooks.post_test.as_ref(),
hooks.pre_bump.as_ref(),
hooks.post_bump.as_ref(),
hooks.pre_publish.as_ref(),
hooks.post_publish.as_ref(),
hooks.pre_tag.as_ref(),
hooks.post_tag.as_ref(),
hooks.pre_release.as_ref(),
hooks.post_release.as_ref(),
]
.iter()
.filter_map(|h| h.as_ref())
.map(|cmds| cmds.len())
.sum()
}
fn print_shipit_squirrel() {
println!();
if !crate::terminal::render_shipit() {
println!(" {}", ":shipit:".bold());
}
println!(" {}", "SHIP IT!".bold());
println!();
}