Skip to main content

ralph/cli/
migrate.rs

1//! Migration CLI command for checking and applying config/file migrations.
2//!
3//! Responsibilities:
4//! - Provide CLI interface for migration operations (check, list, apply).
5//! - Display migration status to users in a readable format.
6//! - Handle user confirmation for destructive operations.
7//!
8//! Not handled here:
9//! - Migration implementation logic (see `crate::migration`).
10//! - Migration history persistence (see `crate::migration::history`).
11//!
12//! Invariants/assumptions:
13//! - Requires a valid Ralph project (with .ralph directory).
14//! - `--apply` requires explicit user action (not automatic).
15//! - Exit code 1 from `--check` when migrations are pending for CI integration.
16
17use crate::commands::init::gitignore;
18use crate::migration::{self, MigrationCheckResult, MigrationContext};
19use anyhow::{Context, Result};
20use clap::{Args, Subcommand};
21use colored::Colorize;
22
23#[derive(Args)]
24#[command(
25    about = "Check and apply migrations for config and project files",
26    after_long_help = "Examples:
27  ralph migrate              # Check for pending migrations
28  ralph migrate --check      # Exit with error code if migrations pending (CI)
29  ralph migrate --apply      # Apply all pending migrations
30  ralph migrate --apply      # Also repairs legacy v1 config files that 0.3 cannot load directly
31  ralph migrate --list       # List all migrations and their status
32  ralph migrate status       # Show detailed migration status
33"
34)]
35pub struct MigrateArgs {
36    /// Check for pending migrations without applying them (exit 1 if any pending).
37    #[arg(long, conflicts_with = "apply")]
38    pub check: bool,
39
40    /// Apply pending migrations.
41    #[arg(long, conflicts_with = "check")]
42    pub apply: bool,
43
44    /// List all migrations and their status.
45    #[arg(long, conflicts_with_all = ["check", "apply"])]
46    pub list: bool,
47
48    /// Force apply migrations even if already applied (dangerous).
49    #[arg(long, requires = "apply")]
50    pub force: bool,
51
52    /// Subcommand for more detailed operations.
53    #[command(subcommand)]
54    pub command: Option<MigrateCommand>,
55}
56
57#[derive(Subcommand)]
58pub enum MigrateCommand {
59    /// Show detailed migration status.
60    Status,
61}
62
63/// Handle the migrate command.
64pub fn handle_migrate(args: MigrateArgs) -> Result<()> {
65    // Handle subcommands first
66    if let Some(MigrateCommand::Status) = args.command {
67        return show_migration_status();
68    }
69
70    // Handle flags
71    if args.list {
72        return list_migrations();
73    }
74
75    if args.apply {
76        return apply_migrations(args.force);
77    }
78
79    if args.check {
80        return check_migrations();
81    }
82
83    // Default: show pending migrations
84    show_pending_migrations()
85}
86
87/// Check for pending migrations and exit with error code if any found.
88fn check_migrations() -> Result<()> {
89    let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
90
91    match migration::check_migrations(&ctx)? {
92        MigrationCheckResult::Current => {
93            println!("{}", "✓ No pending migrations".green());
94            Ok(())
95        }
96        MigrationCheckResult::Pending(migrations) => {
97            println!(
98                "{}",
99                format!("✗ {} pending migration(s) found", migrations.len()).red()
100            );
101            for migration in &migrations {
102                println!("  - {}: {}", migration.id.yellow(), migration.description);
103            }
104            println!("\nRun {} to apply them.", "ralph migrate --apply".cyan());
105            std::process::exit(1);
106        }
107    }
108}
109
110/// Show pending migrations without exiting with error code.
111fn show_pending_migrations() -> Result<()> {
112    let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
113
114    match migration::check_migrations(&ctx)? {
115        MigrationCheckResult::Current => {
116            println!("{}", "✓ No pending migrations".green());
117            println!("\nYour project is up to date!");
118        }
119        MigrationCheckResult::Pending(migrations) => {
120            println!(
121                "{}",
122                format!("Found {} pending migration(s):", migrations.len()).yellow()
123            );
124            println!();
125            for migration in &migrations {
126                println!("  {} {}", "•".cyan(), migration.id.bold());
127                println!("    {}", migration.description);
128                println!();
129            }
130            println!("Run {} to apply them.", "ralph migrate --apply".cyan());
131        }
132    }
133
134    Ok(())
135}
136
137/// List all migrations with their status.
138fn list_migrations() -> Result<()> {
139    let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
140
141    let migrations = migration::list_migrations(&ctx);
142
143    if migrations.is_empty() {
144        println!("No migrations defined.");
145        return Ok(());
146    }
147
148    println!("{}", "Available migrations:".bold());
149    println!();
150
151    for status in &migrations {
152        let status_icon = if status.applied {
153            "✓".green()
154        } else if status.applicable {
155            "○".yellow()
156        } else {
157            "-".dimmed()
158        };
159
160        let status_text = if status.applied {
161            "applied".green()
162        } else if status.applicable {
163            "pending".yellow()
164        } else {
165            "not applicable".dimmed()
166        };
167
168        println!(
169            "  {} {} ({})",
170            status_icon,
171            status.migration.id.bold(),
172            status_text
173        );
174        println!("    {}", status.migration.description);
175        println!();
176    }
177
178    let applied_count = migrations.iter().filter(|m| m.applied).count();
179    let pending_count = migrations
180        .iter()
181        .filter(|m| !m.applied && m.applicable)
182        .count();
183
184    println!(
185        "{} applied, {} pending, {} not applicable",
186        applied_count.to_string().green(),
187        pending_count.to_string().yellow(),
188        (migrations.len() - applied_count - pending_count)
189            .to_string()
190            .dimmed()
191    );
192
193    Ok(())
194}
195
196/// Apply all pending migrations.
197fn apply_migrations(force: bool) -> Result<()> {
198    let mut ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
199
200    // Check what migrations would be applied
201    let pending = match migration::check_migrations(&ctx)? {
202        MigrationCheckResult::Current => {
203            println!("{}", "✓ No pending migrations to apply".green());
204            return Ok(());
205        }
206        MigrationCheckResult::Pending(migrations) => migrations,
207    };
208
209    if force {
210        println!(
211            "{}",
212            "⚠ Force mode enabled: Will re-apply already applied migrations".yellow()
213        );
214    }
215
216    println!(
217        "{}",
218        format!("Will apply {} migration(s):", pending.len()).cyan()
219    );
220    println!();
221    for migration in &pending {
222        println!("  - {}: {}", migration.id.yellow(), migration.description);
223    }
224    println!();
225
226    // Confirm with user
227    if !force {
228        print!("{} ", "Apply these migrations? [y/N]:".bold());
229        use std::io::Write;
230        std::io::stdout().flush()?;
231
232        let mut input = String::new();
233        std::io::stdin().read_line(&mut input)?;
234
235        if !input.trim().eq_ignore_ascii_case("y") {
236            println!("Cancelled.");
237            return Ok(());
238        }
239    }
240
241    println!();
242
243    // Apply migrations
244    let applied = migration::apply_all_migrations(&mut ctx).context("apply migrations")?;
245
246    if applied.is_empty() {
247        println!("{}", "No migrations were applied".yellow());
248    } else {
249        println!(
250            "{}",
251            format!("✓ Successfully applied {} migration(s)", applied.len()).green()
252        );
253        for id in applied {
254            println!("  {} {}", "✓".green(), id);
255        }
256    }
257
258    // Apply gitignore migration for JSON to JSONC patterns
259    match gitignore::migrate_json_to_jsonc_gitignore(&ctx.repo_root) {
260        Ok(true) => {
261            println!("{}", "✓ Updated .gitignore for JSONC patterns".green());
262        }
263        Ok(false) => {
264            log::debug!(".gitignore JSON to JSONC migration not needed or already up to date");
265        }
266        Err(e) => {
267            eprintln!(
268                "{}",
269                format!("⚠ Warning: Failed to update .gitignore for JSONC: {}", e).yellow()
270            );
271        }
272    }
273
274    Ok(())
275}
276
277/// Show detailed migration status.
278fn show_migration_status() -> Result<()> {
279    let ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
280
281    println!("{}", "Migration Status".bold());
282    println!();
283
284    // Show migration history info
285    println!("{}", "History:".bold());
286    println!(
287        "  Location: {}",
288        migration::history::migration_history_path(&ctx.repo_root).display()
289    );
290    println!(
291        "  Applied migrations: {}",
292        ctx.migration_history.applied_migrations.len()
293    );
294    println!();
295
296    // Show pending migrations
297    match migration::check_migrations(&ctx)? {
298        MigrationCheckResult::Current => {
299            println!("{}", "Pending migrations: None".green());
300        }
301        MigrationCheckResult::Pending(migrations) => {
302            println!(
303                "{} {}",
304                "Pending migrations:".yellow(),
305                format!("({})", migrations.len()).yellow()
306            );
307            for migration in migrations {
308                println!("  - {}: {}", migration.id.yellow(), migration.description);
309            }
310        }
311    }
312
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn migrate_args_default_values() {
322        // Test that the struct can be created with default values
323        let args = MigrateArgs {
324            check: false,
325            apply: false,
326            list: false,
327            force: false,
328            command: None,
329        };
330        assert!(!args.check);
331        assert!(!args.apply);
332        assert!(!args.list);
333        assert!(!args.force);
334    }
335
336    #[test]
337    fn migrate_args_with_check_enabled() {
338        let args = MigrateArgs {
339            check: true,
340            apply: false,
341            list: false,
342            force: false,
343            command: None,
344        };
345        assert!(args.check);
346    }
347
348    #[test]
349    fn migrate_args_with_apply_and_force() {
350        let args = MigrateArgs {
351            check: false,
352            apply: true,
353            list: false,
354            force: true,
355            command: None,
356        };
357        assert!(args.apply);
358        assert!(args.force);
359    }
360
361    #[test]
362    fn migrate_command_status_variant() {
363        let cmd = MigrateCommand::Status;
364        assert!(matches!(cmd, MigrateCommand::Status));
365    }
366}