use std::io::Write;
use std::path::Path;
use crate::sql::sqlx::PgPool;
use super::error::MigrateError;
use super::file::{self, Migration, Operation};
use super::make::make_migrations;
use super::runner;
use super::snapshot::SchemaSnapshot;
pub async fn run(
pool: &PgPool,
dir: &Path,
args: impl IntoIterator<Item = String>,
) -> Result<(), MigrateError> {
let mut stdout = std::io::stdout();
run_with_writer(pool, dir, args, &mut stdout).await
}
pub async fn run_with_writer<W: Write + Send>(
pool: &PgPool,
dir: &Path,
args: impl IntoIterator<Item = String>,
writer: &mut W,
) -> Result<(), MigrateError> {
let args: Vec<String> = args.into_iter().collect();
let cmd = args.first().map_or("", String::as_str);
match cmd {
"" | "--help" | "-h" | "help" => {
print_help(writer)?;
Ok(())
}
"makemigrations" => makemigrations(dir, &args[1..], writer),
"migrate" => migrate(pool, dir, &args[1..], writer).await,
"downgrade" => downgrade(pool, dir, &args[1..], writer).await,
"showmigrations" | "status" => showmigrations(pool, dir, writer).await,
"startapp" => startapp(&args[1..], writer),
other => Err(MigrateError::Validation(format!(
"unknown subcommand: `{other}` (run with --help for usage)"
))),
}
}
fn print_help<W: Write>(w: &mut W) -> std::io::Result<()> {
writeln!(w, "rustango::manage — Django-style migration runner\n")?;
writeln!(w, "USAGE:")?;
writeln!(w, " manage <COMMAND> [args]\n")?;
writeln!(w, "COMMANDS:")?;
writeln!(w, " makemigrations [name]")?;
writeln!(
w,
" Diff the inventory registry against the latest snapshot"
)?;
writeln!(
w,
" and write the next migration file. `name` overrides the"
)?;
writeln!(w, " auto-derived suffix.\n")?;
writeln!(w, " makemigrations --empty <name>")?;
writeln!(
w,
" Write an empty migration scaffold (`forward: []`) for"
)?;
writeln!(
w,
" hand-authored data migrations. Edit the JSON to add"
)?;
writeln!(w, " `data` ops with sql + reverse_sql.\n")?;
writeln!(w, " migrate")?;
writeln!(w, " Apply every pending migration in lex order.\n")?;
writeln!(w, " migrate <target>")?;
writeln!(
w,
" Forward or back to <target>. `zero` unapplies every"
)?;
writeln!(w, " applied migration.\n")?;
writeln!(w, " migrate --dry-run")?;
writeln!(
w,
" Print the SQL each pending migration would run; never"
)?;
writeln!(w, " writes. Reads the ledger so the preview is accurate.\n")?;
writeln!(w, " downgrade [N]")?;
writeln!(
w,
" Step back N applied migrations (default 1).\n"
)?;
writeln!(w, " showmigrations | status")?;
writeln!(w, " List migrations with [X]/[ ] applied marker.\n")?;
writeln!(w, " startapp <name> [--with-manage-bin]")?;
writeln!(
w,
" Scaffold a Django-shape app module under src/<name>/"
)?;
writeln!(
w,
" (models.rs + views.rs + urls.rs + mod.rs). Idempotent;"
)?;
writeln!(
w,
" existing files are left untouched. With --with-manage-bin,"
)?;
writeln!(w, " also writes src/bin/manage.rs.")?;
Ok(())
}
fn makemigrations<W: Write>(
dir: &Path,
args: &[String],
w: &mut W,
) -> Result<(), MigrateError> {
let mut empty = false;
let mut name: Option<String> = None;
for arg in args {
match arg.as_str() {
"--empty" => empty = true,
"--help" | "-h" => {
writeln!(
w,
"makemigrations [name] generate next migration\n\
makemigrations --empty <name> empty scaffold for data ops"
)?;
return Ok(());
}
other if other.starts_with('-') => {
return Err(MigrateError::Validation(format!("unknown flag: {other}")));
}
other => {
if name.is_some() {
return Err(MigrateError::Validation(format!(
"unexpected positional argument: {other}"
)));
}
name = Some(other.to_owned());
}
}
}
if empty {
let Some(n) = name else {
return Err(MigrateError::Validation(
"makemigrations --empty requires a name".into(),
));
};
let mig = make_empty(dir, &n)?;
writeln!(
w,
"wrote {} (empty scaffold — fill in `forward` with data ops)",
file_path(dir, &mig.name).display()
)?;
return Ok(());
}
match make_migrations(dir, name.as_deref())? {
Some(mig) => {
writeln!(w, "wrote {}", file_path(dir, &mig.name).display())?;
for op in &mig.forward {
writeln!(w, " + {}", describe_op(op))?;
}
}
None => writeln!(w, "no changes — registry matches latest snapshot")?,
}
Ok(())
}
async fn migrate<W: Write>(
pool: &PgPool,
dir: &Path,
args: &[String],
w: &mut W,
) -> Result<(), MigrateError> {
let mut dry_run = false;
let mut positional: Option<&str> = None;
for arg in args {
match arg.as_str() {
"--dry-run" => dry_run = true,
"--help" | "-h" => {
writeln!(
w,
"migrate apply pending migrations\n\
migrate <target> forward or back to <target> (`zero` wipes)\n\
migrate --dry-run preview the SQL without writing"
)?;
return Ok(());
}
other if other.starts_with('-') => {
return Err(MigrateError::Validation(format!("unknown flag: {other}")));
}
other => {
if positional.is_some() {
return Err(MigrateError::Validation(format!(
"unexpected positional argument: {other}"
)));
}
positional = Some(other);
}
}
}
if dry_run {
if positional.is_some() {
return Err(MigrateError::Validation(
"`migrate <target> --dry-run` is not supported in v0.4 — use plain `--dry-run` to preview pending forward migrations".into(),
));
}
let preview = runner::migrate_dry_run(pool, dir).await?;
if preview.is_empty() {
writeln!(w, "nothing to migrate (already up to date)")?;
} else {
writeln!(
w,
"-- DRY RUN: {} pending migration(s); no SQL will be executed",
preview.len()
)?;
for p in &preview {
writeln!(w)?;
writeln!(
w,
"-- {} ({})",
p.name,
if p.atomic { "atomic" } else { "non-atomic" }
)?;
for stmt in &p.statements {
writeln!(w, "{stmt};")?;
}
}
}
return Ok(());
}
if let Some(target) = positional {
let touched = runner::migrate_to(pool, dir, target).await?;
if touched.is_empty() {
writeln!(w, "already at {target}")?;
} else {
for m in &touched {
writeln!(w, " touched {}", m.name)?;
}
}
return Ok(());
}
let applied = runner::migrate(pool, dir).await?;
if applied.is_empty() {
writeln!(w, "nothing to migrate (already up to date)")?;
} else {
for m in &applied {
writeln!(w, " applied {}", m.name)?;
}
}
Ok(())
}
async fn downgrade<W: Write>(
pool: &PgPool,
dir: &Path,
args: &[String],
w: &mut W,
) -> Result<(), MigrateError> {
let steps: usize = if let Some(arg) = args.first() {
arg.parse().map_err(|_| {
MigrateError::Validation(format!(
"invalid step count: {arg} (expected a non-negative integer)"
))
})?
} else {
1
};
let touched = runner::downgrade(pool, dir, steps).await?;
if touched.is_empty() {
writeln!(w, "nothing to downgrade")?;
} else {
for m in &touched {
writeln!(w, " rolled back {}", m.name)?;
}
}
Ok(())
}
async fn showmigrations<W: Write>(
pool: &PgPool,
dir: &Path,
w: &mut W,
) -> Result<(), MigrateError> {
runner::ensure_ledger(pool).await?;
let all = file::list_dir(dir)?;
let applied = runner::applied_set(pool).await?;
if all.is_empty() {
writeln!(w, "(no migrations in {})", dir.display())?;
return Ok(());
}
writeln!(w, "Migrations in {}:", dir.display())?;
for m in &all {
let mark = if applied.contains(&m.name) {
"[X]"
} else {
"[ ]"
};
writeln!(w, " {mark} {}", m.name)?;
}
Ok(())
}
pub fn make_empty(dir: &Path, name: &str) -> Result<Migration, MigrateError> {
let prior = file::list_dir(dir)?;
let prev_snapshot = prior
.last()
.map_or_else(|| SchemaSnapshot { tables: vec![] }, |m| m.snapshot.clone());
let prev_name = prior.last().map(|m| m.name.clone());
let next_index = prior
.last()
.and_then(|m| file::extract_index(&m.name))
.map_or(1, |n| n + 1);
let full_name = format!("{next_index:04}_{name}");
let mig = Migration {
name: full_name.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
prev: prev_name,
atomic: true,
scope: super::MigrationScope::default(),
snapshot: prev_snapshot,
forward: vec![],
};
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
file::write(&file_path(dir, &mig.name), &mig)?;
Ok(mig)
}
fn file_path(dir: &Path, name: &str) -> std::path::PathBuf {
dir.join(format!("{name}.json"))
}
fn describe_op(op: &Operation) -> String {
match op {
Operation::Schema(c) => format!("{c:?}"),
Operation::Data(d) => {
let head: String = d.sql.chars().take(60).collect();
let ellipsis = if d.sql.chars().count() > 60 {
"…"
} else {
""
};
format!("data: {head}{ellipsis}")
}
}
}
fn startapp<W: Write>(args: &[String], w: &mut W) -> Result<(), MigrateError> {
let mut iter = args.iter();
let app_name = iter
.next()
.cloned()
.ok_or_else(|| MigrateError::Validation(usage()))?;
let mut with_manage_bin = false;
for arg in iter {
match arg.as_str() {
"--with-manage-bin" => with_manage_bin = true,
"--help" | "-h" => {
writeln!(w, "{}", usage())?;
return Ok(());
}
other => {
return Err(MigrateError::Validation(format!(
"startapp: unknown argument `{other}` (run --help for usage)"
)));
}
}
}
let opts = super::scaffold::StartAppOptions {
app_name: app_name.clone(),
manage_bin: with_manage_bin.then_some(super::scaffold::SINGLE_TENANT_MANAGE_BIN),
};
let cwd = std::env::current_dir()?;
let report = super::scaffold::startapp(&cwd, &opts)?;
write_startapp_report(w, &app_name, &report)
}
fn write_startapp_report<W: Write>(
w: &mut W,
app_name: &str,
report: &super::scaffold::StartAppReport,
) -> Result<(), MigrateError> {
if report.written.is_empty() && report.skipped.is_empty() {
writeln!(w, "startapp: nothing to do")?;
return Ok(());
}
writeln!(w, "startapp `{app_name}`")?;
for path in &report.written {
writeln!(w, " + wrote {path}")?;
}
for path in &report.skipped {
writeln!(w, " · {path} already exists — left untouched")?;
}
if !report.written.is_empty() {
writeln!(w, "next:")?;
writeln!(
w,
" add `mod {app_name};` to src/main.rs (or src/lib.rs) so the"
)?;
writeln!(
w,
" derive macros' `inventory` registrations are pulled in."
)?;
}
Ok(())
}
fn usage() -> String {
"startapp <name> [--with-manage-bin]\n \
Scaffold a Django-shape app module under src/<name>/ (mod.rs +\n \
models.rs + views.rs + urls.rs). Idempotent: existing files\n \
are left untouched. <name> must be a valid Rust identifier.\n\n \
--with-manage-bin\n \
Also write src/bin/manage.rs with the single-tenant dispatcher\n \
boilerplate. Skipped if the file already exists."
.to_owned()
}