From 05a994c337d0e66d62d82af386a151f1d2342590 Mon Sep 17 00:00:00 2001 From: James Kominick Date: Sun, 19 Jul 2026 14:45:44 -0400 Subject: [PATCH 1/3] add migration status command with text and json output - `migrant status [--format ]` reports every managed migration's applied/pending state with summary counts, as pretty text (default) or JSON - render via a serializable report in `src/status.rs`, unit + integration tested - document the `SettingsFileInitializer` in the library spec --- CHANGELOG.md | 2 + Cargo.lock | 2 + Cargo.toml | 3 + README.md | 2 + docs/src/cli.md | 6 ++ spec/cli-migration-management.md | 11 ++- spec/library-config-api.md | 15 +++- src/cli.rs | 13 +++ src/main.rs | 12 +++ src/status.rs | 142 +++++++++++++++++++++++++++++++ tests/migrant.rs | 55 ++++++++++++ 11 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 src/status.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 470415c..7705b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [1.0.0-rc.2] ### Added +- `migrant status` reports every managed migration's applied/pending state with summary counts, + as pretty text (`--format text`, the default) or JSON (`--format json`) for scripting - `apply` and `redo` accept `--no-sync` to disable the cross-process advisory lock, for when migration runs are serialized by an external mechanism. On `redo` it applies to both the down and up runs diff --git a/Cargo.lock b/Cargo.lock index 1583b70..e993336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1587,6 +1587,8 @@ dependencies = [ "ratatui", "rusqlite", "self_update", + "serde", + "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 95822bf..3883d36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ crossterm = "0.29" dotenvy = "0.15" ratatui = "0.30" self_update = "0.44" +serde_json = "1" assert_cmd = "2" predicates = "3" @@ -65,6 +66,8 @@ migrant_lib = { workspace = true } ratatui = { workspace = true } rusqlite = { workspace = true, optional = true, features = ["bundled"] } self_update = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/README.md b/README.md index a8aa09d..b8c5ec1 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ When run interactively (without `--no-confirm`), `setup` will be run automatical `migrant list` - Display all available .sql files and mark those applied. +`migrant status [--format ]` - Report every managed migration's applied/pending state with summary counts, as pretty text (default) or JSON. + `migrant apply [--down, --all, --force, --fake, --no-sync]` - Apply the next available migration[s]. `migrant redo [--all, --force, --no-sync]` - Re-apply the latest migration (down then up). diff --git a/docs/src/cli.md b/docs/src/cli.md index 4fd43ad..ac0557d 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -36,6 +36,12 @@ run from anywhere inside the project; migrant searches upward for the config. `migrant list` : List available migrations and mark those applied. +`migrant status [--format ]` +: Report every managed migration with its applied/pending state and summary + counts. `--format text` (the default) prints a summary line plus a `[✓]`/`[ ]` + row per migration; `--format json` prints the same data as JSON + (`{ total, applied, pending, migrations: [{ tag, applied }] }`) for scripting. + `migrant apply [--down] [--all] [--force[=]] [--fake] [--no-sync]` : Apply the next migration. `--down` reverts instead of applying. `--all` runs every remaining migration in the chosen direction. `--force` continues past a diff --git a/spec/cli-migration-management.md b/spec/cli-migration-management.md index 806b959..3953a91 100644 --- a/spec/cli-migration-management.md +++ b/spec/cli-migration-management.md @@ -30,6 +30,13 @@ it on the next run. `migrant redo` unapplies then reapplies the latest migration (`--down` then up); `--all` redoes all applied migrations. Down-migrations run in reverse application order. +## CLIMIG-6 + +`migrant status` reports every managed migration with its applied/pending state plus summary +counts (total, applied, pending). `--format text` (the default) prints a summary line followed +by a `[✓]`/`[ ]` row per migration; `--format json` prints the same data as pretty-printed JSON +(`{ total, applied, pending, migrations: [{ tag, applied }] }`) for scripting. + Coverage: `tests/migrant.rs` (kitchen_sink, new_rejects_invalid_tag, -apply_fake_records_without_running, force_modes_through_the_cli), backend integration tests, -unit tests in `migrant_lib/src/ops.rs`. +apply_fake_records_without_running, force_modes_through_the_cli, status_reports_text_and_json), +backend integration tests, unit tests in `migrant_lib/src/ops.rs` and `src/status.rs`. diff --git a/spec/library-config-api.md b/spec/library-config-api.md index e06c284..0cc6e58 100644 --- a/spec/library-config-api.md +++ b/spec/library-config-api.md @@ -30,5 +30,16 @@ library-managed migrations interoperate with CLI-created ones, and returns `&mut so it can be chained onto construction before `use_migrations`/`reload`; `is_cli_compatible()` reports the current mode. -Coverage: `migrant_lib/tests/sqlite.rs`, `server_dbs.rs`, `reload_memory.rs`; unit tests in -`migrant_lib/src/tags.rs`. +## LIBRAR-6 + +`Config::init_in(dir)` returns a `SettingsFileInitializer` that writes a new `Migrant.toml` +template. Its setters take and return an owned `Self` so calls chain by value: `interactive(bool)` +(default `true`; when on, `initialize()` opens the file in `$EDITOR` and runs `setup`), +`with_env_defaults(bool)` (seed every unset value as `env:VAR`), and `with_sqlite_options` / +`with_postgres_options` / `with_mysql_options`, each taking its settings builder by value. +`initialize()` renders and writes the template. Without a database type set it either prompts +(interactive) or errors (non-interactive). + +Coverage: `tests/migrant.rs` (init_non_interactive_creates_config, +init_rejects_invalid_database_type, init --default-from-env); doc examples in +`migrant_lib/src/config/init.rs`. diff --git a/src/cli.rs b/src/cli.rs index 597e4f3..f159f5a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -107,6 +107,19 @@ pub fn build_cli() -> Command { .subcommand( Command::new("list").about("List status of applied and available migrations"), ) + .subcommand( + Command::new("status") + .about("Report the applied/pending status of every managed migration") + .arg( + Arg::new("format") + .long("format") + .short('f') + .value_parser(["text", "json"]) + .default_value("text") + .value_name("format") + .help("Output format: `text` (default) or `json`"), + ), + ) .subcommand( Command::new("apply") .about("Moves up or down (applies up/down.sql) one migration. Default direction is up unless specified with `-d/--down`.") diff --git a/src/main.rs b/src/main.rs index 214aa60..bb5b119 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use migrant_lib::config::{MySqlSettingsBuilder, PostgresSettingsBuilder, SqliteS use migrant_lib::{Config, DbKind, Direction, ForceMode, Migrator}; mod cli; +mod status; mod tui; type Result = std::result::Result>; @@ -93,6 +94,17 @@ fn run(dir: &Path, matches: &clap::ArgMatches) -> Result<()> { migrant_lib::list(&config)?; } + Some(("status", matches)) => { + // load applied migrations from the database + let config = config.reload()?; + + let statuses = migrant_lib::migration_statuses(&config)?; + let report = status::StatusReport::from_statuses(&statuses); + match matches.get_one::("format").map(String::as_str) { + Some("json") => println!("{}", report.render_json()?), + _ => println!("{}", report.render_text()), + } + } Some(("new", matches)) => { // load applied migrations from the database let config = config.reload()?; diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..8125c02 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,142 @@ +//! `migrant status`: report the state of every managed migration in either a +//! human-readable text form or machine-readable JSON. +//! +//! The rendering is factored out of `main` into pure functions over a +//! serializable [`StatusReport`] so both formats are unit-testable without a +//! live database. + +use migrant_lib::MigrationStatus; +use serde::Serialize; + +/// A single migration's tag and whether it is currently applied. +#[derive(Debug, Clone, Serialize)] +pub struct StatusRow { + pub tag: String, + pub applied: bool, +} + +/// The full migration-table status: per-migration rows plus summary counts. +#[derive(Debug, Clone, Serialize)] +pub struct StatusReport { + pub total: usize, + pub applied: usize, + pub pending: usize, + pub migrations: Vec, +} + +impl StatusReport { + /// Build a report from the library's migration statuses, computing the + /// summary counts. + pub fn from_statuses(statuses: &[MigrationStatus]) -> Self { + let migrations: Vec = statuses + .iter() + .map(|s| StatusRow { + tag: s.tag().to_string(), + applied: s.applied(), + }) + .collect(); + let applied = migrations.iter().filter(|r| r.applied).count(); + StatusReport { + total: migrations.len(), + applied, + pending: migrations.len() - applied, + migrations, + } + } + + /// Render the report as human-readable text: a summary line followed by one + /// `[✓]`/`[ ]` row per migration. + pub fn render_text(&self) -> String { + let mut out = format!( + "Migration status: {} applied, {} pending ({} total)", + self.applied, self.pending, self.total + ); + for row in &self.migrations { + out.push_str(&format!( + "\n [{}] {}", + if row.applied { '✓' } else { ' ' }, + row.tag + )); + } + out + } + + /// Render the report as pretty-printed JSON. + pub fn render_json(&self) -> serde_json::Result { + serde_json::to_string_pretty(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec { + vec![ + StatusRow { + tag: "20170812145327_initial".to_string(), + applied: true, + }, + StatusRow { + tag: "20171126194042_second".to_string(), + applied: false, + }, + ] + } + + fn report() -> StatusReport { + let migrations = rows(); + let applied = migrations.iter().filter(|r| r.applied).count(); + StatusReport { + total: migrations.len(), + applied, + pending: migrations.len() - applied, + migrations, + } + } + + #[test] + fn counts_reflect_rows() { + let r = report(); + assert_eq!(r.total, 2); + assert_eq!(r.applied, 1); + assert_eq!(r.pending, 1); + } + + #[test] + fn text_has_summary_and_a_row_per_migration() { + let text = report().render_text(); + assert!( + text.starts_with("Migration status: 1 applied, 1 pending (2 total)"), + "unexpected summary line: {text}" + ); + assert!(text.contains("[✓] 20170812145327_initial")); + assert!(text.contains("[ ] 20171126194042_second")); + // one summary line + one line per migration + assert_eq!(text.lines().count(), 3); + } + + #[test] + fn empty_report_is_summary_only() { + let r = StatusReport { + total: 0, + applied: 0, + pending: 0, + migrations: vec![], + }; + let text = r.render_text(); + assert_eq!(text, "Migration status: 0 applied, 0 pending (0 total)"); + } + + #[test] + fn json_round_trips_to_the_documented_shape() { + let json = report().render_json().unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["total"], 2); + assert_eq!(value["applied"], 1); + assert_eq!(value["pending"], 1); + assert_eq!(value["migrations"][0]["tag"], "20170812145327_initial"); + assert_eq!(value["migrations"][0]["applied"], true); + assert_eq!(value["migrations"][1]["applied"], false); + } +} diff --git a/tests/migrant.rs b/tests/migrant.rs index ef16525..57a82ba 100644 --- a/tests/migrant.rs +++ b/tests/migrant.rs @@ -91,6 +91,61 @@ fn kitchen_sink() { let _ = migrant().args(["apply", "-ad"]).assert(); } +// CLIMIG-6: `status` reports every managed migration in text and json. +#[test] +fn status_reports_text_and_json() { + let dir = sqlite_project(); + migrant() + .current_dir(dir.path()) + .arg("setup") + .assert() + .success(); + new_migration( + dir.path(), + "first", + "create table status_a (x integer);", + "drop table status_a;", + ); + new_migration( + dir.path(), + "second", + "create table status_b (x integer);", + "drop table status_b;", + ); + + // apply only the first migration so we have one applied, one pending + migrant() + .current_dir(dir.path()) + .arg("apply") + .assert() + .success(); + + // default (text) format: summary line plus a marked row per migration + migrant() + .current_dir(dir.path()) + .arg("status") + .assert() + .success() + .stdout(contains("Migration status: 1 applied, 1 pending (2 total)")) + .stdout(predicates::str::is_match(r"\[✓\] \d{14}_first").expect("valid regex")) + .stdout(predicates::str::is_match(r"\[ \] \d{14}_second").expect("valid regex")); + + // json format is valid and carries the same counts + let out = migrant() + .current_dir(dir.path()) + .args(["status", "--format", "json"]) + .assert() + .success(); + let stdout = String::from_utf8(out.get_output().stdout.clone()).expect("utf8 stdout"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json"); + assert_eq!(value["total"], 2); + assert_eq!(value["applied"], 1); + assert_eq!(value["pending"], 1); + assert_eq!(value["migrations"].as_array().expect("array").len(), 2); + assert_eq!(value["migrations"][0]["applied"], true); + assert_eq!(value["migrations"][1]["applied"], false); +} + // TUI-1: with stdout piped (not a terminal) the tui refuses to start, // before touching the database #[test] From 52112ba9eef2307557fc9d6dfaeb137518876963 Mon Sep 17 00:00:00 2001 From: James Kominick Date: Sun, 19 Jul 2026 14:58:10 -0400 Subject: [PATCH 2/3] make status test order-independent for same-second tags --- tests/migrant.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/migrant.rs b/tests/migrant.rs index 57a82ba..106f59d 100644 --- a/tests/migrant.rs +++ b/tests/migrant.rs @@ -113,22 +113,25 @@ fn status_reports_text_and_json() { "drop table status_b;", ); - // apply only the first migration so we have one applied, one pending + // apply one migration so we have one applied, one pending. The two `new` + // migrations can share a timestamp (created in the same second), so their + // order is not guaranteed; assert on the mixed state and counts, not on + // which specific tag ends up applied. migrant() .current_dir(dir.path()) .arg("apply") .assert() .success(); - // default (text) format: summary line plus a marked row per migration + // default (text) format: summary line plus one applied and one pending row migrant() .current_dir(dir.path()) .arg("status") .assert() .success() .stdout(contains("Migration status: 1 applied, 1 pending (2 total)")) - .stdout(predicates::str::is_match(r"\[✓\] \d{14}_first").expect("valid regex")) - .stdout(predicates::str::is_match(r"\[ \] \d{14}_second").expect("valid regex")); + .stdout(predicates::str::is_match(r"\[✓\] \d{14}_").expect("valid regex")) + .stdout(predicates::str::is_match(r"\[ \] \d{14}_").expect("valid regex")); // json format is valid and carries the same counts let out = migrant() @@ -141,9 +144,15 @@ fn status_reports_text_and_json() { assert_eq!(value["total"], 2); assert_eq!(value["applied"], 1); assert_eq!(value["pending"], 1); - assert_eq!(value["migrations"].as_array().expect("array").len(), 2); - assert_eq!(value["migrations"][0]["applied"], true); - assert_eq!(value["migrations"][1]["applied"], false); + let migrations = value["migrations"].as_array().expect("array"); + assert_eq!(migrations.len(), 2); + assert_eq!( + migrations + .iter() + .filter(|m| m["applied"] == true) + .count(), + 1 + ); } // TUI-1: with stdout piped (not a terminal) the tui refuses to start, From 19dded50120601476935d094ead6005b6a79fad0 Mon Sep 17 00:00:00 2001 From: James Kominick Date: Sun, 19 Jul 2026 15:00:16 -0400 Subject: [PATCH 3/3] cargo fmt --- tests/migrant.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/migrant.rs b/tests/migrant.rs index 106f59d..4b8a426 100644 --- a/tests/migrant.rs +++ b/tests/migrant.rs @@ -147,10 +147,7 @@ fn status_reports_text_and_json() { let migrations = value["migrations"].as_array().expect("array"); assert_eq!(migrations.len(), 2); assert_eq!( - migrations - .iter() - .filter(|m| m["applied"] == true) - .count(), + migrations.iter().filter(|m| m["applied"] == true).count(), 1 ); }