pub mod diff;
pub mod doctor;
pub mod introspect;
pub mod migrate;
pub mod parse;
pub mod snapshot;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use autumn_schema_core::{Backend, Column, Table};
use diff::{MigrationPlan, SchemaChange};
use parse::parse_models_path;
use snapshot::{SNAPSHOT_DEFAULT_PATH, SchemaSnapshot};
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub enum BackendArg {
#[value(alias = "postgres")]
Pg,
Sqlite,
}
impl From<BackendArg> for Backend {
fn from(arg: BackendArg) -> Self {
match arg {
BackendArg::Pg => Self::Postgres,
BackendArg::Sqlite => Self::Sqlite,
}
}
}
#[derive(clap::Subcommand, Debug)]
pub enum SchemaAction {
Parse {
path: std::path::PathBuf,
},
Snapshot {
#[arg(long, value_name = "PATH")]
from: Option<PathBuf>,
#[arg(long, value_name = "PATH", conflicts_with = "stdout")]
out: Option<PathBuf>,
#[arg(long, value_enum)]
backend: Option<BackendArg>,
#[arg(long)]
stdout: bool,
},
Diff {
#[arg(long, value_name = "PATH")]
from: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
snapshot: Option<PathBuf>,
#[arg(long, value_enum)]
backend: Option<BackendArg>,
#[arg(long)]
write_migration: bool,
#[arg(long, value_name = "NAME")]
name: Option<String>,
#[arg(long)]
allow_destructive: bool,
},
Pull {
#[arg(long, value_name = "PROFILE")]
profile: Option<String>,
#[arg(long, value_name = "PATH")]
out: Option<PathBuf>,
#[arg(long, value_enum)]
backend: Option<BackendArg>,
#[arg(long)]
dry_run: bool,
},
Migrate {
#[arg(long, value_name = "PROFILE")]
profile: Option<String>,
},
Doctor {
#[arg(long, value_name = "PROFILE")]
profile: Option<String>,
#[arg(long)]
json: bool,
},
}
pub fn run(action: SchemaAction) {
let result = match action {
SchemaAction::Parse { path } => run_parse(&path).map(|json| println!("{json}")),
SchemaAction::Snapshot {
from,
out,
backend,
stdout,
} => run_snapshot(from.as_deref(), out.as_deref(), backend, stdout),
SchemaAction::Diff {
from,
snapshot,
backend,
write_migration,
name,
allow_destructive,
} => run_diff(
from.as_deref(),
snapshot.as_deref(),
backend,
write_migration,
name.as_deref(),
allow_destructive,
),
SchemaAction::Pull {
profile,
out,
backend,
dry_run,
} => run_pull(profile.as_deref(), out.as_deref(), backend, dry_run),
SchemaAction::Migrate { profile } => migrate::run_migrate(profile.as_deref()),
SchemaAction::Doctor { profile, json } => doctor::run_doctor(profile.as_deref(), json),
};
if let Err(message) = result {
eprintln!("error: {message}");
std::process::exit(1);
}
}
const fn map_detected_backend(detected: autumn_web::config::DatabaseBackend) -> Backend {
match detected {
autumn_web::config::DatabaseBackend::Postgres => Backend::Postgres,
autumn_web::config::DatabaseBackend::Sqlite => Backend::Sqlite,
}
}
fn backend_for_url(project_root: &Path, profile: Option<&str>, url: Option<&str>) -> Backend {
url.and_then(autumn_web::config::DatabaseBackend::detect)
.map_or_else(
|| {
map_detected_backend(crate::generate::detect_backend_for_profile(
project_root,
profile,
))
},
map_detected_backend,
)
}
fn resolve_default_models_path(project_root: &Path) -> Result<PathBuf, String> {
let dir = project_root.join("src").join("models");
if dir.is_dir() {
return Ok(dir);
}
let file = project_root.join("src").join("models.rs");
if file.is_file() {
return Ok(file);
}
Err(format!(
"no models found at {} or {} — pass --from to point at your models file or directory",
dir.display(),
file.display()
))
}
fn existing_models_path(project_root: &Path) -> Option<PathBuf> {
let dir = project_root.join("src").join("models");
if dir.is_dir() {
return Some(dir);
}
let file = project_root.join("src").join("models.rs");
if file.is_file() {
return Some(file);
}
None
}
const fn needs_project_root(
from: Option<&Path>,
out: Option<&Path>,
stdout: bool,
backend: Option<BackendArg>,
) -> bool {
from.is_none() || (out.is_none() && !stdout) || backend.is_none()
}
fn project_root_required_error(backend_is_a_reason: bool) -> String {
if backend_is_a_reason {
"cannot auto-detect the database backend outside the project root — \
run from the project root or pass --backend pg|sqlite"
.to_string()
} else {
crate::generate::GenerateError::NotInProject.to_string()
}
}
fn run_snapshot(
from: Option<&Path>,
out: Option<&Path>,
backend: Option<BackendArg>,
stdout: bool,
) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
let backend_given = backend.is_some();
if needs_project_root(from, out, stdout, backend)
&& crate::generate::ensure_project_root(&project_root).is_err()
{
return Err(project_root_required_error(!backend_given));
}
let models_path = match from {
Some(path) => path.to_path_buf(),
None => resolve_default_models_path(&project_root)?,
};
let backend = backend.map_or_else(
|| map_detected_backend(crate::generate::detect_backend(&project_root)),
Backend::from,
);
let parsed = parse_models_path(&models_path, backend).map_err(|e| e.to_string())?;
for diag in &parsed.diagnostics {
eprintln!("warning: {}", diag.message);
}
let snapshot = SchemaSnapshot::new(backend, parsed.tables);
if stdout {
print!("{}", snapshot::to_canonical_json(&snapshot));
return Ok(());
}
let out_path = out.map_or_else(|| PathBuf::from(SNAPSHOT_DEFAULT_PATH), Path::to_path_buf);
snapshot::write_snapshot(&out_path, &snapshot).map_err(|e| e.to_string())?;
println!(
"wrote schema snapshot for {} table(s) to {}",
snapshot.tables.len(),
out_path.display()
);
Ok(())
}
fn run_pull(
profile: Option<&str>,
out: Option<&Path>,
backend: Option<BackendArg>,
dry_run: bool,
) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
pull_at(&project_root, profile, out, backend, dry_run)
}
fn pull_at(
project_root: &Path,
profile: Option<&str>,
out: Option<&Path>,
backend: Option<BackendArg>,
dry_run: bool,
) -> Result<(), String> {
crate::generate::ensure_project_root(project_root)
.map_err(|_| crate::generate::GenerateError::NotInProject.to_string())?;
let url = crate::migrate::resolve_primary_url(profile).ok_or_else(|| {
"no database URL configured — set AUTUMN_DATABASE__URL or DATABASE_URL, or add a \
[database] primary_url to autumn.toml"
.to_string()
})?;
let backend: Backend = backend.map_or_else(
|| backend_for_url(project_root, profile, Some(url.as_str())),
Backend::from,
);
let out_path = out.map_or_else(
|| project_root.join(SNAPSHOT_DEFAULT_PATH),
Path::to_path_buf,
);
let existing = load_existing_snapshot(&out_path);
if let Some(existing) = &existing
&& existing.backend != backend
{
return Err(format!(
"refusing to overwrite the existing snapshot at {} (backend {:?}) with a {:?} pull \
— the snapshot is provider-locked to {:?}. Point --out elsewhere or remove the \
mismatched snapshot first.",
out_path.display(),
existing.backend,
backend,
existing.backend
));
}
let tables = match backend {
Backend::Postgres => introspect::introspect_postgres(&url).map_err(|e| e.to_string())?,
#[cfg(feature = "sqlite")]
Backend::Sqlite => introspect::introspect_sqlite(&url).map_err(|e| e.to_string())?,
#[cfg(not(feature = "sqlite"))]
Backend::Sqlite => {
return Err(
"`autumn schema pull` detected a SQLite backend, but this CLI build targets \
Postgres only. Rebuild with `--features sqlite` to introspect a SQLite \
database (the sqlite backend-flip must never be co-built with the default \
Postgres backend). No snapshot was written."
.to_string(),
);
}
};
let snapshot = SchemaSnapshot::new(backend, tables);
if dry_run {
report_pull_dry_run(&snapshot, existing.as_ref(), &out_path);
return Ok(());
}
if existing.is_none() && out_path.exists() {
eprintln!(
"note: existing snapshot at {} was unreadable; replacing it.",
out_path.display()
);
}
snapshot::write_snapshot(&out_path, &snapshot).map_err(|e| e.to_string())?;
println!(
"pulled schema snapshot for {} table(s) from the database to {}",
snapshot.tables.len(),
out_path.display()
);
Ok(())
}
fn load_existing_snapshot(path: &Path) -> Option<SchemaSnapshot> {
snapshot::load_snapshot(path).ok()
}
fn report_pull_dry_run(
pulled: &SchemaSnapshot,
existing: Option<&SchemaSnapshot>,
out_path: &Path,
) {
match existing {
None => {
println!(
"--dry-run: no existing snapshot at {} — would create a new baseline with {} \
table(s) from the database.",
out_path.display(),
pulled.tables.len()
);
}
Some(existing) => {
let drift = doctor::compute_db_schema_drift(&existing.tables, &pulled.tables);
print!(
"{}",
format_pull_dry_run_report(&drift.forward, &drift.reverse, out_path)
);
}
}
}
fn format_pull_dry_run_report(
forward: &MigrationPlan,
reverse: &MigrationPlan,
out_path: &Path,
) -> String {
use std::fmt::Write as _;
let removals = reverse_only_removals(reverse);
if forward.is_empty() && removals.is_empty() {
return format!(
"--dry-run: the snapshot at {} is up to date — the database matches it.\n",
out_path.display()
);
}
let mut out = String::new();
if !forward.is_empty() {
let _ = writeln!(
out,
"--dry-run: the database differs from the snapshot at {} — {} change(s) would be \
captured (nothing written):",
out_path.display(),
forward.changes.len()
);
out.push_str(&diff::describe_plan(forward));
}
if !removals.is_empty() {
let _ = writeln!(
out,
"--dry-run: pulling would also remove from the snapshot at {}: {} (nothing written).",
out_path.display(),
removals.join(", ")
);
}
out
}
fn reverse_only_removals(reverse: &MigrationPlan) -> Vec<String> {
reverse
.changes
.iter()
.filter_map(|change| match change {
SchemaChange::SetDefault {
table,
column,
from: None,
..
} => Some(format!("default on `{table}.{column}`")),
SchemaChange::AddForeignKey { table, column, .. }
| SchemaChange::AddForeignKeyToExistingColumn { table, column } => {
Some(format!("foreign key on `{table}.{column}`"))
}
SchemaChange::AddCheck { table, check } => Some(format!(
"CHECK `{}` on `{table}`",
check.name.as_deref().unwrap_or("(unnamed)")
)),
_ => None,
})
.collect()
}
fn run_diff(
from: Option<&Path>,
snapshot_path: Option<&Path>,
backend: Option<BackendArg>,
write_migration: bool,
name: Option<&str>,
allow_destructive: bool,
) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
diff_at(
&project_root,
from,
snapshot_path,
backend,
write_migration,
name,
allow_destructive,
)
}
fn diff_at(
project_root: &Path,
from: Option<&Path>,
snapshot_path: Option<&Path>,
backend: Option<BackendArg>,
write_migration: bool,
name: Option<&str>,
allow_destructive: bool,
) -> Result<(), String> {
let backend_given = backend.is_some();
if (from.is_none() || snapshot_path.is_none() || backend.is_none())
&& crate::generate::ensure_project_root(project_root).is_err()
{
return Err(project_root_required_error(!backend_given));
}
let backend: Backend = backend.map_or_else(
|| map_detected_backend(crate::generate::detect_backend(project_root)),
Backend::from,
);
let snapshot_path = snapshot_path.map_or_else(
|| project_root.join(SNAPSHOT_DEFAULT_PATH),
Path::to_path_buf,
);
let baseline = snapshot::load_snapshot(&snapshot_path).map_err(|e| match e {
snapshot::SnapshotError::Io { source, .. }
if source.kind() == std::io::ErrorKind::NotFound =>
{
format!(
"no schema snapshot at {} — run `autumn schema snapshot` first to create the diff baseline",
snapshot_path.display()
)
}
other => other.to_string(),
})?;
baseline
.ensure_backend_matches(backend)
.map_err(|e| e.to_string())?;
let models_path = match from {
Some(path) => path.to_path_buf(),
None => resolve_default_models_path(project_root)?,
};
let desired = parse_models_path(&models_path, backend).map_err(|e| e.to_string())?;
for d in &desired.diagnostics {
eprintln!("warning: {}", d.message);
}
let opts = diff::DiffOptions {
allow_destructive,
..Default::default()
};
let plan = diff::diff_schema(&baseline.tables, &desired, opts);
if plan.is_empty() {
println!("No schema changes — models match the snapshot baseline.");
return Ok(());
}
diff::guard_plan(&plan, opts).map_err(|e| e.to_string())?;
let ctx = diff::SchemaContext::from_tables(&desired.tables, &baseline.tables);
let up = diff::emit_up_sql_with_context(&plan, &ctx).map_err(|e| e.to_string())?;
let down = diff::emit_down_sql_with_context(&plan, &ctx).map_err(|e| e.to_string())?;
if !write_migration {
print!("{}", diff::describe_plan(&plan));
println!("\n-- up.sql\n{up}\n-- down.sql\n{down}");
return Ok(());
}
let ts = crate::generate::timestamp_now();
let suffix = crate::generate::naming::snake(name.unwrap_or("schema_update"));
let dir = write_migration_dir(project_root, &ts, &suffix, &up, &down)?;
let target_tables = project_plan_target(&baseline.tables, &plan);
if let Err(e) =
snapshot::write_snapshot(&snapshot_path, &SchemaSnapshot::new(backend, target_tables))
{
let _ = std::fs::remove_dir_all(&dir);
return Err(e.to_string());
}
println!(
"wrote migration {} ({} change(s))",
dir.display(),
plan.changes.len()
);
println!("advanced schema snapshot at {}", snapshot_path.display());
Ok(())
}
fn project_plan_target(baseline: &[Table], plan: &MigrationPlan) -> Vec<Table> {
let mut tables: BTreeMap<String, Table> = baseline
.iter()
.map(|t| (t.name.clone(), t.clone()))
.collect();
for change in &plan.changes {
match change {
SchemaChange::CreateTable(table) => {
tables.insert(table.name.clone(), table.clone());
}
SchemaChange::DropTable(table) => {
tables.remove(&table.name);
}
SchemaChange::AddColumn { table, column } => {
if let Some(t) = tables.get_mut(table) {
t.columns.push(column.clone());
}
}
SchemaChange::DropColumn { table, column } => {
if let Some(t) = tables.get_mut(table) {
t.columns.retain(|c| c.name != column.name);
t.indexes
.retain(|i| !diff::index_depends_on_column(i, &column.name, plan.backend));
}
}
SchemaChange::AlterColumnType {
table, column, to, ..
} => {
if let Some(c) = column_mut(&mut tables, table, column) {
c.ty = to.clone();
}
}
SchemaChange::SetNotNull { table, column } => {
if let Some(c) = column_mut(&mut tables, table, column) {
c.nullable = false;
}
}
SchemaChange::DropNotNull { table, column } => {
if let Some(c) = column_mut(&mut tables, table, column) {
c.nullable = true;
}
}
SchemaChange::SetDefault {
table, column, to, ..
} => {
if let Some(c) = column_mut(&mut tables, table, column) {
c.default = Some(to.clone());
}
}
SchemaChange::AddForeignKey {
table,
column,
foreign_key,
} => {
if let Some(c) = column_mut(&mut tables, table, column) {
c.references = Some(foreign_key.clone());
}
}
SchemaChange::AddIndex { table, index } => {
if let Some(t) = tables.get_mut(table) {
t.indexes.push(index.clone());
}
}
SchemaChange::DropIndex { table, index } => {
if let Some(t) = tables.get_mut(table) {
t.indexes.retain(|i| i.name != index.name);
}
}
SchemaChange::AddCheck { table, check } => {
if let Some(t) = tables.get_mut(table) {
t.checks.push(check.clone());
}
}
SchemaChange::PrimaryKeyChange { .. }
| SchemaChange::ForeignKeyChange { .. }
| SchemaChange::IdentityChange { .. }
| SchemaChange::DropTableBlockedByInboundFk { .. }
| SchemaChange::AlterColumnTypeBlockedByFk { .. }
| SchemaChange::AddForeignKeyToExistingColumn { .. }
| SchemaChange::CreateTableBlockedBySkippedField { .. } => {}
}
}
tables.into_values().collect()
}
fn column_mut<'a>(
tables: &'a mut BTreeMap<String, Table>,
table: &str,
column: &str,
) -> Option<&'a mut Column> {
tables
.get_mut(table)
.and_then(|t| t.columns.iter_mut().find(|c| c.name == column))
}
fn write_migration_dir(
project_root: &Path,
ts: &str,
suffix: &str,
up: &str,
down: &str,
) -> Result<std::path::PathBuf, String> {
write_migration_dir_with(project_root, ts, suffix, up, down, |path, contents| {
std::fs::write(path, contents)
})
}
fn write_migration_dir_with<W>(
project_root: &Path,
ts: &str,
suffix: &str,
up: &str,
down: &str,
write: W,
) -> Result<std::path::PathBuf, String>
where
W: Fn(&Path, &str) -> std::io::Result<()>,
{
let migrations_dir = project_root.join("migrations");
let dir = migrations_dir.join(format!("{ts}_{suffix}"));
std::fs::create_dir_all(&migrations_dir).map_err(|e| {
format!(
"failed to create migrations directory {}: {e}",
migrations_dir.display()
)
})?;
std::fs::create_dir(&dir).map_err(|e| {
if e.kind() == std::io::ErrorKind::AlreadyExists {
format!(
"refusing to overwrite existing migration directory {}; a migration with this \
timestamp and name already exists",
dir.display()
)
} else {
format!(
"failed to create migration directory {}: {e}",
dir.display()
)
}
})?;
let write_both = || -> Result<(), String> {
write(&dir.join("up.sql"), up).map_err(|e| format!("failed to write up.sql: {e}"))?;
write(&dir.join("down.sql"), down).map_err(|e| format!("failed to write down.sql: {e}"))?;
Ok(())
};
if let Err(e) = write_both() {
let _ = std::fs::remove_dir_all(&dir);
return Err(e);
}
Ok(dir)
}
fn run_parse(path: &Path) -> Result<String, String> {
let backend = Backend::Postgres;
let parsed = parse_models_path(path, backend).map_err(|e| e.to_string())?;
for diag in &parsed.diagnostics {
eprintln!("warning: {}", diag.message);
}
serde_json::to_string_pretty(&parsed.tables).map_err(|e| e.to_string())
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
fn scaffold_project(models: &str, snapshot_json: &str) -> tempfile::TempDir {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("Cargo.toml"), "[package]\nname=\"x\"\n")
.expect("Cargo.toml");
let src = root.path().join("src");
std::fs::create_dir_all(&src).expect("mkdir src");
std::fs::write(src.join("models.rs"), models).expect("models.rs");
let snap = root.path().join(".autumn");
std::fs::create_dir_all(&snap).expect("mkdir .autumn");
std::fs::write(snap.join("schema-snapshot.json"), snapshot_json).expect("snapshot");
root
}
const POST_MODEL: &str = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
"#;
fn posts_snapshot(backend: &str) -> String {
format!(
r#"{{
"snapshot_version": 1,
"backend": "{backend}",
"tables": [
{{
"name": "posts",
"columns": [
{{ "name": "id", "ty": "Int64", "nullable": false, "primary_key": true, "unique": false, "default": null, "references": null, "serial": "BigSerial" }},
{{ "name": "title", "ty": "Text", "nullable": false, "primary_key": false, "unique": false, "default": null, "references": null }},
{{ "name": "created_at", "ty": "Timestamp", "nullable": false, "primary_key": false, "unique": false, "default": "Now", "references": null }}
],
"primary_key": ["id"],
"indexes": [],
"checks": [],
"backend": "{backend}",
"managed": true
}}
]
}}
"#
)
}
#[test]
fn diff_backend_mismatch_errors_before_diff() {
let root = scaffold_project(POST_MODEL, &posts_snapshot("Sqlite"));
let err = diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
false,
None,
false,
)
.unwrap_err();
assert!(
err.contains("does not match") && err.to_lowercase().contains("backend"),
"backend mismatch surfaced: {err}"
);
}
#[test]
fn diff_missing_snapshot_is_friendly_error() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join("Cargo.toml"), "[package]\nname=\"x\"\n")
.expect("Cargo.toml");
let src = root.path().join("src");
std::fs::create_dir_all(&src).expect("mkdir");
std::fs::write(src.join("models.rs"), POST_MODEL).expect("models.rs");
let err = diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
false,
None,
false,
)
.unwrap_err();
assert!(
err.contains("autumn schema snapshot"),
"friendly 'run snapshot first' message: {err}"
);
}
#[test]
fn diff_no_op_writes_nothing() {
let root = scaffold_project(POST_MODEL, &posts_snapshot("Postgres"));
let snapshot_path = root.path().join(".autumn/schema-snapshot.json");
let before = std::fs::read_to_string(&snapshot_path).expect("snapshot before");
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
None,
false,
)
.expect("no-op diff ok");
assert!(
!root.path().join("migrations").exists(),
"a no-op must not create a migrations directory"
);
let after = std::fs::read_to_string(&snapshot_path).expect("snapshot after");
assert_eq!(
before, after,
"a no-op diff must leave the snapshot byte-for-byte unchanged"
);
}
#[test]
fn write_migration_creates_up_and_down() {
let models = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub body: Option<String>,
}
"#;
let root = scaffold_project(models, &posts_snapshot("Postgres"));
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
Some("add_body"),
false,
)
.expect("write migration ok");
let migrations = root.path().join("migrations");
let entries: Vec<_> = std::fs::read_dir(&migrations)
.expect("read migrations dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries.len(), 1, "one migration dir: {entries:?}");
assert!(
entries[0].ends_with("_add_body"),
"named suffix: {entries:?}"
);
let dir = migrations.join(&entries[0]);
let up = std::fs::read_to_string(dir.join("up.sql")).expect("up.sql");
let down = std::fs::read_to_string(dir.join("down.sql")).expect("down.sql");
assert!(
up.contains("ALTER TABLE posts ADD COLUMN body TEXT NULL;"),
"up: {up}"
);
assert!(
down.contains("ALTER TABLE posts DROP COLUMN body;"),
"down: {down}"
);
let snapshot_path = root.path().join(".autumn/schema-snapshot.json");
let advanced = snapshot::load_snapshot(&snapshot_path).expect("load advanced snapshot");
let posts = advanced
.tables
.iter()
.find(|t| t.name == "posts")
.expect("posts table in advanced snapshot");
assert!(
posts.columns.iter().any(|c| c.name == "body"),
"advanced snapshot must contain the new `body` column: {:?}",
posts.columns.iter().map(|c| &c.name).collect::<Vec<_>>()
);
}
#[cfg(unix)]
#[test]
fn write_migration_rolls_back_on_snapshot_write_failure() {
use std::os::unix::fs::PermissionsExt;
let models = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub body: Option<String>,
}
"#;
let root = scaffold_project(models, &posts_snapshot("Postgres"));
let autumn_dir = root.path().join(".autumn");
std::fs::set_permissions(&autumn_dir, std::fs::Permissions::from_mode(0o555))
.expect("chmod .autumn read-only");
let can_still_write = tempfile::NamedTempFile::new_in(&autumn_dir).is_ok();
if can_still_write {
std::fs::set_permissions(&autumn_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore chmod");
return;
}
let result = diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
Some("add_body"),
false,
);
std::fs::set_permissions(&autumn_dir, std::fs::Permissions::from_mode(0o755))
.expect("restore chmod");
assert!(
result.is_err(),
"a snapshot-write failure must surface as an error, got: {result:?}"
);
let migrations = root.path().join("migrations");
let leftover: Vec<_> = std::fs::read_dir(&migrations)
.map(|rd| {
rd.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
assert!(
leftover.is_empty(),
"the migration dir must be rolled back on a snapshot-write failure, found: {leftover:?}"
);
}
#[test]
#[allow(clippy::too_many_lines)] fn write_migration_advances_snapshot_so_later_edits_diff_on_top() {
let models_a = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub body: Option<String>,
}
"#;
let root = scaffold_project(models_a, &posts_snapshot("Postgres"));
let snapshot_path = root.path().join(".autumn/schema-snapshot.json");
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
Some("add_body"),
false,
)
.expect("write migration A ok");
let after_a = snapshot::load_snapshot(&snapshot_path).expect("load snapshot after A");
let posts_a = after_a
.tables
.iter()
.find(|t| t.name == "posts")
.expect("posts after A");
assert!(
posts_a.columns.iter().any(|c| c.name == "body"),
"snapshot advanced to include `body`"
);
assert!(
!posts_a.columns.iter().any(|c| c.name == "draft"),
"snapshot must not contain the not-yet-added `draft` column"
);
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
Some("add_body_again"),
false,
)
.expect("second (converged) diff ok");
let dir_count = std::fs::read_dir(root.path().join("migrations"))
.expect("read migrations")
.count();
assert_eq!(
dir_count, 1,
"a converged re-run must not write a second migration"
);
std::fs::write(
root.path().join("src").join("models.rs"),
r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub body: Option<String>,
pub draft: Option<String>,
}
"#,
)
.expect("edit models to add draft");
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
true,
Some("add_draft"),
false,
)
.expect("write migration B (draft) ok");
let mut names: Vec<String> = std::fs::read_dir(root.path().join("migrations"))
.expect("read migrations")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names.len(), 2, "two migration dirs now: {names:?}");
let draft_dir = names
.iter()
.find(|n| n.ends_with("_add_draft"))
.expect("add_draft dir");
let up = std::fs::read_to_string(
root.path()
.join("migrations")
.join(draft_dir)
.join("up.sql"),
)
.expect("draft up.sql");
assert!(
up.contains("draft"),
"the second migration still generates `draft`: {up}"
);
assert!(
!up.contains("body"),
"the second migration must NOT re-generate the already-added `body`: {up}"
);
}
#[test]
fn write_migration_refuses_to_overwrite_existing_dir() {
let root = tempfile::tempdir().expect("tempdir");
let dir = write_migration_dir(
root.path(),
"20260101120000",
"add_body",
"UP-ONE",
"DOWN-ONE",
)
.expect("first write ok");
assert_eq!(
std::fs::read_to_string(dir.join("up.sql")).expect("up.sql"),
"UP-ONE"
);
let err = write_migration_dir(
root.path(),
"20260101120000",
"add_body",
"UP-TWO",
"DOWN-TWO",
)
.expect_err("second write to the same dir must be refused");
assert!(
err.contains("refusing to overwrite") && err.contains("add_body"),
"clear refusal naming the colliding dir: {err}"
);
assert_eq!(
std::fs::read_to_string(dir.join("up.sql")).expect("up.sql"),
"UP-ONE",
"first up.sql untouched"
);
assert_eq!(
std::fs::read_to_string(dir.join("down.sql")).expect("down.sql"),
"DOWN-ONE",
"first down.sql untouched"
);
}
#[test]
fn write_migration_cleans_up_partial_dir_on_write_failure() {
let root = tempfile::tempdir().expect("tempdir");
let leaf = root
.path()
.join("migrations")
.join("20260101120000_add_body");
let err = write_migration_dir_with(
root.path(),
"20260101120000",
"add_body",
"UP",
"DOWN",
|path, contents| {
if path.file_name().and_then(|n| n.to_str()) == Some("down.sql") {
Err(std::io::Error::other("simulated disk full"))
} else {
std::fs::write(path, contents)
}
},
)
.expect_err("down.sql write failure must propagate");
assert!(
err.contains("down.sql") && err.contains("simulated disk full"),
"original write error is surfaced: {err}"
);
assert!(
!leaf.exists(),
"partial migration dir must be removed on write failure"
);
let dir = write_migration_dir(root.path(), "20260101120000", "add_body", "UP", "DOWN")
.expect("retry after cleanup succeeds");
assert_eq!(
std::fs::read_to_string(dir.join("up.sql")).expect("up.sql"),
"UP"
);
assert_eq!(
std::fs::read_to_string(dir.join("down.sql")).expect("down.sql"),
"DOWN"
);
}
#[test]
fn diff_destructive_refused_without_flag_then_allowed() {
let models = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
}
"#;
let root = scaffold_project(models, &posts_snapshot("Postgres"));
let err = diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
false,
None,
false,
)
.unwrap_err();
assert!(
err.contains("--allow-destructive"),
"refusal names the flag: {err}"
);
diff_at(
root.path(),
None,
None,
Some(BackendArg::Pg),
false,
None,
true,
)
.expect("allowed with --allow-destructive");
}
#[test]
fn default_resolver_prefers_src_models_dir() {
let root = tempfile::tempdir().expect("tempdir");
let dir = root.path().join("src").join("models");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(root.path().join("src").join("models.rs"), "").expect("write file");
let resolved = resolve_default_models_path(root.path()).expect("resolve");
assert_eq!(resolved, dir);
}
#[test]
fn default_resolver_falls_back_to_single_file() {
let root = tempfile::tempdir().expect("tempdir");
let src = root.path().join("src");
std::fs::create_dir_all(&src).expect("mkdir");
let file = src.join("models.rs");
std::fs::write(&file, "").expect("write file");
let resolved = resolve_default_models_path(root.path()).expect("resolve");
assert_eq!(resolved, file);
}
#[test]
fn fully_explicit_invocation_needs_no_project_root() {
let from = Path::new("models.rs");
let out = Path::new("snap.json");
assert!(!needs_project_root(
Some(from),
Some(out),
false,
Some(BackendArg::Sqlite)
));
assert!(!needs_project_root(
Some(from),
None,
true,
Some(BackendArg::Sqlite)
));
}
#[test]
fn missing_backend_requires_project_root_even_with_explicit_paths() {
let from = Path::new("models.rs");
let out = Path::new("snap.json");
assert!(needs_project_root(Some(from), Some(out), false, None));
assert!(needs_project_root(Some(from), None, true, None));
}
#[test]
fn default_paths_require_project_root() {
let from = Path::new("models.rs");
let out = Path::new("snap.json");
assert!(needs_project_root(
None,
Some(out),
false,
Some(BackendArg::Sqlite)
));
assert!(needs_project_root(
Some(from),
None,
false,
Some(BackendArg::Sqlite)
));
assert!(needs_project_root(None, None, false, None));
}
#[test]
fn root_required_error_names_backend_when_detection_is_a_reason() {
let msg = project_root_required_error(true);
assert!(
msg.contains("--backend"),
"guides the user to --backend: {msg}"
);
assert!(
msg.contains("auto-detect"),
"explains the auto-detection failure: {msg}"
);
}
#[test]
fn root_required_error_is_generic_when_backend_is_explicit() {
let msg = project_root_required_error(false);
assert!(!msg.contains("--backend"), "no --backend hint: {msg}");
assert!(
msg.contains("Autumn project"),
"the generic project-root message: {msg}"
);
}
#[derive(clap::Parser, Debug)]
struct TestCli {
#[command(subcommand)]
action: SchemaAction,
}
#[test]
fn out_and_stdout_are_mutually_exclusive() {
use clap::Parser;
let err = TestCli::try_parse_from([
"autumn",
"snapshot",
"--from",
"models.rs",
"--out",
"snap.json",
"--stdout",
])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
assert!(
TestCli::try_parse_from(["autumn", "snapshot", "--from", "models.rs", "--stdout"])
.is_ok()
);
assert!(
TestCli::try_parse_from([
"autumn",
"snapshot",
"--from",
"models.rs",
"--out",
"snap.json"
])
.is_ok()
);
}
#[test]
fn backend_for_url_prefers_url_scheme_over_project_default() {
let root = tempfile::tempdir().expect("tempdir");
assert_eq!(
backend_for_url(root.path(), None, Some("sqlite://app.db")),
Backend::Sqlite
);
assert_eq!(
backend_for_url(root.path(), None, Some("postgres://u@h/db")),
Backend::Postgres
);
assert_eq!(backend_for_url(root.path(), None, None), Backend::Postgres);
}
#[test]
fn existing_models_path_prefers_dir_then_file_then_none() {
let root = tempfile::tempdir().expect("tempdir");
assert!(existing_models_path(root.path()).is_none());
let src = root.path().join("src");
std::fs::create_dir_all(&src).expect("mkdir src");
let file = src.join("models.rs");
std::fs::write(&file, "").expect("write file");
assert_eq!(existing_models_path(root.path()), Some(file));
let dir = src.join("models");
std::fs::create_dir_all(&dir).expect("mkdir models");
assert_eq!(existing_models_path(root.path()), Some(dir));
}
#[test]
fn load_existing_snapshot_absent_and_corrupt_are_none_valid_is_some() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join("snap.json");
assert!(load_existing_snapshot(&path).is_none());
std::fs::write(&path, "{ not json").expect("write corrupt");
assert!(load_existing_snapshot(&path).is_none());
let snap = SchemaSnapshot::new(Backend::Sqlite, Vec::new());
snapshot::write_snapshot(&path, &snap).expect("write valid");
let loaded = load_existing_snapshot(&path).expect("some");
assert_eq!(loaded.backend, Backend::Sqlite);
}
#[test]
fn default_resolver_errors_when_neither_exists() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(root.path().join("src")).expect("mkdir");
let err = resolve_default_models_path(root.path()).unwrap_err();
assert!(
err.contains("--from"),
"error tells the user to pass --from"
);
}
fn users_table_with_email_unique_index() -> Table {
use autumn_schema_core::{ColumnType, Index};
let mut id = Column::new("id".to_string(), ColumnType::Int64);
id.primary_key = true;
let email = Column::new("email".to_string(), ColumnType::Text);
let mut table = Table::new("users", Backend::Postgres);
table.managed = true;
table.primary_key = vec!["id".to_string()];
table.columns = vec![id, email];
table.indexes = vec![Index {
name: "users_email_key".to_string(),
columns: vec!["email".to_string()],
unique: true,
definition: Some("CREATE UNIQUE INDEX users_email_key ON users (email)".to_string()),
is_partial: false,
key_columns: Vec::new(),
}];
table
}
fn drop_email_plan() -> MigrationPlan {
use autumn_schema_core::{Column, ColumnType};
MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropColumn {
table: "users".to_string(),
column: Column::new("email".to_string(), ColumnType::Text),
}],
}
}
#[test]
fn project_plan_target_prunes_retained_index_depending_on_dropped_column() {
let baseline = vec![users_table_with_email_unique_index()];
let projected = project_plan_target(&baseline, &drop_email_plan());
let users = projected
.iter()
.find(|t| t.name == "users")
.expect("users table retained");
assert!(
users.columns.iter().all(|c| c.name != "email"),
"dropped column must be gone: {:?}",
users.columns
);
assert!(
users.indexes.is_empty(),
"retained index depending on the dropped column must be pruned: {:?}",
users.indexes
);
}
#[test]
fn project_plan_target_keeps_retained_index_on_unrelated_column() {
use autumn_schema_core::{Column, ColumnType, Index};
let mut table = Table::new("users", Backend::Postgres);
table.managed = true;
let mut id = Column::new("id".to_string(), ColumnType::Int64);
id.primary_key = true;
table.primary_key = vec!["id".to_string()];
table.columns = vec![
id,
Column::new("email".to_string(), ColumnType::Text),
Column::new("handle".to_string(), ColumnType::Text),
];
table.indexes = vec![Index {
name: "users_lower_handle_idx".to_string(),
columns: vec!["handle".to_string()],
unique: false,
definition: Some(
"CREATE INDEX users_lower_handle_idx ON users (lower(handle))".to_string(),
),
is_partial: false,
key_columns: Vec::new(),
}];
let projected = project_plan_target(&[table], &drop_email_plan());
let users = projected
.iter()
.find(|t| t.name == "users")
.expect("users table");
assert_eq!(
users.indexes.len(),
1,
"an index on an unrelated column must survive: {:?}",
users.indexes
);
assert_eq!(users.indexes[0].name, "users_lower_handle_idx");
}
#[test]
fn project_plan_target_false_drift_regression_is_clean() {
let baseline = vec![users_table_with_email_unique_index()];
let snapshot = project_plan_target(&baseline, &drop_email_plan());
let mut db_users = users_table_with_email_unique_index();
db_users.columns.retain(|c| c.name != "email");
db_users.indexes.clear();
let db = vec![db_users];
let drift = doctor::compute_db_schema_drift(&snapshot, &db);
assert!(
drift.is_clean(),
"projected snapshot must match the cascade-dropped DB (no false drift); \
forward={:?} reverse={:?}",
drift.forward.changes,
drift.reverse.changes
);
}
fn dry_run_posts(default: Option<autumn_schema_core::ColumnDefault>, extra: bool) -> Table {
use autumn_schema_core::{Column, ColumnType};
let mut id = Column::new("id".to_string(), ColumnType::Int64);
id.primary_key = true;
let mut created_at = Column::new("created_at".to_string(), ColumnType::Timestamp);
created_at.default = default;
let mut table = Table::new("posts", Backend::Postgres);
table.managed = true;
table.primary_key = vec!["id".to_string()];
table.columns = vec![id, created_at];
if extra {
table
.columns
.push(Column::new("extra".to_string(), ColumnType::Text));
}
table
}
#[test]
fn dry_run_report_surfaces_reverse_only_removal_alongside_forward_change() {
let snapshot = vec![dry_run_posts(
Some(autumn_schema_core::ColumnDefault::Now),
false,
)];
let db = vec![dry_run_posts(None, true)];
let drift = doctor::compute_db_schema_drift(&snapshot, &db);
assert!(
!drift.forward.is_empty(),
"forward plan should carry the added column: {:?}",
drift.forward.changes
);
let report = format_pull_dry_run_report(
&drift.forward,
&drift.reverse,
Path::new(".autumn/schema-snapshot.json"),
);
assert!(
report.contains("ADD COLUMN posts.extra"),
"dry-run report must include the forward added-column change:\n{report}"
);
assert!(
report.contains("pulling would also remove from the snapshot")
&& report.contains("default on `posts.created_at`"),
"dry-run report must ALSO surface the reverse-only dropped default even though a \
forward change exists:\n{report}"
);
}
#[test]
fn dry_run_report_reverse_only_removal_with_empty_forward() {
let snapshot = vec![dry_run_posts(
Some(autumn_schema_core::ColumnDefault::Now),
false,
)];
let db = vec![dry_run_posts(None, false)];
let drift = doctor::compute_db_schema_drift(&snapshot, &db);
assert!(
drift.forward.is_empty(),
"forward pass must miss the dropped default"
);
let report = format_pull_dry_run_report(
&drift.forward,
&drift.reverse,
Path::new(".autumn/schema-snapshot.json"),
);
assert!(
report.contains("default on `posts.created_at`"),
"dry-run report must surface the reverse-only dropped default:\n{report}"
);
assert!(
!report.contains("is up to date"),
"a dropped default is drift, never 'up to date':\n{report}"
);
}
#[test]
fn dry_run_report_clean_when_both_directions_empty() {
let snapshot = vec![dry_run_posts(
Some(autumn_schema_core::ColumnDefault::Now),
false,
)];
let db = snapshot.clone();
let drift = doctor::compute_db_schema_drift(&snapshot, &db);
let report = format_pull_dry_run_report(
&drift.forward,
&drift.reverse,
Path::new(".autumn/schema-snapshot.json"),
);
assert!(
report.contains("is up to date"),
"identical snapshot/DB must report up to date:\n{report}"
);
}
}