pub mod diff;
pub mod doctor;
pub mod introspect;
pub mod migrate;
pub mod parse;
pub mod rename;
pub mod schema_rs;
pub mod shadow;
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,
}
}
}
const DEV_URL_ENV: &str = "AUTUMN_DEV_URL";
#[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,
#[arg(
long,
value_name = "URL",
env = DEV_URL_ENV,
hide_env_values = true
)]
dev_url: Option<String>,
},
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,
dev_url,
} => run_diff(&DiffArgs {
from: from.as_deref(),
snapshot: snapshot.as_deref(),
backend,
write_migration,
name: name.as_deref(),
allow_destructive,
dev_url: dev_url.as_deref(),
}),
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()
))
}
pub 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()
}
#[derive(Debug, Default, Clone, Copy)]
struct DiffArgs<'a> {
from: Option<&'a Path>,
snapshot: Option<&'a Path>,
backend: Option<BackendArg>,
write_migration: bool,
name: Option<&'a str>,
allow_destructive: bool,
dev_url: Option<&'a str>,
}
fn run_diff(args: &DiffArgs<'_>) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
diff_at(&project_root, args)
}
fn diff_at(project_root: &Path, args: &DiffArgs<'_>) -> Result<(), String> {
diff_at_with(project_root, args, &write_file_atomic)
}
fn write_file_atomic(path: &Path, text: &str) -> std::io::Result<()> {
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
std::io::Write::write_all(&mut tmp, text.as_bytes())?;
if let Ok(meta) = std::fs::metadata(&target) {
tmp.as_file().set_permissions(meta.permissions())?;
}
tmp.as_file().sync_all()?;
tmp.persist(&target).map(|_| ()).map_err(|e| e.error)
}
fn diff_at_with(
project_root: &Path,
args: &DiffArgs<'_>,
write_schema_rs: &dyn Fn(&Path, &str) -> std::io::Result<()>,
) -> Result<(), String> {
let DiffArgs {
from,
snapshot: snapshot_path,
backend,
write_migration,
name,
allow_destructive,
dev_url,
} = *args;
let backend_given = backend.is_some();
if (from.is_none() || snapshot_path.is_none() || backend.is_none() || dev_url.is_some())
&& 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 snapshot = load_diff_snapshot(&snapshot_path, backend, dev_url.is_some())?;
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 (baseline_tables, other_relations) = match dev_url {
Some(url) => {
let replay = replay_baseline(project_root, url, backend, snapshot.as_ref(), &desired)?;
(replay.tables, replay.other_relations)
}
None => (
snapshot
.as_ref()
.map(|s| s.tables.clone())
.unwrap_or_default(),
std::collections::BTreeSet::new(),
),
};
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 baseline.");
if write_migration {
let sync = plan_schema_rs(project_root, &desired, &plan)?;
let adopted = adopted_snapshot(snapshot.as_ref(), &baseline_tables, backend, &desired);
commit_empty_plan(
&snapshot_path,
adopted.as_ref(),
sync.as_ref(),
write_schema_rs,
)?;
}
return Ok(());
}
diff::guard_plan(&plan, opts).map_err(|e| e.to_string())?;
if let Some(clash) = relation_clash(&plan, &baseline_tables, &other_relations) {
return Err(clash);
}
let renamed = rename::renamed_baseline(&baseline_tables, &plan.changes);
let ctx =
diff::SchemaContext::from_tables(&desired.tables, &renamed).with_skipped_decimals(&desired);
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 sync = plan_schema_rs(project_root, &desired, &plan)?;
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 mut target_tables = project_plan_target(&baseline_tables, &plan);
mark_managed(&mut target_tables, &desired);
commit_outputs(
&snapshot_path,
Some(&SchemaSnapshot::new(backend, target_tables)),
Some(&dir),
sync.as_ref(),
write_schema_rs,
)?;
println!(
"wrote migration {} ({} change(s))",
dir.display(),
plan.changes.len()
);
println!("advanced schema snapshot at {}", snapshot_path.display());
report_schema_rs(sync.as_ref());
Ok(())
}
fn commit_empty_plan(
snapshot_path: &Path,
adopted: Option<&SchemaSnapshot>,
sync: Option<&(PathBuf, schema_rs::SchemaRsSync)>,
write_schema_rs: &dyn Fn(&Path, &str) -> std::io::Result<()>,
) -> Result<(), String> {
commit_outputs(snapshot_path, adopted, None, sync, write_schema_rs)?;
if adopted.is_some() {
println!("recorded the managed models in {}", snapshot_path.display());
}
report_schema_rs(sync);
Ok(())
}
fn adopted_snapshot(
snapshot: Option<&SchemaSnapshot>,
baseline: &[Table],
backend: Backend,
desired: &parse::ParsedSchema,
) -> Option<SchemaSnapshot> {
snapshot.map_or_else(
|| {
let mut tables = baseline.to_vec();
mark_managed(&mut tables, desired);
Some(SchemaSnapshot::new(backend, tables))
},
|s| {
let mut tables = s.tables.clone();
mark_managed(&mut tables, desired);
(tables != s.tables).then(|| SchemaSnapshot::new(s.backend, tables))
},
)
}
fn mark_managed(tables: &mut [Table], desired: &parse::ParsedSchema) {
for table in tables {
if desired
.tables
.iter()
.any(|t| t.managed && t.name == table.name)
{
table.managed = true;
}
}
}
fn commit_outputs(
snapshot_path: &Path,
snapshot: Option<&SchemaSnapshot>,
migration: Option<&Path>,
sync: Option<&(PathBuf, schema_rs::SchemaRsSync)>,
write_schema_rs: &dyn Fn(&Path, &str) -> std::io::Result<()>,
) -> Result<(), String> {
let remove_migration = || {
if let Some(dir) = migration {
let _ = std::fs::remove_dir_all(dir);
}
};
let old_snapshot = std::fs::read(snapshot_path).ok();
if let Some(snapshot) = snapshot
&& let Err(e) = snapshot::write_snapshot(snapshot_path, snapshot)
{
remove_migration();
return Err(e.to_string());
}
if let Err(e) = apply_schema_rs(sync, write_schema_rs) {
remove_migration();
if snapshot.is_some() && restore_file(snapshot_path, old_snapshot.as_deref()).is_err() {
return Err(format!(
"{e}; could not restore the snapshot at {} — check it",
snapshot_path.display()
));
}
return Err(format!(
"{e}; the migration and the snapshot were not changed"
));
}
Ok(())
}
fn plan_schema_rs(
project_root: &Path,
desired: &parse::ParsedSchema,
plan: &MigrationPlan,
) -> Result<Option<(PathBuf, schema_rs::SchemaRsSync)>, String> {
let path = project_root.join(schema_rs::SCHEMA_RS_PATH);
let existing = match std::fs::read_to_string(&path) {
Ok(existing) => existing,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if desired.tables.iter().any(|t| t.managed) {
eprintln!(
"note: no {} — the diesel schema was not written",
schema_rs::SCHEMA_RS_PATH
);
}
return Ok(None);
}
Err(e) => return Err(format!("failed to read {}: {e}", path.display())),
};
let sync = schema_rs::sync_for_plan(&existing, desired, plan);
for (table, reason) in &sync.skipped {
eprintln!(
"warning: {} — did not write `{table}`: {reason}",
schema_rs::SCHEMA_RS_PATH
);
}
if sync.text == existing {
return Ok(None);
}
if let Err(reason) = schema_rs::validate(&sync.text) {
eprintln!(
"warning: did not update {}: {reason}; edit it by hand",
schema_rs::SCHEMA_RS_PATH
);
return Ok(None);
}
Ok(Some((path, sync)))
}
fn apply_schema_rs(
sync: Option<&(PathBuf, schema_rs::SchemaRsSync)>,
write: &dyn Fn(&Path, &str) -> std::io::Result<()>,
) -> Result<(), String> {
let Some((path, sync)) = sync else {
return Ok(());
};
write(path, &sync.text).map_err(|e| format!("failed to write {}: {e}", path.display()))
}
fn restore_file(path: &Path, old: Option<&[u8]>) -> std::io::Result<()> {
old.map_or_else(
|| std::fs::remove_file(path),
|bytes| std::fs::write(path, bytes),
)
}
fn report_schema_rs(sync: Option<&(PathBuf, schema_rs::SchemaRsSync)>) {
let Some((path, sync)) = sync else { return };
let mut parts = Vec::new();
if !sync.written.is_empty() {
parts.push(format!("wrote {}", sync.written.join(", ")));
}
if !sync.removed.is_empty() {
parts.push(format!("removed {}", sync.removed.join(", ")));
}
for (old, new) in &sync.renamed {
parts.push(format!("renamed {old} to {new}"));
}
if parts.is_empty() {
parts.push("updated the table lists".to_owned());
}
println!("updated {} ({})", path.display(), parts.join("; "));
}
fn load_diff_snapshot(
snapshot_path: &Path,
backend: Backend,
optional: bool,
) -> Result<Option<SchemaSnapshot>, String> {
match snapshot::load_snapshot(snapshot_path) {
Ok(snapshot) => {
snapshot
.ensure_backend_matches(backend)
.map_err(|e| e.to_string())?;
Ok(Some(snapshot))
}
Err(snapshot::SnapshotError::Io { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
if optional {
Ok(None)
} else {
Err(format!(
"no schema snapshot at {} — run `autumn schema snapshot` first to create the diff baseline",
snapshot_path.display()
))
}
}
Err(other) => Err(other.to_string()),
}
}
fn replay_baseline(
project_root: &Path,
url: &str,
backend: Backend,
snapshot: Option<&SchemaSnapshot>,
desired: &parse::ParsedSchema,
) -> Result<shadow::Replay, String> {
let mut replayed = shadow::replay(backend, url, &project_root.join("migrations"))?;
shadow::adopt_model_spelling(&mut replayed.tables, desired);
if let Some(snapshot) = snapshot {
warn_snapshot_drift(&snapshot.tables, &replayed.tables);
}
shadow::adopt_managed_flags(
&mut replayed.tables,
snapshot.map(|s| &s.tables[..]),
desired,
);
Ok(replayed)
}
fn relation_clash(
plan: &MigrationPlan,
baseline: &[Table],
others: &std::collections::BTreeSet<String>,
) -> Option<String> {
let key = |name: &str| name.to_ascii_lowercase();
let added: std::collections::BTreeSet<String> = plan
.changes
.iter()
.filter_map(|c| match c {
SchemaChange::AddIndex { index, .. } => Some(key(&index.name)),
_ => None,
})
.collect();
let freed: std::collections::BTreeSet<String> = plan
.changes
.iter()
.filter_map(|c| match c {
SchemaChange::RenameTable { from, .. } | SchemaChange::RenameIndex { from, .. } => {
Some(key(from))
}
SchemaChange::DropIndex { index, .. } if added.contains(&key(&index.name)) => {
Some(key(&index.name))
}
_ => None,
})
.collect();
let existing: std::collections::BTreeSet<String> = others
.iter()
.map(|n| key(n))
.chain(baseline.iter().flat_map(|t| {
std::iter::once(key(&t.name)).chain(t.indexes.iter().map(|i| key(&i.name)))
}))
.collect();
let taken = |name: &str| {
let k = key(name);
!freed.contains(&k) && existing.contains(&k)
};
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
plan.changes.iter().find_map(|c| {
let names: Vec<&String> = match c {
SchemaChange::CreateTable(t) => std::iter::once(&t.name)
.chain(t.indexes.iter().map(|i| &i.name))
.collect(),
SchemaChange::RenameTable { to, .. } => vec![to],
SchemaChange::AddIndex { index, .. } | SchemaChange::RenameIndex { index, .. } => {
vec![&index.name]
}
_ => return None,
};
let name = names
.into_iter()
.find(|n| taken(n) || !seen.insert(key(n)))?;
Some(format!(
"`{name}` is already a table, index, view, sequence, type or other relation \
in the schema; choose another name"
))
})
}
fn warn_snapshot_drift(snapshot: &[Table], replayed: &[Table]) {
if let Some(text) = snapshot_drift(snapshot, replayed) {
eprintln!("warning: the snapshot does not match the migrations.\n{text}");
}
}
fn snapshot_drift(snapshot: &[Table], replayed: &[Table]) -> Option<String> {
let drift = doctor::compute_db_schema_drift(snapshot, replayed);
if drift.is_clean() {
return None;
}
let mut text = String::new();
if !drift.forward.is_empty() {
text.push_str("Changes that turn the snapshot into the migrated schema:\n");
text.push_str(&diff::describe_plan(&drift.forward));
}
if !drift.reverse.is_empty() {
text.push_str("Changes that turn the migrated schema into the snapshot:\n");
text.push_str(&diff::describe_plan(&drift.reverse));
}
Some(text)
}
fn project_plan_target(baseline: &[Table], plan: &MigrationPlan) -> Vec<Table> {
let baseline = rename::renamed_baseline(baseline, &plan.changes);
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::RenameTable { .. }
| SchemaChange::RenameColumn { .. }
| SchemaChange::RenameIndex { .. }
| SchemaChange::RenameConflict { .. }
| 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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
..DiffArgs::default()
},
)
.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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
..DiffArgs::default()
},
)
.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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
..DiffArgs::default()
},
)
.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_with_renamed_from_renames_and_advances_snapshot() {
let models = r#"
#[autumn_web::model(managed)]
#[renamed_from("posts")]
pub struct Article {
#[id]
pub id: i64,
#[renamed_from("title")]
pub headline: String,
}
"#;
let root = scaffold_project(models, &posts_snapshot("Postgres"));
diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("rename_posts"),
..DiffArgs::default()
},
)
.expect("a rename needs no --allow-destructive");
let migrations = root.path().join("migrations");
let dirs: Vec<_> = std::fs::read_dir(&migrations)
.expect("read migrations dir")
.map(|e| e.expect("entry").path())
.collect();
assert_eq!(dirs.len(), 1, "{dirs:?}");
let up = std::fs::read_to_string(dirs[0].join("up.sql")).expect("up.sql");
assert_eq!(
up.trim(),
"ALTER TABLE posts RENAME TO articles;\n\n\
ALTER TABLE articles RENAME COLUMN title TO headline;"
);
let snapshot_path = root.path().join(".autumn/schema-snapshot.json");
let advanced = snapshot::load_snapshot(&snapshot_path).expect("load snapshot");
let names: Vec<&str> = advanced.tables.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["articles"]);
assert!(
advanced.tables[0]
.columns
.iter()
.any(|c| c.name == "headline")
);
diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("again"),
..DiffArgs::default()
},
)
.expect("second diff");
assert_eq!(std::fs::read_dir(&migrations).expect("read").count(), 1);
}
#[test]
fn snapshot_drift_sees_replay_only_facets_both_ways() {
let mut posts = autumn_schema_core::Table::new("posts", Backend::Postgres);
posts
.columns
.push(Column::new("id", autumn_schema_core::ColumnType::Int64));
assert_eq!(snapshot_drift(&[posts.clone()], &[posts.clone()]), None);
let mut replayed = posts.clone();
replayed.checks.push(autumn_schema_core::CheckConstraint {
name: Some("id_positive".to_owned()),
expression: "id > 0".to_owned(),
});
let text = snapshot_drift(&[posts.clone()], &[replayed.clone()]).expect("drift");
assert!(text.contains("id_positive"), "{text}");
let text = snapshot_drift(&[replayed], &[posts]).expect("drift");
assert!(text.contains("id_positive"), "{text}");
}
#[test]
fn a_new_table_or_rename_on_a_replayed_view_name_is_refused() {
let others: std::collections::BTreeSet<String> =
["report".to_owned(), "posts_id_seq".to_owned()].into();
let plan = |change| MigrationPlan {
backend: Backend::Postgres,
changes: vec![change],
};
let create = plan(SchemaChange::CreateTable(autumn_schema_core::Table::new(
"report",
Backend::Postgres,
)));
assert!(relation_clash(&create, &[], &others).is_some_and(|e| e.contains("report")));
let rename = plan(SchemaChange::RenameTable {
from: "old".to_owned(),
to: "posts_id_seq".to_owned(),
});
assert!(relation_clash(&rename, &[], &others).is_some());
let mut with_index = autumn_schema_core::Table::new("new_table", Backend::Postgres);
with_index.indexes.push(autumn_schema_core::Index::new(
"report",
vec!["id".to_owned()],
false,
));
assert!(
relation_clash(&plan(SchemaChange::CreateTable(with_index)), &[], &others).is_some()
);
let fine = plan(SchemaChange::RenameTable {
from: "old".to_owned(),
to: "new".to_owned(),
});
assert_eq!(relation_clash(&fine, &[], &others), None);
}
#[test]
fn a_new_index_named_like_a_baseline_index_on_another_table_is_refused() {
use autumn_schema_core::{Index, Table};
let mut audit = Table::new("audit", Backend::Postgres);
audit
.indexes
.push(Index::new("idx_reports_slug", vec!["x".to_owned()], false));
let baseline = vec![audit, Table::new("reports", Backend::Postgres)];
let none = std::collections::BTreeSet::new();
let add = |name: &str| SchemaChange::AddIndex {
table: "reports".to_owned(),
index: Index::new(name, vec!["slug".to_owned()], false),
};
let plan = |changes| MigrationPlan {
backend: Backend::Postgres,
changes,
};
let err =
relation_clash(&plan(vec![add("idx_reports_slug")]), &baseline, &none).expect("clash");
assert!(
err.contains("idx_reports_slug") && err.contains("already"),
"{err}"
);
assert!(relation_clash(&plan(vec![add("audit")]), &baseline, &none).is_some());
let replaced = plan(vec![
SchemaChange::DropIndex {
table: "audit".to_owned(),
index: Index::new("idx_reports_slug", vec!["x".to_owned()], false),
},
add("idx_reports_slug"),
]);
assert_eq!(relation_clash(&replaced, &baseline, &none), None);
assert_eq!(
relation_clash(&plan(vec![add("idx_new")]), &baseline, &none),
None
);
let mixed: std::collections::BTreeSet<String> = ["Reports".to_owned()].into();
let create = plan(vec![SchemaChange::CreateTable(Table::new(
"reports",
Backend::Sqlite,
))]);
assert!(relation_clash(&create, &[], &mixed).is_some());
assert!(relation_clash(&plan(vec![add("IDX_REPORTS_SLUG")]), &baseline, &none).is_some());
let twice = plan(vec![
SchemaChange::RenameIndex {
table: "reports".to_owned(),
from: "idx_old".to_owned(),
index: Index::new("idx_users_mails", vec!["x".to_owned()], false),
},
SchemaChange::CreateTable(Table::new("idx_users_mails", Backend::Postgres)),
]);
assert!(
relation_clash(&twice, &baseline, &none).is_some_and(|e| e.contains("idx_users_mails"))
);
}
#[test]
fn dev_url_makes_the_snapshot_optional() {
let root = scaffold_project(POST_MODEL, "unused");
std::fs::remove_file(root.path().join(".autumn/schema-snapshot.json")).expect("rm");
let err = diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Pg),
dev_url: Some("postgres://user:pw_do_not_leak@127.0.0.1:1/dev"),
..DiffArgs::default()
},
)
.unwrap_err();
assert!(!err.contains("autumn schema snapshot"), "{err}");
assert!(
err.contains("127.0.0.1") && !err.contains("pw_do_not_leak"),
"{err}"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_dev_url_reads_names_that_differ_only_in_case_as_the_model_names() {
let root = scaffold_project(POST_MODEL, "unused");
std::fs::remove_file(root.path().join(".autumn/schema-snapshot.json")).expect("rm");
let init = root.path().join("migrations/20000101000000_init");
std::fs::create_dir_all(&init).expect("mkdir");
std::fs::write(
init.join("up.sql"),
"CREATE TABLE \"Posts\" (id INTEGER PRIMARY KEY AUTOINCREMENT, \
\"Title\" TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);",
)
.expect("up.sql");
std::fs::write(init.join("down.sql"), "DROP TABLE \"Posts\";").expect("down.sql");
diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Sqlite),
dev_url: Some("sqlite::memory:"),
write_migration: true,
..DiffArgs::default()
},
)
.expect("`Posts` and `Title` are the model's `posts` and `title`");
let written: Vec<String> = std::fs::read_dir(root.path().join("migrations"))
.expect("migrations")
.filter_map(|e| {
let p = e.ok()?.path();
(!p.ends_with("20000101000000_init"))
.then(|| std::fs::read_to_string(p.join("up.sql")).unwrap_or_default())
})
.collect();
assert!(written.is_empty(), "no new migration: {written:?}");
}
#[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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("add_body"),
..DiffArgs::default()
},
)
.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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("add_body"),
..DiffArgs::default()
},
);
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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("add_body"),
..DiffArgs::default()
},
)
.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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("add_body_again"),
..DiffArgs::default()
},
)
.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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
name: Some("add_draft"),
..DiffArgs::default()
},
)
.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}"
);
}
const POSTS_BLOCK: &str = "diesel::table! {
posts (id) {
id -> Int8,
title -> Text,
created_at -> Timestamp,
}
}
";
fn write_schema_rs(root: &Path, text: &str) -> PathBuf {
let path = root.join(schema_rs::SCHEMA_RS_PATH);
std::fs::write(&path, text).expect("schema.rs");
path
}
fn write_args<'a>() -> DiffArgs<'a> {
DiffArgs {
backend: Some(BackendArg::Pg),
write_migration: true,
..DiffArgs::default()
}
}
#[test]
fn write_migration_writes_the_new_column_to_schema_rs() {
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 path = write_schema_rs(root.path(), POSTS_BLOCK);
diff_at(root.path(), &write_args()).expect("write migration ok");
let schema = std::fs::read_to_string(path).expect("schema.rs");
assert!(
schema.contains(" body -> Nullable<Text>,\n"),
"{schema}"
);
}
#[test]
fn write_migration_removes_the_block_of_a_dropped_table() {
let root = scaffold_project("", &posts_snapshot("Postgres"));
let path = write_schema_rs(
root.path(),
&format!(
"{POSTS_BLOCK}\ndiesel::table! {{\n users (id) {{\n id -> Int8,\n }}\n}}\n"
),
);
diff_at(
root.path(),
&DiffArgs {
allow_destructive: true,
..write_args()
},
)
.expect("drop ok");
let schema = std::fs::read_to_string(path).expect("schema.rs");
assert!(!schema.contains("posts"), "{schema}");
assert!(schema.contains("users (id)"), "{schema}");
}
#[test]
fn a_no_op_write_migration_still_syncs_a_stale_schema_rs() {
let root = scaffold_project(POST_MODEL, &posts_snapshot("Postgres"));
let snapshot_path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let before = std::fs::read_to_string(&snapshot_path).expect("snapshot");
let path = write_schema_rs(
root.path(),
"diesel::table! {\n posts (id) {\n id -> Int8,\n }\n}\n",
);
diff_at(root.path(), &write_args()).expect("no-op ok");
assert_eq!(
std::fs::read_to_string(path).expect("schema.rs"),
POSTS_BLOCK
);
assert!(!root.path().join("migrations").exists());
assert_eq!(std::fs::read_to_string(&snapshot_path).unwrap(), before);
}
#[test]
fn a_preview_diff_and_a_missing_schema_rs_write_no_schema_rs() {
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 path = write_schema_rs(root.path(), POSTS_BLOCK);
diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Pg),
..DiffArgs::default()
},
)
.expect("preview ok");
assert_eq!(std::fs::read_to_string(&path).unwrap(), POSTS_BLOCK);
std::fs::remove_file(&path).unwrap();
diff_at(root.path(), &write_args()).expect("write ok");
assert!(!path.exists());
}
#[test]
fn adopting_a_model_records_it_as_managed_in_the_snapshot() {
let root = scaffold_project(
POST_MODEL,
&posts_snapshot("Postgres").replace("\"managed\": true", "\"managed\": false"),
);
diff_at(root.path(), &write_args()).expect("adopt ok");
let snapshot =
snapshot::load_snapshot(&root.path().join(SNAPSHOT_DEFAULT_PATH)).expect("load");
assert!(snapshot.tables[0].managed, "{:?}", snapshot.tables[0]);
assert!(!root.path().join("migrations").exists());
}
#[test]
fn a_macro_only_schema_rs_change_is_written() {
let root = scaffold_project("", &posts_snapshot("Postgres"));
let path = write_schema_rs(
root.path(),
"diesel::allow_tables_to_appear_in_same_query!(posts, users);\n",
);
diff_at(
root.path(),
&DiffArgs {
allow_destructive: true,
..write_args()
},
)
.expect("drop ok");
assert_eq!(std::fs::read_to_string(path).unwrap(), "");
}
#[cfg(unix)]
#[test]
fn an_unreadable_schema_rs_stops_the_command_before_any_write() {
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 snapshot_path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let before = std::fs::read_to_string(&snapshot_path).expect("snapshot");
std::fs::create_dir_all(root.path().join(schema_rs::SCHEMA_RS_PATH)).unwrap();
let err = diff_at(root.path(), &write_args()).unwrap_err();
assert!(err.contains("schema.rs"), "{err}");
assert!(!root.path().join("migrations").exists());
assert_eq!(std::fs::read_to_string(&snapshot_path).unwrap(), before);
}
#[test]
fn an_update_that_would_not_parse_is_not_written() {
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 broken = "diesel::table! {\n posts (id) {\n id -> Int8,\n";
let path = write_schema_rs(root.path(), broken);
diff_at(root.path(), &write_args()).expect("write ok");
assert_eq!(std::fs::read_to_string(path).unwrap(), broken);
assert!(root.path().join("migrations").exists());
}
#[test]
fn an_empty_dev_url_write_creates_the_snapshot() {
let root = tempfile::tempdir().expect("tempdir");
let snapshot_path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let desired = parse::parse_model_source(POST_MODEL, Backend::Postgres).expect("parse");
let mut baseline = desired.tables.clone();
baseline[0].managed = false;
let adopted = adopted_snapshot(None, &baseline, Backend::Postgres, &desired);
commit_empty_plan(&snapshot_path, adopted.as_ref(), None, &|_, _| Ok(())).expect("commit");
let written = snapshot::load_snapshot(&snapshot_path).expect("snapshot");
assert!(written.tables[0].managed, "{:?}", written.tables);
}
#[test]
fn write_migration_rolls_back_on_schema_rs_write_failure() {
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 snapshot_path = root.path().join(SNAPSHOT_DEFAULT_PATH);
let before = std::fs::read_to_string(&snapshot_path).expect("snapshot");
let path = write_schema_rs(root.path(), POSTS_BLOCK);
let err = diff_at_with(root.path(), &write_args(), &|_, _| {
Err(std::io::Error::other("disk full"))
})
.unwrap_err();
assert!(
err.contains("schema.rs") && err.contains("disk full"),
"{err}"
);
let leftover: Vec<_> = std::fs::read_dir(root.path().join("migrations"))
.map(|rd| rd.flatten().map(|e| e.file_name()).collect())
.unwrap_or_default();
assert!(leftover.is_empty(), "{leftover:?}");
assert_eq!(std::fs::read_to_string(&snapshot_path).unwrap(), before);
assert_eq!(std::fs::read_to_string(&path).unwrap(), POSTS_BLOCK);
}
#[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(),
&DiffArgs {
backend: Some(BackendArg::Pg),
..DiffArgs::default()
},
)
.unwrap_err();
assert!(
err.contains("--allow-destructive"),
"refusal names the flag: {err}"
);
diff_at(
root.path(),
&DiffArgs {
backend: Some(BackendArg::Pg),
allow_destructive: true,
..DiffArgs::default()
},
)
.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}"
);
}
}