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::config;
19use crate::migration::{self, MigrationCheckResult, MigrationContext};
20use anyhow::{Context, Result};
21use clap::{Args, Subcommand};
22use colored::Colorize;
23
24#[derive(Args)]
25#[command(
26    about = "Check and apply migrations for config and project files",
27    after_long_help = "Examples:
28  ralph migrate              # Check for pending migrations
29  ralph migrate --check      # Exit with error code if migrations pending (CI)
30  ralph migrate --apply      # Apply all pending migrations
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 resolved = config::resolve_from_cwd().context("resolve configuration")?;
90    let ctx = MigrationContext::from_resolved(&resolved).context("create migration context")?;
91
92    match migration::check_migrations(&ctx)? {
93        MigrationCheckResult::Current => {
94            println!("{}", "✓ No pending migrations".green());
95            Ok(())
96        }
97        MigrationCheckResult::Pending(migrations) => {
98            println!(
99                "{}",
100                format!("✗ {} pending migration(s) found", migrations.len()).red()
101            );
102            for migration in &migrations {
103                println!("  - {}: {}", migration.id.yellow(), migration.description);
104            }
105            println!("\nRun {} to apply them.", "ralph migrate --apply".cyan());
106            std::process::exit(1);
107        }
108    }
109}
110
111/// Show pending migrations without exiting with error code.
112fn show_pending_migrations() -> Result<()> {
113    let resolved = config::resolve_from_cwd().context("resolve configuration")?;
114    let ctx = MigrationContext::from_resolved(&resolved).context("create migration context")?;
115
116    match migration::check_migrations(&ctx)? {
117        MigrationCheckResult::Current => {
118            println!("{}", "✓ No pending migrations".green());
119            println!("\nYour project is up to date!");
120        }
121        MigrationCheckResult::Pending(migrations) => {
122            println!(
123                "{}",
124                format!("Found {} pending migration(s):", migrations.len()).yellow()
125            );
126            println!();
127            for migration in &migrations {
128                println!("  {} {}", "•".cyan(), migration.id.bold());
129                println!("    {}", migration.description);
130                println!();
131            }
132            println!("Run {} to apply them.", "ralph migrate --apply".cyan());
133        }
134    }
135
136    Ok(())
137}
138
139/// List all migrations with their status.
140fn list_migrations() -> Result<()> {
141    let resolved = config::resolve_from_cwd().context("resolve configuration")?;
142    let ctx = MigrationContext::from_resolved(&resolved).context("create migration context")?;
143
144    let migrations = migration::list_migrations(&ctx);
145
146    if migrations.is_empty() {
147        println!("No migrations defined.");
148        return Ok(());
149    }
150
151    println!("{}", "Available migrations:".bold());
152    println!();
153
154    for status in &migrations {
155        let status_icon = if status.applied {
156            "✓".green()
157        } else if status.applicable {
158            "○".yellow()
159        } else {
160            "-".dimmed()
161        };
162
163        let status_text = if status.applied {
164            "applied".green()
165        } else if status.applicable {
166            "pending".yellow()
167        } else {
168            "not applicable".dimmed()
169        };
170
171        println!(
172            "  {} {} ({})",
173            status_icon,
174            status.migration.id.bold(),
175            status_text
176        );
177        println!("    {}", status.migration.description);
178        println!();
179    }
180
181    let applied_count = migrations.iter().filter(|m| m.applied).count();
182    let pending_count = migrations
183        .iter()
184        .filter(|m| !m.applied && m.applicable)
185        .count();
186
187    println!(
188        "{} applied, {} pending, {} not applicable",
189        applied_count.to_string().green(),
190        pending_count.to_string().yellow(),
191        (migrations.len() - applied_count - pending_count)
192            .to_string()
193            .dimmed()
194    );
195
196    Ok(())
197}
198
199/// Apply all pending migrations.
200fn apply_migrations(force: bool) -> Result<()> {
201    let resolved = config::resolve_from_cwd().context("resolve configuration")?;
202    let mut ctx = MigrationContext::from_resolved(&resolved).context("create migration context")?;
203
204    // Check what migrations would be applied
205    let pending = match migration::check_migrations(&ctx)? {
206        MigrationCheckResult::Current => {
207            println!("{}", "✓ No pending migrations to apply".green());
208            return Ok(());
209        }
210        MigrationCheckResult::Pending(migrations) => migrations,
211    };
212
213    if force {
214        println!(
215            "{}",
216            "⚠ Force mode enabled: Will re-apply already applied migrations".yellow()
217        );
218    }
219
220    println!(
221        "{}",
222        format!("Will apply {} migration(s):", pending.len()).cyan()
223    );
224    println!();
225    for migration in &pending {
226        println!("  - {}: {}", migration.id.yellow(), migration.description);
227    }
228    println!();
229
230    // Confirm with user
231    if !force {
232        print!("{} ", "Apply these migrations? [y/N]:".bold());
233        use std::io::Write;
234        std::io::stdout().flush()?;
235
236        let mut input = String::new();
237        std::io::stdin().read_line(&mut input)?;
238
239        if !input.trim().eq_ignore_ascii_case("y") {
240            println!("Cancelled.");
241            return Ok(());
242        }
243    }
244
245    println!();
246
247    // Apply migrations
248    let applied = migration::apply_all_migrations(&mut ctx).context("apply migrations")?;
249
250    if applied.is_empty() {
251        println!("{}", "No migrations were applied".yellow());
252    } else {
253        println!(
254            "{}",
255            format!("✓ Successfully applied {} migration(s)", applied.len()).green()
256        );
257        for id in applied {
258            println!("  {} {}", "✓".green(), id);
259        }
260    }
261
262    // Apply gitignore migration for JSON to JSONC patterns
263    match gitignore::migrate_json_to_jsonc_gitignore(&ctx.repo_root) {
264        Ok(true) => {
265            println!("{}", "✓ Updated .gitignore for JSONC patterns".green());
266        }
267        Ok(false) => {
268            log::debug!(".gitignore JSON to JSONC migration not needed or already up to date");
269        }
270        Err(e) => {
271            eprintln!(
272                "{}",
273                format!("⚠ Warning: Failed to update .gitignore for JSONC: {}", e).yellow()
274            );
275        }
276    }
277
278    Ok(())
279}
280
281/// Show detailed migration status.
282fn show_migration_status() -> Result<()> {
283    let resolved = config::resolve_from_cwd().context("resolve configuration")?;
284    let ctx = MigrationContext::from_resolved(&resolved).context("create migration context")?;
285
286    println!("{}", "Migration Status".bold());
287    println!();
288
289    // Show migration history info
290    println!("{}", "History:".bold());
291    println!(
292        "  Location: {}",
293        migration::history::migration_history_path(&ctx.repo_root).display()
294    );
295    println!(
296        "  Applied migrations: {}",
297        ctx.migration_history.applied_migrations.len()
298    );
299    println!();
300
301    // Show pending migrations
302    match migration::check_migrations(&ctx)? {
303        MigrationCheckResult::Current => {
304            println!("{}", "Pending migrations: None".green());
305        }
306        MigrationCheckResult::Pending(migrations) => {
307            println!(
308                "{} {}",
309                "Pending migrations:".yellow(),
310                format!("({})", migrations.len()).yellow()
311            );
312            for migration in migrations {
313                println!("  - {}: {}", migration.id.yellow(), migration.description);
314            }
315        }
316    }
317
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn migrate_args_default_values() {
327        // Test that the struct can be created with default values
328        let args = MigrateArgs {
329            check: false,
330            apply: false,
331            list: false,
332            force: false,
333            command: None,
334        };
335        assert!(!args.check);
336        assert!(!args.apply);
337        assert!(!args.list);
338        assert!(!args.force);
339    }
340
341    #[test]
342    fn migrate_args_with_check_enabled() {
343        let args = MigrateArgs {
344            check: true,
345            apply: false,
346            list: false,
347            force: false,
348            command: None,
349        };
350        assert!(args.check);
351    }
352
353    #[test]
354    fn migrate_args_with_apply_and_force() {
355        let args = MigrateArgs {
356            check: false,
357            apply: true,
358            list: false,
359            force: true,
360            command: None,
361        };
362        assert!(args.apply);
363        assert!(args.force);
364    }
365
366    #[test]
367    fn migrate_command_status_variant() {
368        let cmd = MigrateCommand::Status;
369        assert!(matches!(cmd, MigrateCommand::Status));
370    }
371}