Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text|json>]` - 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).
Expand Down
6 changes: 6 additions & 0 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text|json>]`
: 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[=<mode>]] [--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
Expand Down
11 changes: 9 additions & 2 deletions spec/cli-migration-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
15 changes: 13 additions & 2 deletions spec/library-config-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
13 changes: 13 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.")
Expand Down
12 changes: 12 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, Box<dyn std::error::Error>>;
Expand Down Expand Up @@ -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::<String>("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()?;
Expand Down
142 changes: 142 additions & 0 deletions src/status.rs
Original file line number Diff line number Diff line change
@@ -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<StatusRow>,
}

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<StatusRow> = 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<String> {
serde_json::to_string_pretty(self)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn rows() -> Vec<StatusRow> {
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);
}
}
61 changes: 61 additions & 0 deletions tests/migrant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,67 @@ 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 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 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}_").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()
.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);
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,
// before touching the database
#[test]
Expand Down
Loading