Skip to main content

slave_framework/migration/
console.rs

1//! Command-line front end for the migration engine.
2//!
3//! Generated applications call [`run`] from `src/bin/migrate.rs`; the `slave`
4//! CLI invokes that binary for `migrate`, `migrate:status`, `migrate:rollback`
5//! and `migrate:fresh`.
6
7use std::fmt::Write as _;
8use std::process::ExitCode;
9use std::time::Duration;
10
11use super::{Migrator, Registered, RollbackReport, RunReport, State, Status};
12use crate::database::Database;
13use crate::{Result, env};
14
15/// Parses the first argument (`run`, `status`, `rollback` or `fresh`; default
16/// `run`), connects to the database from `TURSO_DATABASE_URL`, and prints the
17/// outcome. `fresh` drops every table first, then runs all migrations.
18pub async fn run(migrations: Vec<Registered>) -> ExitCode {
19    let command = std::env::args().nth(1);
20    match execute(command.as_deref(), migrations).await {
21        Ok(code) => code,
22        Err(err) => {
23            eprintln!("error: {err}");
24            ExitCode::FAILURE
25        }
26    }
27}
28
29async fn execute(command: Option<&str>, migrations: Vec<Registered>) -> Result<ExitCode> {
30    if !matches!(
31        command,
32        None | Some("run" | "status" | "rollback" | "fresh")
33    ) {
34        eprintln!("usage: migrate [run|status|rollback|fresh]");
35        return Ok(ExitCode::from(2));
36    }
37
38    env::load_dotenv()?;
39    let database = Database::from_env().await?;
40    let conn = database.connect().await?;
41    println!("Database: {}\n", database.url());
42    if command == Some("fresh") {
43        let dropped = super::drop_all_tables(&conn).await?;
44        println!("Dropped {dropped} table(s).\n");
45    }
46    let mut migrator = Migrator::new(conn, migrations)?;
47
48    let (output, ok) = match command {
49        Some("status") => (format_status(&migrator.status().await?), true),
50        Some("rollback") => {
51            let report = migrator.rollback().await?;
52            (format_rollback(&report), report.failed.is_none())
53        }
54        _ => {
55            let report = migrator.run().await?;
56            (format_run(&report), report.failed.is_none())
57        }
58    };
59    print!("{output}");
60    Ok(if ok {
61        ExitCode::SUCCESS
62    } else {
63        ExitCode::FAILURE
64    })
65}
66
67pub fn format_run(report: &RunReport) -> String {
68    let mut out = String::new();
69    for outcome in &report.applied {
70        let _ = writeln!(
71            out,
72            "  DONE    {}  {}",
73            outcome.name,
74            millis(outcome.duration)
75        );
76    }
77    if let Some(failure) = &report.failed {
78        let _ = writeln!(
79            out,
80            "  FAILED  {}\n          {}",
81            failure.name, failure.error
82        );
83    }
84
85    let skipped = report.skipped.len();
86    if report.applied.is_empty() && report.failed.is_none() {
87        let _ = writeln!(out, "Nothing to migrate ({skipped} already applied).");
88        return out;
89    }
90    let _ = writeln!(
91        out,
92        "\nBatch {}: {} applied, {skipped} skipped (already applied).",
93        report.batch,
94        report.applied.len()
95    );
96    if let Some(failure) = &report.failed {
97        let _ = writeln!(out, "{}", failure_note(&failure.name, failure.reverted));
98    }
99    out
100}
101
102pub fn format_rollback(report: &RollbackReport) -> String {
103    let mut out = String::new();
104    let Some(batch) = report.batch else {
105        out.push_str("Nothing to roll back.\n");
106        return out;
107    };
108    let _ = writeln!(out, "Rolling back batch {batch}:");
109    for outcome in &report.rolled_back {
110        let _ = writeln!(
111            out,
112            "  REVERTED  {}  {}",
113            outcome.name,
114            millis(outcome.duration)
115        );
116    }
117    if let Some(failure) = &report.failed {
118        let _ = writeln!(
119            out,
120            "  FAILED    {}\n            {}",
121            failure.name, failure.error
122        );
123        let _ = writeln!(
124            out,
125            "\n{} Migrations reverted above stay reverted.",
126            failure_note(&failure.name, failure.reverted)
127        );
128    }
129    out
130}
131
132pub fn format_status(statuses: &[Status]) -> String {
133    if statuses.is_empty() {
134        return "No migrations found.\n".to_owned();
135    }
136    let mut out = format!(
137        "{:<8} {:<6} {:<20} Migration\n",
138        "Status", "Batch", "Executed at"
139    );
140    let mut any_missing = false;
141    for status in statuses {
142        let (label, batch, at) = match &status.state {
143            State::Applied { batch, executed_at } => {
144                ("Ran", batch.to_string(), executed_at.as_str())
145            }
146            State::Pending => ("Pending", String::new(), ""),
147            State::Missing { batch, executed_at } => {
148                any_missing = true;
149                ("Missing", batch.to_string(), executed_at.as_str())
150            }
151        };
152        let _ = writeln!(out, "{label:<8} {batch:<6} {at:<20} {}", status.name);
153    }
154    if any_missing {
155        out.push_str(
156            "\nMissing: recorded as applied, but not registered in database/migrations/mod.rs.\n",
157        );
158    }
159    out
160}
161
162fn failure_note(name: &str, reverted: bool) -> String {
163    if reverted {
164        format!("`{name}` was rolled back and not recorded; fix it and run the command again.")
165    } else {
166        format!(
167            "`{name}` is non-transactional: statements before the failure may have been applied. \
168             Inspect the database before retrying."
169        )
170    }
171}
172
173fn millis(duration: Duration) -> String {
174    format!("({} ms)", duration.as_millis())
175}