Skip to main content

pebble_cms/cli/
migrate.rs

1use 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            // Default: run forward migrations (backward compatible)
14            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    // Safety check: rolling back migration 001 requires --force
104    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    // Show what will be rolled back
112    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    // Prompt for confirmation unless --force
134    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    // Execute rollbacks in reverse order (highest version first)
146    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}