1use 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 #[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 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
111fn 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
139fn 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
199fn 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 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 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 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 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
281fn 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 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 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 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}