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:
cueloop migrate # Check for pending migrations
cueloop migrate --check # Exit with error code if migrations pending (CI)
cueloop migrate --apply # Apply all pending config/file migrations
cueloop migrate --list # List all migrations and their status
cueloop migrate status # Show detailed migration status
cueloop migrate runtime-dir --check # Check whether .ralph should be moved to .cueloop
cueloop migrate runtime-dir --apply # Explicitly move .ralph project state to .cueloop
"
)]
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,
#[command(name = "runtime-dir")]
RuntimeDir(RuntimeDirArgs),
}
#[derive(Args)]
pub struct RuntimeDirArgs {
#[arg(long, conflicts_with = "apply")]
pub check: bool,
#[arg(long, conflicts_with = "check")]
pub apply: bool,
}
pub fn handle_migrate(args: MigrateArgs) -> Result<()> {
if let Some(command) = args.command {
return match command {
MigrateCommand::Status => show_migration_status(),
MigrateCommand::RuntimeDir(runtime_args) => handle_runtime_dir_migration(runtime_args),
};
}
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.", "cueloop 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.", "cueloop 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 handle_runtime_dir_migration(args: RuntimeDirArgs) -> Result<()> {
let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
if args.apply {
return apply_runtime_dir_migration(&ctx.repo_root);
}
let state = migration::runtime_dir::check_runtime_dir_migration(&ctx.repo_root);
print_runtime_dir_state(&state);
if args.check && state.check_should_fail() {
std::process::exit(1);
}
Ok(())
}
fn print_runtime_dir_state(state: &migration::runtime_dir::RuntimeDirMigrationState) {
let label = match state {
migration::runtime_dir::RuntimeDirMigrationState::Uninitialized { .. } => {
state.label().dimmed()
}
migration::runtime_dir::RuntimeDirMigrationState::AlreadyCurrent { .. } => {
state.label().green()
}
migration::runtime_dir::RuntimeDirMigrationState::NeedsMigration { .. } => {
state.label().yellow()
}
migration::runtime_dir::RuntimeDirMigrationState::Collision { .. } => state.label().red(),
};
println!("{} {}", "Runtime directory migration:".bold(), label);
println!("{}", state.guidance());
if matches!(
state,
migration::runtime_dir::RuntimeDirMigrationState::NeedsMigration { .. }
) {
println!(
"Run {} to move durable project state to .cueloop.",
"cueloop migrate runtime-dir --apply".cyan()
);
}
}
fn apply_runtime_dir_migration(repo_root: &std::path::Path) -> Result<()> {
let report = migration::runtime_dir::apply_runtime_dir_migration(repo_root)?;
match &report.initial_state {
migration::runtime_dir::RuntimeDirMigrationState::Uninitialized { .. }
| migration::runtime_dir::RuntimeDirMigrationState::AlreadyCurrent { .. } => {
print_runtime_dir_state(&report.initial_state);
return Ok(());
}
migration::runtime_dir::RuntimeDirMigrationState::NeedsMigration { .. } => {}
migration::runtime_dir::RuntimeDirMigrationState::Collision { .. } => unreachable!(
"runtime-dir collision should be returned as an error before report construction"
),
}
println!(
"{}",
"✓ Moved project runtime directory from .ralph to .cueloop".green()
);
if report.gitignore_updated {
println!("{}", "✓ Updated .gitignore runtime path references".green());
}
if report.config_files_updated > 0 {
println!(
"{}",
format!(
"✓ Updated runtime path references in {} config file(s)",
report.config_files_updated
)
.green()
);
}
if report.readme_refreshed {
println!("{}", "✓ Refreshed generated runtime README".green());
}
if report.history_recorded {
println!(
"{}",
format!(
"✓ Recorded migration history at {}",
migration::history::migration_history_path(repo_root).display()
)
.green()
);
}
for warning in report.warnings {
eprintln!("{}", format!("⚠ Warning: {warning}").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));
}
}