use std::path::Path;
use autumn_schema_core::{Backend, Table};
use diesel_migrations::FileBasedMigrations;
use serde::Serialize;
use super::diff::{DiffOptions, MigrationPlan, diff_schema};
use super::introspect::introspect_postgres;
#[cfg(feature = "sqlite")]
use super::introspect::introspect_sqlite;
use super::parse::{ParsedSchema, parse_models_path};
use super::snapshot::{SNAPSHOT_DEFAULT_PATH, SnapshotError, load_snapshot};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Status {
Ok,
Warn,
Error,
}
impl Status {
const fn label(self) -> &'static str {
match self {
Self::Ok => "[OK] ",
Self::Warn => "[WARN] ",
Self::Error => "[ERROR]",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SnapshotState {
Loaded { backend: Backend, drift: DriftState },
Missing,
Unreadable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DriftState {
Clean,
Changed(usize),
ModelsError(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PendingState {
NotConfigured,
Skipped(String),
Reachable(Vec<String>),
Unreachable(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DbSchemaState {
NotConfigured,
#[cfg_attr(feature = "sqlite", allow(dead_code))]
Skipped(String),
SnapshotMissing,
Unreachable(String),
Clean,
Drifted(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalFacts {
cargo_toml_present: bool,
autumn_dir_present: bool,
snapshot: SnapshotState,
detected_backend: Backend,
url_backend: Option<Backend>,
}
pub fn run_doctor(profile: Option<&str>, json: bool) -> Result<(), String> {
let project_root = std::env::current_dir()
.map_err(|e| format!("failed to resolve the current directory: {e}"))?;
let local = gather_local_facts(&project_root, profile);
let pending = gather_pending(&project_root, profile, &local);
let db_schema = gather_db_schema_drift(&project_root, profile, &local);
let checks = compute_checks(&local, &pending, &db_schema);
render(&checks, json)?;
finish(&checks)
}
fn gather_local_facts(project_root: &Path, profile: Option<&str>) -> LocalFacts {
let cargo_toml_present = project_root.join("Cargo.toml").is_file();
let autumn_dir_present = project_root.join(".autumn").is_dir();
let url = crate::migrate::resolve_primary_url(profile);
let url_backend = url
.as_deref()
.and_then(autumn_web::config::DatabaseBackend::detect)
.map(super::map_detected_backend);
let detected_backend = super::backend_for_url(project_root, profile, url.as_deref());
let snapshot = probe_snapshot(project_root, detected_backend);
LocalFacts {
cargo_toml_present,
autumn_dir_present,
snapshot,
detected_backend,
url_backend,
}
}
fn probe_snapshot(project_root: &Path, _detected: Backend) -> SnapshotState {
let snapshot_path = project_root.join(SNAPSHOT_DEFAULT_PATH);
match load_snapshot(&snapshot_path) {
Ok(snapshot) => {
let drift = compute_drift(project_root, snapshot.backend, &snapshot.tables);
SnapshotState::Loaded {
backend: snapshot.backend,
drift,
}
}
Err(SnapshotError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
SnapshotState::Missing
}
Err(other) => SnapshotState::Unreadable(other.to_string()),
}
}
fn compute_drift(project_root: &Path, backend: Backend, baseline: &[Table]) -> DriftState {
let Some(models_path) = super::existing_models_path(project_root) else {
return DriftState::ModelsError(
"no declarative models found under src/ to compare against the snapshot".to_string(),
);
};
match parse_models_path(&models_path, backend) {
Ok(desired) => {
let plan = diff_schema(baseline, &desired, DiffOptions::default());
if plan.is_empty() {
DriftState::Clean
} else {
DriftState::Changed(plan.changes.len())
}
}
Err(e) => DriftState::ModelsError(e.to_string()),
}
}
fn gather_pending(project_root: &Path, profile: Option<&str>, local: &LocalFacts) -> PendingState {
let Some(url) = crate::migrate::resolve_primary_url(profile) else {
return PendingState::NotConfigured;
};
if local.detected_backend != Backend::Postgres {
return PendingState::Skipped(format!(
"pending-migration check is Postgres-only; detected {:?} backend",
local.detected_backend
));
}
probe_pending(&url, &project_root.join("migrations"))
}
fn probe_pending(url: &str, migrations_dir: &Path) -> PendingState {
let fallback_dir;
let migrations = if let Ok(source) = FileBasedMigrations::from_path(migrations_dir) {
source
} else {
fallback_dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(e) => {
return PendingState::Unreachable(format!(
"could not create a temp directory to probe the database: {e}"
));
}
};
match FileBasedMigrations::from_path(fallback_dir.path()) {
Ok(source) => source,
Err(e) => {
return PendingState::Unreachable(format!(
"could not build an empty migration source to probe the database: {e}"
));
}
}
};
match autumn_web::migrate::pending_migrations(url, migrations) {
Ok(names) => PendingState::Reachable(names),
Err(e) => PendingState::Unreachable(e.to_string()),
}
}
fn gather_db_schema_drift(
project_root: &Path,
profile: Option<&str>,
local: &LocalFacts,
) -> DbSchemaState {
let Some(url) = crate::migrate::resolve_primary_url(profile) else {
return DbSchemaState::NotConfigured;
};
match local.detected_backend {
Backend::Postgres => gather_db_schema_drift_with(project_root, &url, introspect_postgres),
#[cfg(feature = "sqlite")]
Backend::Sqlite => gather_db_schema_drift_with(project_root, &url, introspect_sqlite),
#[cfg(not(feature = "sqlite"))]
Backend::Sqlite => DbSchemaState::Skipped(format!(
"database-schema drift check is Postgres-only on this build; detected {:?} backend \
(rebuild with `--features sqlite` to check SQLite drift)",
local.detected_backend
)),
}
}
fn gather_db_schema_drift_with(
project_root: &Path,
url: &str,
introspect: impl Fn(&str) -> Result<Vec<Table>, super::introspect::IntrospectError>,
) -> DbSchemaState {
let snapshot_path = project_root.join(SNAPSHOT_DEFAULT_PATH);
let Ok(snapshot) = load_snapshot(&snapshot_path) else {
return DbSchemaState::SnapshotMissing;
};
match introspect(url) {
Ok(tables) => {
let drift = compute_db_schema_drift(&snapshot.tables, &tables);
if drift.is_clean() {
DbSchemaState::Clean
} else {
DbSchemaState::Drifted(drift.reported_count())
}
}
Err(e) => DbSchemaState::Unreachable(e.to_string()),
}
}
pub struct DbSchemaDrift {
pub forward: MigrationPlan,
pub reverse: MigrationPlan,
}
impl DbSchemaDrift {
pub const fn is_clean(&self) -> bool {
self.forward.is_empty() && self.reverse.is_empty()
}
pub const fn reported_count(&self) -> usize {
if self.forward.is_empty() {
self.reverse.changes.len()
} else {
self.forward.changes.len()
}
}
}
pub fn compute_db_schema_drift(snapshot_tables: &[Table], db_tables: &[Table]) -> DbSchemaDrift {
let opts = DiffOptions {
definitions_authoritative: true,
..DiffOptions::default()
};
let forward = diff_schema(
snapshot_tables,
&ParsedSchema::from_tables(db_tables.to_vec()),
opts,
);
let reverse = diff_schema(
db_tables,
&ParsedSchema::from_tables(snapshot_tables.to_vec()),
opts,
);
DbSchemaDrift { forward, reverse }
}
fn compute_checks(
local: &LocalFacts,
pending: &PendingState,
db_schema: &DbSchemaState,
) -> Vec<Check> {
let mut checks = Vec::new();
checks.push(if !local.cargo_toml_present {
Check {
name: "project-root".to_string(),
status: Status::Error,
detail: "not in an Autumn project (no Cargo.toml) — run from your project root"
.to_string(),
}
} else if !local.autumn_dir_present {
Check {
name: "project-root".to_string(),
status: Status::Error,
detail: "missing .autumn/ state directory — run `autumn schema snapshot` first"
.to_string(),
}
} else {
Check {
name: "project-root".to_string(),
status: Status::Ok,
detail: "Cargo.toml and .autumn/ present".to_string(),
}
});
checks.push(match &local.snapshot {
SnapshotState::Loaded { backend, .. } => Check {
name: "snapshot-present".to_string(),
status: Status::Ok,
detail: format!("snapshot loaded (backend {backend:?})"),
},
SnapshotState::Missing => Check {
name: "snapshot-present".to_string(),
status: Status::Error,
detail: format!(
"no snapshot at {SNAPSHOT_DEFAULT_PATH} — run `autumn schema snapshot`"
),
},
SnapshotState::Unreadable(reason) => Check {
name: "snapshot-present".to_string(),
status: Status::Error,
detail: format!("snapshot is unreadable: {reason}"),
},
});
checks.push(drift_check(&local.snapshot));
checks.push(provider_lock_check(&local.snapshot, local.detected_backend));
checks.push(dialect_vs_db_check(&local.snapshot, local.url_backend));
checks.push(pending_check(pending));
checks.push(db_schema_drift_check(db_schema));
checks
}
fn drift_check(snapshot: &SnapshotState) -> Check {
let name = "snapshot-drift".to_string();
match snapshot {
SnapshotState::Loaded {
drift: DriftState::Clean,
..
} => Check {
name,
status: Status::Ok,
detail: "models match the snapshot baseline".to_string(),
},
SnapshotState::Loaded {
drift: DriftState::Changed(n),
..
} => Check {
name,
status: Status::Warn,
detail: format!(
"{n} pending model change(s) — run `autumn schema diff --write-migration`"
),
},
SnapshotState::Loaded {
drift: DriftState::ModelsError(reason),
..
} => Check {
name,
status: Status::Warn,
detail: format!("could not evaluate drift: {reason}"),
},
SnapshotState::Missing | SnapshotState::Unreadable(_) => Check {
name,
status: Status::Warn,
detail: "skipped — no readable snapshot baseline".to_string(),
},
}
}
fn provider_lock_check(snapshot: &SnapshotState, detected: Backend) -> Check {
let name = "provider-lock".to_string();
match snapshot {
SnapshotState::Loaded { backend, .. } if *backend == detected => Check {
name,
status: Status::Ok,
detail: format!("snapshot and detected backend agree ({detected:?})"),
},
SnapshotState::Loaded { backend, .. } => Check {
name,
status: Status::Error,
detail: format!(
"snapshot backend {backend:?} does not match the detected backend {detected:?}"
),
},
SnapshotState::Missing | SnapshotState::Unreadable(_) => Check {
name,
status: Status::Warn,
detail: "skipped — no readable snapshot".to_string(),
},
}
}
fn dialect_vs_db_check(snapshot: &SnapshotState, url_backend: Option<Backend>) -> Check {
let name = "snapshot-dialect-vs-db".to_string();
match (snapshot, url_backend) {
(SnapshotState::Missing | SnapshotState::Unreadable(_), _) => Check {
name,
status: Status::Warn,
detail: "skipped — no readable snapshot".to_string(),
},
(SnapshotState::Loaded { .. }, None) => Check {
name,
status: Status::Warn,
detail: "no database URL configured — skipped".to_string(),
},
(SnapshotState::Loaded { backend, .. }, Some(url_backend)) if *backend == url_backend => {
Check {
name,
status: Status::Ok,
detail: format!("snapshot dialect matches the database URL ({url_backend:?})"),
}
}
(SnapshotState::Loaded { backend, .. }, Some(url_backend)) => Check {
name,
status: Status::Error,
detail: format!(
"snapshot backend {backend:?} does not match the database URL backend {url_backend:?}"
),
},
}
}
fn pending_check(pending: &PendingState) -> Check {
let name = "pending-migrations".to_string();
match pending {
PendingState::NotConfigured => Check {
name,
status: Status::Warn,
detail: "no database configured — skipped".to_string(),
},
PendingState::Skipped(reason) => Check {
name,
status: Status::Warn,
detail: reason.clone(),
},
PendingState::Unreachable(reason) => Check {
name,
status: Status::Warn,
detail: format!("could not reach database: {reason}"),
},
PendingState::Reachable(names) if names.is_empty() => Check {
name,
status: Status::Ok,
detail: "database is up to date".to_string(),
},
PendingState::Reachable(names) => Check {
name,
status: Status::Warn,
detail: format!(
"{} pending migration(s): {} — run `autumn schema migrate`",
names.len(),
names.join(", ")
),
},
}
}
fn db_schema_drift_check(state: &DbSchemaState) -> Check {
let name = "database-schema-drift".to_string();
match state {
DbSchemaState::Clean => Check {
name,
status: Status::Ok,
detail: "database schema matches the snapshot baseline".to_string(),
},
DbSchemaState::Drifted(n) => Check {
name,
status: Status::Warn,
detail: format!(
"database schema differs from the snapshot baseline ({n} difference(s)); \
run `autumn schema pull` to update the baseline or generate a migration"
),
},
DbSchemaState::Unreachable(reason) => Check {
name,
status: Status::Warn,
detail: format!("could not reach the database: {reason}"),
},
DbSchemaState::NotConfigured => Check {
name,
status: Status::Warn,
detail: "no database configured — skipped".to_string(),
},
DbSchemaState::Skipped(reason) => Check {
name,
status: Status::Warn,
detail: reason.clone(),
},
DbSchemaState::SnapshotMissing => Check {
name,
status: Status::Warn,
detail: "skipped — no readable snapshot baseline".to_string(),
},
}
}
fn render(checks: &[Check], json: bool) -> Result<(), String> {
if json {
let out = serde_json::to_string_pretty(checks)
.map_err(|e| format!("failed to serialize doctor report: {e}"))?;
println!("{out}");
return Ok(());
}
let width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
println!("Schema doctor:");
for check in checks {
println!(
" {} {:<width$} {}",
check.status.label(),
check.name,
check.detail,
width = width
);
}
Ok(())
}
fn finish(checks: &[Check]) -> Result<(), String> {
let errors: Vec<&str> = checks
.iter()
.filter(|c| c.status == Status::Error)
.map(|c| c.name.as_str())
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(format!(
"schema doctor found {} error check(s): {}",
errors.len(),
errors.join(", ")
))
}
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
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
}}
]
}}
"#
)
}
fn scaffold(models: &str, snapshot_json: Option<&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 autumn = root.path().join(".autumn");
std::fs::create_dir_all(&autumn).expect("mkdir .autumn");
if let Some(json) = snapshot_json {
std::fs::write(autumn.join("schema-snapshot.json"), json).expect("snapshot");
}
root
}
#[test]
fn missing_project_root_is_error() {
let root = tempfile::tempdir().expect("tempdir"); let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let pr = checks.iter().find(|c| c.name == "project-root").unwrap();
assert_eq!(pr.status, Status::Error, "{pr:?}");
assert!(finish(&checks).is_err());
}
#[test]
fn matching_models_report_drift_ok() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let drift = checks.iter().find(|c| c.name == "snapshot-drift").unwrap();
assert_eq!(drift.status, Status::Ok, "{drift:?}");
assert!(finish(&checks).is_ok(), "checks: {checks:?}");
}
#[test]
fn diverging_models_report_drift_warn() {
let models = r#"
#[autumn_web::model(managed)]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub body: Option<String>,
}
"#;
let root = scaffold(models, Some(&posts_snapshot("Postgres")));
let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let drift = checks.iter().find(|c| c.name == "snapshot-drift").unwrap();
assert_eq!(drift.status, Status::Warn, "{drift:?}");
assert!(drift.detail.contains("pending model change"), "{drift:?}");
assert!(finish(&checks).is_ok());
}
#[test]
fn selected_profile_backend_flows_into_provider_lock() {
let local = LocalFacts {
cargo_toml_present: true,
autumn_dir_present: true,
snapshot: SnapshotState::Loaded {
backend: Backend::Sqlite,
drift: DriftState::Clean,
},
detected_backend: Backend::Sqlite,
url_backend: Some(Backend::Sqlite),
};
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let lock = checks.iter().find(|c| c.name == "provider-lock").unwrap();
assert_eq!(lock.status, Status::Ok, "{lock:?}");
assert!(finish(&checks).is_ok(), "checks: {checks:?}");
}
#[test]
fn snapshot_backend_mismatch_is_provider_lock_error() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Sqlite")));
let local = gather_local_facts(root.path(), None);
assert_eq!(local.detected_backend, Backend::Postgres);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let lock = checks.iter().find(|c| c.name == "provider-lock").unwrap();
assert_eq!(lock.status, Status::Error, "{lock:?}");
assert!(finish(&checks).is_err());
}
#[test]
fn missing_snapshot_is_snapshot_present_error() {
let root = scaffold(POST_MODEL, None); let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let snap = checks
.iter()
.find(|c| c.name == "snapshot-present")
.unwrap();
assert_eq!(snap.status, Status::Error, "{snap:?}");
let drift = checks.iter().find(|c| c.name == "snapshot-drift").unwrap();
assert_eq!(drift.status, Status::Warn);
assert!(finish(&checks).is_err());
}
#[test]
fn unreadable_snapshot_is_snapshot_present_error() {
let root = scaffold(POST_MODEL, Some("{ not valid json"));
let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let snap = checks
.iter()
.find(|c| c.name == "snapshot-present")
.unwrap();
assert_eq!(snap.status, Status::Error, "{snap:?}");
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn missing_migrations_dir_still_probes_the_database() {
let root = tempfile::tempdir().expect("tempdir"); let state = probe_pending(
"postgres://user:pw@127.0.0.1:1/db",
&root.path().join("migrations"),
);
assert!(
matches!(state, PendingState::Unreachable(_)),
"missing dir + unreachable DB must be Unreachable, got {state:?}"
);
}
#[test]
fn pending_reachable_empty_is_ok_and_nonempty_is_warn() {
let clean = pending_check(&PendingState::Reachable(Vec::new()));
assert_eq!(clean.status, Status::Ok);
let dirty = pending_check(&PendingState::Reachable(vec!["20260101_init".to_string()]));
assert_eq!(dirty.status, Status::Warn);
assert!(dirty.detail.contains("autumn schema migrate"));
let down = pending_check(&PendingState::Unreachable("connection refused".to_string()));
assert_eq!(down.status, Status::Warn);
}
#[test]
fn dialect_vs_db_mismatch_is_error() {
let snapshot = SnapshotState::Loaded {
backend: Backend::Postgres,
drift: DriftState::Clean,
};
let check = dialect_vs_db_check(&snapshot, Some(Backend::Sqlite));
assert_eq!(check.status, Status::Error, "{check:?}");
let ok = dialect_vs_db_check(&snapshot, Some(Backend::Postgres));
assert_eq!(ok.status, Status::Ok);
let skip = dialect_vs_db_check(&snapshot, None);
assert_eq!(skip.status, Status::Warn);
}
#[test]
fn status_serializes_uppercase_in_json() {
let checks = vec![Check {
name: "x".to_string(),
status: Status::Warn,
detail: "y".to_string(),
}];
let json = serde_json::to_string(&checks).unwrap();
assert!(json.contains("\"status\":\"WARN\""), "{json}");
assert!(json.contains("\"name\":\"x\""));
}
#[test]
fn report_ordering_is_filesystem_then_snapshot_then_db() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
let local = gather_local_facts(root.path(), None);
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
);
let names: Vec<&str> = checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec![
"project-root",
"snapshot-present",
"snapshot-drift",
"provider-lock",
"snapshot-dialect-vs-db",
"pending-migrations",
"database-schema-drift",
]
);
}
#[test]
fn db_schema_drift_states_map_to_expected_status() {
assert_eq!(
db_schema_drift_check(&DbSchemaState::Clean).status,
Status::Ok
);
let drifted = db_schema_drift_check(&DbSchemaState::Drifted(3));
assert_eq!(drifted.status, Status::Warn);
assert!(drifted.detail.contains("3 difference(s)"), "{drifted:?}");
assert!(drifted.detail.contains("autumn schema pull"), "{drifted:?}");
let down = db_schema_drift_check(&DbSchemaState::Unreachable("refused".to_string()));
assert_eq!(down.status, Status::Warn);
assert!(down.detail.contains("could not reach the database"));
assert_eq!(
db_schema_drift_check(&DbSchemaState::Skipped("sqlite".to_string())).status,
Status::Warn
);
assert_eq!(
db_schema_drift_check(&DbSchemaState::NotConfigured).status,
Status::Warn
);
assert_eq!(
db_schema_drift_check(&DbSchemaState::SnapshotMissing).status,
Status::Warn
);
}
#[test]
fn db_schema_drift_never_fails_the_command() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
let local = gather_local_facts(root.path(), None);
for state in [
DbSchemaState::Drifted(5),
DbSchemaState::Unreachable("connection refused".to_string()),
DbSchemaState::Clean,
] {
let checks = compute_checks(&local, &PendingState::NotConfigured, &state);
let db = checks
.iter()
.find(|c| c.name == "database-schema-drift")
.unwrap();
assert_ne!(db.status, Status::Error, "{db:?}");
assert!(finish(&checks).is_ok(), "checks: {checks:?}");
}
}
fn drift_posts_table(created_at_default: Option<autumn_schema_core::ColumnDefault>) -> 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 = created_at_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];
table
}
#[test]
fn compute_db_schema_drift_identical_is_clean() {
let snapshot = vec![drift_posts_table(Some(
autumn_schema_core::ColumnDefault::Now,
))];
let db = snapshot.clone();
let drift = compute_db_schema_drift(&snapshot, &db);
assert!(drift.is_clean(), "identical table sets must be clean");
assert_eq!(drift.reported_count(), 0);
}
#[test]
fn compute_db_schema_drift_catches_dropped_default() {
let snapshot = vec![drift_posts_table(Some(
autumn_schema_core::ColumnDefault::Now,
))];
let db = vec![drift_posts_table(None)];
let drift = compute_db_schema_drift(&snapshot, &db);
assert!(
drift.forward.is_empty(),
"forward pass alone must miss the dropped default: {:?}",
drift.forward.changes
);
assert!(
!drift.reverse.is_empty(),
"reverse pass must catch the dropped default"
);
assert!(!drift.is_clean(), "dropped default is drift");
assert!(drift.reported_count() >= 1);
}
#[test]
fn compute_db_schema_drift_catches_dropped_foreign_key() {
use autumn_schema_core::{Column, ColumnType, ForeignKey};
let build = |with_fk: bool| {
let mut id = Column::new("id".to_string(), ColumnType::Int64);
id.primary_key = true;
let mut author_id = Column::new("author_id".to_string(), ColumnType::Int64);
if with_fk {
author_id.references = Some(ForeignKey::new("authors", "id"));
}
let mut table = Table::new("posts", Backend::Postgres);
table.managed = true;
table.primary_key = vec!["id".to_string()];
table.columns = vec![id, author_id];
table
};
let snapshot = vec![build(true)];
let db = vec![build(false)];
let drift = compute_db_schema_drift(&snapshot, &db);
assert!(
drift.forward.is_empty(),
"forward pass alone must miss the dropped FK: {:?}",
drift.forward.changes
);
assert!(
!drift.reverse.is_empty(),
"reverse pass must catch the dropped FK"
);
assert!(!drift.is_clean(), "dropped FK is drift");
}
#[test]
fn compute_db_schema_drift_catches_added_column() {
use autumn_schema_core::{Column, ColumnType};
let snapshot = vec![drift_posts_table(Some(
autumn_schema_core::ColumnDefault::Now,
))];
let mut db_table = drift_posts_table(Some(autumn_schema_core::ColumnDefault::Now));
db_table
.columns
.push(Column::new("extra".to_string(), ColumnType::Text));
let db = vec![db_table];
let drift = compute_db_schema_drift(&snapshot, &db);
assert!(
!drift.forward.is_empty(),
"forward pass must catch the added column"
);
assert!(!drift.is_clean(), "added column is drift");
assert_eq!(
drift.reported_count(),
drift.forward.changes.len(),
"reported count prefers the non-empty forward plan"
);
}
#[test]
fn compute_db_schema_drift_catches_dropped_expression_index() {
use autumn_schema_core::Index;
let mut snapshot_table = drift_posts_table(None);
snapshot_table.indexes.push(Index {
name: "idx_posts_lower_created".to_owned(),
columns: vec!["created_at".to_owned()],
unique: false,
definition: Some(
"CREATE INDEX idx_posts_lower_created ON posts (lower(created_at::text))"
.to_owned(),
),
is_partial: false,
key_columns: Vec::new(),
});
let snapshot = vec![snapshot_table];
let db = vec![drift_posts_table(None)];
let drift = compute_db_schema_drift(&snapshot, &db);
assert!(
!drift.is_clean(),
"a dropped expression index must be reported as drift by the authoritative path"
);
assert!(
drift
.forward
.changes
.iter()
.any(|c| matches!(c, crate::schema::diff::SchemaChange::DropIndex { .. })),
"forward introspection pass must emit a DropIndex for the missing expression index: {:?}",
drift.forward.changes
);
}
}