1use 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 #[arg(long, conflicts_with = "apply")]
38 pub check: bool,
39
40 #[arg(long, conflicts_with = "check")]
42 pub apply: bool,
43
44 #[arg(long, conflicts_with_all = ["check", "apply"])]
46 pub list: bool,
47
48 #[arg(long, requires = "apply")]
50 pub force: bool,
51
52 #[command(subcommand)]
54 pub command: Option<MigrateCommand>,
55}
56
57#[derive(Subcommand)]
58pub enum MigrateCommand {
59 Status,
61}
62
63pub fn handle_migrate(args: MigrateArgs) -> Result<()> {
65 if let Some(MigrateCommand::Status) = args.command {
67 return show_migration_status();
68 }
69
70 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 show_pending_migrations()
85}
86
87fn 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
110fn 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
137fn 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
196fn apply_migrations(force: bool) -> Result<()> {
198 let mut ctx = MigrationContext::discover_from_cwd().context("discover migration context")?;
199
200 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 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 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 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
277fn 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 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 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 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}