use crate::commands::init::gitignore;
use crate::migration::{self, MigrationCheckResult, MigrationContext};
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use colored::Colorize;
#[derive(Args)]
#[command(
about = "Check and apply migrations for config and project files",
after_long_help = "Examples:
ralph migrate # Check for pending migrations
ralph migrate --check # Exit with error code if migrations pending (CI)
ralph migrate --apply # Apply all pending migrations
ralph migrate --apply # Also repairs legacy v1 config files that 0.3 cannot load directly
ralph migrate --list # List all migrations and their status
ralph migrate status # Show detailed migration status
"
)]
pub struct MigrateArgs {
#[arg(long, conflicts_with = "apply")]
pub check: bool,
#[arg(long, conflicts_with = "check")]
pub apply: bool,
#[arg(long, conflicts_with_all = ["check", "apply"])]
pub list: bool,
#[arg(long, requires = "apply")]
pub force: bool,
#[command(subcommand)]
pub command: Option<MigrateCommand>,
}
#[derive(Subcommand)]
pub enum MigrateCommand {
Status,
}
pub fn handle_migrate(args: MigrateArgs) -> Result<()> {
if let Some(MigrateCommand::Status) = args.command {
return show_migration_status();
}
if args.list {
return list_migrations();
}
if args.apply {
return apply_migrations(args.force);
}
if args.check {
return check_migrations();
}
show_pending_migrations()
}
fn check_migrations() -> Result<()> {
let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
match migration::check_migrations(&ctx)? {
MigrationCheckResult::Current => {
println!("{}", "✓ No pending migrations".green());
Ok(())
}
MigrationCheckResult::Pending(migrations) => {
println!(
"{}",
format!("✗ {} pending migration(s) found", migrations.len()).red()
);
for migration in &migrations {
println!(" - {}: {}", migration.id.yellow(), migration.description);
}
println!("\nRun {} to apply them.", "ralph migrate --apply".cyan());
std::process::exit(1);
}
}
}
fn show_pending_migrations() -> Result<()> {
let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
match migration::check_migrations(&ctx)? {
MigrationCheckResult::Current => {
println!("{}", "✓ No pending migrations".green());
println!("\nYour project is up to date!");
}
MigrationCheckResult::Pending(migrations) => {
println!(
"{}",
format!("Found {} pending migration(s):", migrations.len()).yellow()
);
println!();
for migration in &migrations {
println!(" {} {}", "•".cyan(), migration.id.bold());
println!(" {}", migration.description);
println!();
}
println!("Run {} to apply them.", "ralph migrate --apply".cyan());
}
}
Ok(())
}
fn list_migrations() -> Result<()> {
let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
let migrations = migration::list_migrations(&ctx);
if migrations.is_empty() {
println!("No migrations defined.");
return Ok(());
}
println!("{}", "Available migrations:".bold());
println!();
for status in &migrations {
let status_icon = if status.applied {
"✓".green()
} else if status.applicable {
"○".yellow()
} else {
"-".dimmed()
};
let status_text = if status.applied {
"applied".green()
} else if status.applicable {
"pending".yellow()
} else {
"not applicable".dimmed()
};
println!(
" {} {} ({})",
status_icon,
status.migration.id.bold(),
status_text
);
println!(" {}", status.migration.description);
println!();
}
let applied_count = migrations.iter().filter(|m| m.applied).count();
let pending_count = migrations
.iter()
.filter(|m| !m.applied && m.applicable)
.count();
println!(
"{} applied, {} pending, {} not applicable",
applied_count.to_string().green(),
pending_count.to_string().yellow(),
(migrations.len() - applied_count - pending_count)
.to_string()
.dimmed()
);
Ok(())
}
fn apply_migrations(force: bool) -> Result<()> {
let mut ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
let pending = match migration::check_migrations(&ctx)? {
MigrationCheckResult::Current => {
println!("{}", "✓ No pending migrations to apply".green());
return Ok(());
}
MigrationCheckResult::Pending(migrations) => migrations,
};
if force {
println!(
"{}",
"⚠ Force mode enabled: Will re-apply already applied migrations".yellow()
);
}
println!(
"{}",
format!("Will apply {} migration(s):", pending.len()).cyan()
);
println!();
for migration in &pending {
println!(" - {}: {}", migration.id.yellow(), migration.description);
}
println!();
if !force {
print!("{} ", "Apply these migrations? [y/N]:".bold());
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
println!();
let applied = migration::apply_all_migrations(&mut ctx).context("apply migrations")?;
if applied.is_empty() {
println!("{}", "No migrations were applied".yellow());
} else {
println!(
"{}",
format!("✓ Successfully applied {} migration(s)", applied.len()).green()
);
for id in applied {
println!(" {} {}", "✓".green(), id);
}
}
match gitignore::migrate_json_to_jsonc_gitignore(&ctx.repo_root) {
Ok(true) => {
println!("{}", "✓ Updated .gitignore for JSONC patterns".green());
}
Ok(false) => {
log::debug!(".gitignore JSON to JSONC migration not needed or already up to date");
}
Err(e) => {
eprintln!(
"{}",
format!("⚠ Warning: Failed to update .gitignore for JSONC: {}", e).yellow()
);
}
}
Ok(())
}
fn show_migration_status() -> Result<()> {
let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
println!("{}", "Migration Status".bold());
println!();
println!("{}", "History:".bold());
println!(
" Location: {}",
migration::history::migration_history_path(&ctx.repo_root).display()
);
println!(
" Applied migrations: {}",
ctx.migration_history.applied_migrations.len()
);
println!();
match migration::check_migrations(&ctx)? {
MigrationCheckResult::Current => {
println!("{}", "Pending migrations: None".green());
}
MigrationCheckResult::Pending(migrations) => {
println!(
"{} {}",
"Pending migrations:".yellow(),
format!("({})", migrations.len()).yellow()
);
for migration in migrations {
println!(" - {}: {}", migration.id.yellow(), migration.description);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_args_default_values() {
let args = MigrateArgs {
check: false,
apply: false,
list: false,
force: false,
command: None,
};
assert!(!args.check);
assert!(!args.apply);
assert!(!args.list);
assert!(!args.force);
}
#[test]
fn migrate_args_with_check_enabled() {
let args = MigrateArgs {
check: true,
apply: false,
list: false,
force: false,
command: None,
};
assert!(args.check);
}
#[test]
fn migrate_args_with_apply_and_force() {
let args = MigrateArgs {
check: false,
apply: true,
list: false,
force: true,
command: None,
};
assert!(args.apply);
assert!(args.force);
}
#[test]
fn migrate_command_status_variant() {
let cmd = MigrateCommand::Status;
assert!(matches!(cmd, MigrateCommand::Status));
}
}