pebble_cms/cli/
migrate.rs1use crate::cli::MigrateCommand;
2use crate::{Config, Database};
3use anyhow::Result;
4use std::io::{self, Write};
5use std::path::Path;
6
7pub async fn run(config_path: &Path, command: Option<MigrateCommand>) -> Result<()> {
8 let config = Config::load(config_path)?;
9 let db = Database::open(&config.database.path)?;
10
11 match command {
12 None => {
13 db.migrate()?;
15 tracing::info!("Migrations complete");
16 }
17 Some(MigrateCommand::Status) => {
18 show_status(&db)?;
19 }
20 Some(MigrateCommand::Rollback { steps, force }) => {
21 rollback(&db, steps, force)?;
22 }
23 }
24
25 Ok(())
26}
27
28fn show_status(db: &Database) -> Result<()> {
29 let statuses = db.get_migration_status()?;
30
31 println!("\n Migration Status\n");
32 println!(" {:<10} {:<45} {}", "Version", "Description", "Applied");
33 println!(" {}", "-".repeat(80));
34
35 let descriptions = [
36 "Core tables (users, content, tags, media, settings)",
37 "Full-text search (FTS5)",
38 "Media optimization columns",
39 "Scheduled publishing",
40 "Analytics tables",
41 "Content versioning",
42 "Audit logging",
43 "Preview tokens",
44 "Content series",
45 "API tokens and webhooks",
46 ];
47
48 for (version, applied_at) in &statuses {
49 let desc = descriptions
50 .get((*version as usize).saturating_sub(1))
51 .unwrap_or(&"Unknown migration");
52
53 let applied = match applied_at {
54 Some(ts) => format!("\x1b[32m✓\x1b[0m {}", ts),
55 None => "\x1b[33m✗ pending\x1b[0m".to_string(),
56 };
57
58 println!(
59 " {:<10} {:<45} {}",
60 format!("{:03}", version),
61 desc,
62 applied
63 );
64 }
65
66 let applied_count = statuses.iter().filter(|(_, ts)| ts.is_some()).count();
67 let pending_count = statuses.len() - applied_count;
68
69 println!();
70 if pending_count > 0 {
71 println!(
72 " {} applied, {} pending. Run `pebble migrate` to apply.",
73 applied_count, pending_count
74 );
75 } else {
76 println!(" All {} migrations applied.", applied_count);
77 }
78 println!();
79
80 Ok(())
81}
82
83fn rollback(db: &Database, steps: u32, force: bool) -> Result<()> {
84 let statuses = db.get_migration_status()?;
85 let applied: Vec<i32> = statuses
86 .iter()
87 .filter(|(_, ts)| ts.is_some())
88 .map(|(v, _)| *v)
89 .collect();
90
91 if applied.is_empty() {
92 println!("No migrations to roll back.");
93 return Ok(());
94 }
95
96 let to_rollback: Vec<i32> = applied.iter().rev().take(steps as usize).copied().collect();
97
98 if to_rollback.is_empty() {
99 println!("Nothing to roll back.");
100 return Ok(());
101 }
102
103 if to_rollback.contains(&1) && !force {
105 anyhow::bail!(
106 "Rolling back migration 001 will DESTROY ALL DATA (users, content, tags, media).\n\
107 This cannot be undone. Use --force to confirm."
108 );
109 }
110
111 let descriptions = [
113 "Core tables (users, content, tags, media, settings)",
114 "Full-text search (FTS5)",
115 "Media optimization columns",
116 "Scheduled publishing",
117 "Analytics tables",
118 "Content versioning",
119 "Audit logging",
120 "Preview tokens",
121 "Content series",
122 "API tokens and webhooks",
123 ];
124
125 println!("\n The following migrations will be rolled back:\n");
126 for v in &to_rollback {
127 let desc = descriptions
128 .get((*v as usize).saturating_sub(1))
129 .unwrap_or(&"Unknown");
130 println!(" {:03} — {}", v, desc);
131 }
132
133 if !force {
135 print!("\n This will delete data. Continue? [y/N] ");
136 io::stdout().flush()?;
137 let mut input = String::new();
138 io::stdin().read_line(&mut input)?;
139 if !input.trim().eq_ignore_ascii_case("y") {
140 println!(" Rollback cancelled.");
141 return Ok(());
142 }
143 }
144
145 for version in &to_rollback {
147 println!(" Rolling back migration {:03}...", version);
148 db.rollback_migration(*version)?;
149 }
150
151 println!(
152 "\n Successfully rolled back {} migration(s).",
153 to_rollback.len()
154 );
155 println!(" Run `pebble migrate` to re-apply them.\n");
156
157 Ok(())
158}