Skip to main content

siderite_migrations/
cli.rs

1//! Reusable command runner (`makemigrations`, `migrate`, `rollback`,
2//! `showmigrations`, `squashmigrations`).
3//!
4//! Applications call [`run`] from their own binary so `makemigrations` can
5//! see compiled [`siderite_orm::ModelMeta`]. The `siderite` CLI binary only
6//! applies JSON files; see `docs/MIGRATIONS.md`.
7
8use crate::autodetector;
9use crate::error::MigrationError;
10use crate::executor::{Migrator, Report};
11use crate::loader::{self, MigrationGraph};
12use crate::migration::{Migration, next_id, sanitize_slug, slug_from_operations};
13use crate::squash;
14use crate::state::ProjectState;
15use siderite_orm::{Db, ModelMeta};
16use std::path::Path;
17
18/// Process exit status (`0` is success).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ExitCode(pub u8);
21
22impl ExitCode {
23    /// Success.
24    pub const SUCCESS: Self = Self(0);
25    /// Command failed.
26    pub const FAILURE: Self = Self(1);
27    /// Usage / argument error.
28    pub const USAGE: Self = Self(2);
29}
30
31/// Run a migrations command.
32///
33/// `models` is the compiled metadata (`User::META`, …). `dir` is the
34/// migrations directory. `args` is the command plus flags, without the
35/// program name (`["makemigrations", "--name", "init"]`).
36///
37/// # Errors
38/// Graph, IO, JSON, ORM, or usage errors.
39pub async fn run(
40    models: &[&'static ModelMeta],
41    db: &Db,
42    dir: &Path,
43    args: impl IntoIterator<Item = String>,
44) -> Result<ExitCode, MigrationError> {
45    let argv: Vec<String> = args.into_iter().collect();
46    let parsed = parse_args(&argv)?;
47    if parsed.help {
48        print_help();
49        return Ok(ExitCode::SUCCESS);
50    }
51    let command = parsed
52        .command
53        .as_deref()
54        .ok_or_else(|| MigrationError::usage("missing command (try --help)"))?;
55    match command {
56        "makemigrations" => cmd_makemigrations(models, dir, &parsed),
57        "migrate" => cmd_migrate(db, dir, &parsed).await,
58        "rollback" => cmd_rollback(db, dir, &parsed).await,
59        "showmigrations" => cmd_show(db, dir).await,
60        "inspectmigrations" => cmd_inspect(db, dir).await,
61        "squashmigrations" => cmd_squash(dir, &parsed),
62        other => Err(MigrationError::usage(format!(
63            "unknown command `{other}` (try --help)"
64        ))),
65    }
66}
67
68struct Parsed {
69    command: Option<String>,
70    positional: Vec<String>,
71    dry_run: bool,
72    empty: bool,
73    name: Option<String>,
74    steps: Option<u32>,
75    help: bool,
76    lock_timeout_ms: u64,
77}
78
79fn parse_args(argv: &[String]) -> Result<Parsed, MigrationError> {
80    let mut parsed = Parsed {
81        command: None,
82        positional: Vec::new(),
83        dry_run: false,
84        empty: false,
85        name: None,
86        steps: None,
87        help: false,
88        lock_timeout_ms: 30_000,
89    };
90    let mut i = 0;
91    while i < argv.len() {
92        let a = &argv[i];
93        if a == "--help" || a == "-h" {
94            parsed.help = true;
95            i += 1;
96            continue;
97        }
98        if a == "--dry-run" {
99            parsed.dry_run = true;
100            i += 1;
101            continue;
102        }
103        if a == "--empty" {
104            parsed.empty = true;
105            i += 1;
106            continue;
107        }
108        if let Some(v) = strip_flag(a, "--name") {
109            parsed.name = Some(v.to_owned());
110            i += 1;
111            continue;
112        }
113        if a == "--name" {
114            parsed.name = Some(need_value(argv, &mut i, "--name")?);
115            continue;
116        }
117        if let Some(value) = strip_flag(a, "--lock-timeout-ms") {
118            parsed.lock_timeout_ms = parse_lock_timeout(value)?;
119            i += 1;
120            continue;
121        }
122        if a == "--lock-timeout-ms" {
123            let value = need_value(argv, &mut i, "--lock-timeout-ms")?;
124            parsed.lock_timeout_ms = parse_lock_timeout(&value)?;
125            continue;
126        }
127        if let Some(v) = strip_flag(a, "--steps") {
128            parsed.steps = Some(parse_steps(v)?);
129            i += 1;
130            continue;
131        }
132        if a == "--steps" {
133            parsed.steps = Some(parse_steps(&need_value(argv, &mut i, "--steps")?)?);
134            continue;
135        }
136        if a.starts_with('-') {
137            return Err(MigrationError::usage(format!("unknown flag `{a}`")));
138        }
139        if parsed.command.is_none() {
140            parsed.command = Some(a.clone());
141        } else {
142            parsed.positional.push(a.clone());
143        }
144        i += 1;
145    }
146    Ok(parsed)
147}
148
149fn strip_flag<'a>(arg: &'a str, flag: &str) -> Option<&'a str> {
150    let prefix = format!("{flag}=");
151    arg.strip_prefix(&prefix)
152}
153
154fn need_value(argv: &[String], i: &mut usize, flag: &str) -> Result<String, MigrationError> {
155    let value = argv
156        .get(*i + 1)
157        .cloned()
158        .ok_or_else(|| MigrationError::usage(format!("{flag} requires a value")))?;
159    *i += 2;
160    Ok(value)
161}
162
163fn parse_steps(v: &str) -> Result<u32, MigrationError> {
164    v.parse()
165        .map_err(|_| MigrationError::usage(format!("invalid --steps `{v}`")))
166}
167
168fn parse_lock_timeout(value: &str) -> Result<u64, MigrationError> {
169    value
170        .parse::<u64>()
171        .ok()
172        .filter(|timeout| *timeout > 0)
173        .ok_or_else(|| MigrationError::usage("lock timeout must be positive"))
174}
175
176fn print_help() {
177    println!(
178        "\
179siderite migrations
180
181Commands:
182  makemigrations [--name SLUG] [--empty] [--dry-run]
183  migrate [TARGET] [--dry-run]
184  rollback [--steps N | TARGET] [--dry-run]
185  showmigrations
186  inspectmigrations             Read-only recovery report (JSON)
187  squashmigrations FROM TO [--name SLUG]
188
189Flags:
190  --lock-timeout-ms N Advisory-lock deadline (default: 30000)
191  --dry-run          Print SQL / operations without writing
192  --name SLUG        Slug for a new or squashed migration
193  --empty            Write an empty migration
194  --steps N          Number of migrations to roll back
195  --help             Show this help
196"
197    );
198}
199
200fn cmd_makemigrations(
201    models: &[&'static ModelMeta],
202    dir: &Path,
203    parsed: &Parsed,
204) -> Result<ExitCode, MigrationError> {
205    let loaded = loader::load_dir(dir)?;
206    let graph = if loaded.is_empty() {
207        None
208    } else {
209        Some(MigrationGraph::build(loaded.clone())?)
210    };
211    let from = match &graph {
212        Some(g) => g.project_state()?,
213        None => ProjectState::new(),
214    };
215    let operations = if parsed.empty {
216        Vec::new()
217    } else {
218        let to = ProjectState::from_metas(models);
219        autodetector::diff(&from, &to)?
220    };
221    if operations.is_empty() && !parsed.empty {
222        println!("No changes detected.");
223        return Ok(ExitCode::SUCCESS);
224    }
225    let is_first = loaded.is_empty();
226    let slug = match &parsed.name {
227        Some(n) => sanitize_slug(n),
228        None => slug_from_operations(&operations, is_first),
229    };
230    let deps = graph
231        .as_ref()
232        .and_then(|g| g.order.last().cloned())
233        .into_iter()
234        .collect();
235    let id = next_id(&loaded, &slug);
236    let migration = Migration::new(id, deps, operations, true, Vec::new())?;
237    if parsed.dry_run {
238        println!("Would create {}:", migration.id);
239        for op in &migration.operations {
240            println!("  {}", op.summary());
241        }
242        return Ok(ExitCode::SUCCESS);
243    }
244    let path = loader::write_migration(dir, &migration)?;
245    println!("Created {}", path.display());
246    for op in &migration.operations {
247        println!("  {}", op.summary());
248    }
249    Ok(ExitCode::SUCCESS)
250}
251
252async fn cmd_migrate(db: &Db, dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
253    let graph = load_graph(dir)?;
254    let timeout = std::time::Duration::from_millis(parsed.lock_timeout_ms);
255    let migrator = Migrator::new(db, &graph).with_lock_timeout(timeout)?;
256    let target = parsed.positional.first().map(String::as_str);
257    let report = migrator.migrate(target, parsed.dry_run).await;
258    let report = name_lock_holder(&migrator, report).await?;
259    print_report("Applying", &report);
260    Ok(ExitCode::SUCCESS)
261}
262
263async fn cmd_rollback(db: &Db, dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
264    let graph = load_graph(dir)?;
265    let timeout = std::time::Duration::from_millis(parsed.lock_timeout_ms);
266    let migrator = Migrator::new(db, &graph).with_lock_timeout(timeout)?;
267    let target = parsed.positional.first().map(String::as_str);
268    let report = migrator
269        .rollback(target, parsed.steps, parsed.dry_run)
270        .await;
271    let report = name_lock_holder(&migrator, report).await?;
272    print_report("Unapplying", &report);
273    Ok(ExitCode::SUCCESS)
274}
275
276/// On [`MigrationError::LockTimeout`], print the session holding the lock
277/// to stderr; the error itself is returned unchanged.
278async fn name_lock_holder(
279    migrator: &Migrator<'_>,
280    outcome: Result<Report, MigrationError>,
281) -> Result<Report, MigrationError> {
282    if matches!(outcome, Err(MigrationError::LockTimeout)) {
283        match migrator.lock_holder().await {
284            Ok(Some(holder)) => {
285                eprintln!("the migration lock is held by {holder}");
286            }
287            Ok(None) => eprintln!("the migration lock has since been freed"),
288            Err(err) => eprintln!("cannot identify the lock holder: {err}"),
289        }
290    }
291    outcome
292}
293
294async fn cmd_show(db: &Db, dir: &Path) -> Result<ExitCode, MigrationError> {
295    let graph = load_graph(dir)?;
296    let rows = Migrator::new(db, &graph).show().await?;
297    print!("{}", render_status(&rows));
298    Ok(ExitCode::SUCCESS)
299}
300
301/// The `showmigrations` text for rows of [`Migrator::show`]: one
302/// `[X] id` (applied) or `[ ] id` (pending) line per migration.
303///
304/// Args:
305///   rows: `(id, applied)` pairs in display order.
306///
307/// Returns:
308///   The text, one line per row (empty for no rows).
309pub fn render_status(rows: &[(String, bool)]) -> String {
310    let mut out = String::new();
311    for (id, applied) in rows {
312        let mark = if *applied { 'X' } else { ' ' };
313        out.push_str(&format!("[{mark}] {id}\n"));
314    }
315    out
316}
317
318async fn cmd_inspect(db: &Db, dir: &Path) -> Result<ExitCode, MigrationError> {
319    let graph = load_graph(dir)?;
320    let report = Migrator::new(db, &graph).inspect_recovery().await?;
321    println!("{}", serde_json::to_string_pretty(&report)?);
322    eprintln!(
323        "Recorded progress is not proof of the live schema. \
324        Reads may race with an active migrator; reconcile catalogs before repair."
325    );
326    Ok(ExitCode::SUCCESS)
327}
328
329fn cmd_squash(dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
330    if parsed.positional.len() < 2 {
331        return Err(MigrationError::usage(
332            "squashmigrations requires FROM and TO ids",
333        ));
334    }
335    let graph = load_graph(dir)?;
336    let migration = squash::squash(
337        &graph,
338        &parsed.positional[0],
339        &parsed.positional[1],
340        parsed.name.as_deref(),
341    )?;
342    if parsed.dry_run {
343        println!(
344            "Would create {} replacing {:?}:",
345            migration.id, migration.replaces
346        );
347        for op in &migration.operations {
348            println!("  {}", op.summary());
349        }
350        return Ok(ExitCode::SUCCESS);
351    }
352    let path = loader::write_migration(dir, &migration)?;
353    println!(
354        "Created {} (replaces {:?})",
355        path.display(),
356        migration.replaces
357    );
358    Ok(ExitCode::SUCCESS)
359}
360
361fn load_graph(dir: &Path) -> Result<MigrationGraph, MigrationError> {
362    MigrationGraph::build(loader::load_dir(dir)?)
363}
364
365fn print_report(verb: &str, report: &Report) {
366    if report.planned.is_empty() {
367        println!("No migrations to apply.");
368        return;
369    }
370    if report.dry_run {
371        println!("Would run:");
372        for id in &report.planned {
373            println!("  {id}");
374        }
375        for sql in &report.sql {
376            println!("{sql};");
377        }
378        return;
379    }
380    for id in &report.applied {
381        println!("{verb} {id}... OK");
382    }
383}
384
385/// Generate a migration from compiled metadata without going through argv.
386///
387/// # Errors
388/// Graph, IO or JSON errors.
389pub fn make_migrations(
390    models: &[&'static ModelMeta],
391    dir: &Path,
392    name: Option<&str>,
393    empty: bool,
394) -> Result<Option<Migration>, MigrationError> {
395    let loaded = loader::load_dir(dir)?;
396    let graph = if loaded.is_empty() {
397        None
398    } else {
399        Some(MigrationGraph::build(loaded.clone())?)
400    };
401    let from = match &graph {
402        Some(g) => g.project_state()?,
403        None => ProjectState::new(),
404    };
405    let operations = if empty {
406        Vec::new()
407    } else {
408        autodetector::diff(&from, &ProjectState::from_metas(models))?
409    };
410    if operations.is_empty() && !empty {
411        return Ok(None);
412    }
413    let slug = match name {
414        Some(n) => sanitize_slug(n),
415        None => slug_from_operations(&operations, loaded.is_empty()),
416    };
417    let deps = graph
418        .as_ref()
419        .and_then(|g| g.order.last().cloned())
420        .into_iter()
421        .collect();
422    let id = next_id(&loaded, &slug);
423    let migration = Migration::new(id, deps, operations, true, Vec::new())?;
424    loader::write_migration(dir, &migration)?;
425    Ok(Some(migration))
426}