use std::path::Path;
use autumn_schema_core::{Backend, ColumnType, Table};
use diesel_migrations::FileBasedMigrations;
use serde::Serialize;
use super::diff::{DiffOptions, MigrationPlan, SchemaChange, diff_schema};
use super::introspect::introspect_postgres;
#[cfg(feature = "sqlite")]
use super::introspect::introspect_sqlite;
use super::parse::{ParsedSchema, parse_models_path};
use super::schema_rs::{SCHEMA_RS_PATH, SchemaRsCheck, check_tables};
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)]
enum SchemaRsState {
NoFile,
Unreadable(String),
NoManaged,
Checked(SchemaRsCheck),
ModelsError(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum UnmanagedState {
NotConfigured,
#[cfg_attr(feature = "sqlite", allow(dead_code))]
Skipped(String),
NoUnmanaged,
ModelsError(String),
Unreachable(String),
Checked(UnmanagedDrift),
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct UnmanagedDrift {
drifted: Vec<(String, usize)>,
unchecked: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalFacts {
cargo_toml_present: bool,
autumn_dir_present: bool,
snapshot: SnapshotState,
detected_backend: Backend,
url_backend: Option<Backend>,
schema_rs: SchemaRsState,
}
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, unmanaged) = gather_db_facts(&project_root, profile, &local);
let checks = compute_checks(&local, &pending, &db_schema, &unmanaged);
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);
let schema_rs = probe_schema_rs(project_root, detected_backend);
LocalFacts {
cargo_toml_present,
autumn_dir_present,
snapshot,
detected_backend,
url_backend,
schema_rs,
}
}
fn probe_schema_rs(project_root: &Path, backend: Backend) -> SchemaRsState {
let Some(models_path) = super::existing_models_path(project_root) else {
return SchemaRsState::NoManaged;
};
let desired = match parse_models_path(&models_path, backend) {
Ok(desired) => desired,
Err(e) => return SchemaRsState::ModelsError(e.to_string()),
};
if !desired.tables.iter().any(|t| t.managed) {
return SchemaRsState::NoManaged;
}
match std::fs::read_to_string(project_root.join(SCHEMA_RS_PATH)) {
Ok(existing) => SchemaRsState::Checked(check_tables(&existing, &desired)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => SchemaRsState::NoFile,
Err(e) => SchemaRsState::Unreadable(e.to_string()),
}
}
fn compute_unmanaged_drift(models: &ParsedSchema, db: &[Table]) -> UnmanagedDrift {
let mut unchecked: Vec<String> = models
.tables
.iter()
.filter(|t| !t.managed && models.diagnostics.iter().any(|d| d.table == t.name))
.map(|t| t.name.clone())
.collect();
unchecked.dedup();
let drifted = models
.tables
.iter()
.filter(|t| !t.managed)
.filter_map(|model| {
let mut want = model.clone();
want.managed = true;
want.columns.retain(|c| {
!models
.implicit_columns
.iter()
.any(|(t, col)| *t == model.name && *col == c.name)
});
let base: Vec<Table> = db
.iter()
.filter(|t| t.name == model.name)
.map(|t| Table {
managed: true,
..t.clone()
})
.collect();
let plan = diff_schema(
&base,
&ParsedSchema::from_tables(vec![want]),
DiffOptions::default(),
);
let missing = plan.changes.iter().any(|c| {
matches!(
c,
SchemaChange::CreateTable(_)
| SchemaChange::CreateTableBlockedBySkippedField { .. }
)
});
let count = if missing {
1
} else {
plan.changes
.iter()
.filter(|c| match c {
SchemaChange::AlterColumnType { from, .. } => {
!matches!(from, ColumnType::Opaque { .. })
}
SchemaChange::AddColumn { .. }
| SchemaChange::PrimaryKeyChange { .. }
| SchemaChange::SetNotNull { .. }
| SchemaChange::DropNotNull { .. } => true,
_ => false,
})
.count()
};
(count > 0).then(|| (model.name.clone(), count))
})
.collect();
UnmanagedDrift { drifted, unchecked }
}
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_facts(
project_root: &Path,
profile: Option<&str>,
local: &LocalFacts,
) -> (DbSchemaState, UnmanagedState) {
let Some(url) = crate::migrate::resolve_primary_url(profile) else {
return (DbSchemaState::NotConfigured, UnmanagedState::NotConfigured);
};
let backend = local.detected_backend;
match backend {
Backend::Postgres => gather_db_facts_with(project_root, &url, backend, introspect_postgres),
#[cfg(feature = "sqlite")]
Backend::Sqlite => gather_db_facts_with(project_root, &url, backend, introspect_sqlite),
#[cfg(not(feature = "sqlite"))]
Backend::Sqlite => {
let reason = format!(
"database checks are Postgres-only on this build; detected {backend:?} backend \
(rebuild with `--features sqlite` to check SQLite drift)"
);
(
DbSchemaState::Skipped(reason.clone()),
UnmanagedState::Skipped(reason),
)
}
}
}
fn gather_db_facts_with(
project_root: &Path,
url: &str,
backend: Backend,
introspect: impl Fn(&str) -> Result<Vec<Table>, super::introspect::IntrospectError>,
) -> (DbSchemaState, UnmanagedState) {
let snapshot = load_snapshot(&project_root.join(SNAPSHOT_DEFAULT_PATH)).ok();
let (models, models_error) = match super::existing_models_path(project_root)
.map(|path| parse_models_path(&path, backend))
{
Some(Ok(models)) if models.tables.iter().any(|t| !t.managed) => (Some(models), None),
Some(Err(e)) => (None, Some(UnmanagedState::ModelsError(e.to_string()))),
_ => (None, None),
};
if snapshot.is_none() && models.is_none() {
return (
DbSchemaState::SnapshotMissing,
models_error.unwrap_or(UnmanagedState::NoUnmanaged),
);
}
let read = introspect(url).map_err(|e| e.to_string()); let schema = match (&snapshot, &read) {
(None, _) => DbSchemaState::SnapshotMissing,
(Some(_), Err(e)) => DbSchemaState::Unreachable(e.clone()),
(Some(snapshot), Ok(tables)) => {
let drift = compute_db_schema_drift(&snapshot.tables, tables);
if drift.is_clean() {
DbSchemaState::Clean
} else {
DbSchemaState::Drifted(drift.reported_count())
}
}
};
let unmanaged = match (models, read) {
(None, _) => models_error.unwrap_or(UnmanagedState::NoUnmanaged),
(Some(_), Err(e)) => UnmanagedState::Unreachable(e),
(Some(models), Ok(tables)) => {
UnmanagedState::Checked(compute_unmanaged_drift(&models, &tables))
}
};
(schema, unmanaged)
}
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,
unmanaged: &UnmanagedState,
) -> 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(schema_rs_check(&local.schema_rs));
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.push(unmanaged_check(unmanaged));
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 schema_rs_check(state: &SchemaRsState) -> Check {
let (status, detail) = match state {
SchemaRsState::Checked(check) => {
let mut parts = Vec::new();
if !check.stale.is_empty() {
parts.push(format!(
"missing or stale block for {}",
check.stale.join(", ")
));
}
if check.stale_macros {
parts.push("a stale `joinable!` or table list".to_owned());
}
if !parts.is_empty() {
parts.push("run `autumn schema diff --write-migration`".to_owned());
}
if !check.unchecked.is_empty() {
let unchecked: Vec<String> = check
.unchecked
.iter()
.map(|(table, reason)| format!("{table} ({reason})"))
.collect();
parts.push(format!("not checked: {}", unchecked.join(", ")));
}
if parts.is_empty() {
(Status::Ok, "the managed blocks match".to_owned())
} else {
(Status::Warn, parts.join("; "))
}
}
SchemaRsState::NoManaged => (
Status::Ok,
"no managed model — nothing to compare".to_owned(),
),
SchemaRsState::NoFile => (Status::Warn, format!("no {SCHEMA_RS_PATH} — skipped")),
SchemaRsState::Unreadable(reason) => (
Status::Warn,
format!("could not read {SCHEMA_RS_PATH}: {reason}"),
),
SchemaRsState::ModelsError(reason) => {
(Status::Warn, format!("could not read the models: {reason}"))
}
};
Check {
name: "schema-rs-drift".to_owned(),
status,
detail,
}
}
fn unmanaged_check(state: &UnmanagedState) -> Check {
let (status, detail) = match state {
UnmanagedState::Checked(drift) => {
let note = if drift.unchecked.is_empty() {
String::new()
} else {
format!(
"; not checked: {} (a field the parser cannot read)",
drift.unchecked.join(", ")
)
};
if drift.drifted.is_empty() && drift.unchecked.is_empty() {
(
Status::Ok,
"the unmanaged models match their tables".to_owned(),
)
} else if drift.drifted.is_empty() {
(
Status::Warn,
format!("the other unmanaged models match their tables{note}"),
)
} else {
let list: Vec<String> = drift
.drifted
.iter()
.map(|(t, n)| format!("{t} ({n})"))
.collect();
(
Status::Warn,
format!(
"unmanaged model(s) differ from the database: {} — write a migration \
with `autumn generate migration`, or change the model{note}",
list.join(", ")
),
)
}
}
UnmanagedState::NoUnmanaged => (Status::Ok, "no unmanaged model".to_owned()),
UnmanagedState::NotConfigured => {
(Status::Warn, "no database configured — skipped".to_owned())
}
UnmanagedState::Unreachable(reason) => (
Status::Warn,
format!("could not reach the database: {reason}"),
),
UnmanagedState::Skipped(reason) => (Status::Warn, reason.clone()),
UnmanagedState::ModelsError(reason) => {
(Status::Warn, format!("could not read the models: {reason}"))
}
};
Check {
name: "unmanaged-drift".to_owned(),
status,
detail,
}
}
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,
&UnmanagedState::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,
&UnmanagedState::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,
&UnmanagedState::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),
schema_rs: SchemaRsState::NoFile,
};
let checks = compute_checks(
&local,
&PendingState::NotConfigured,
&DbSchemaState::NotConfigured,
&UnmanagedState::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,
&UnmanagedState::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,
&UnmanagedState::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,
&UnmanagedState::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,
&UnmanagedState::NotConfigured,
);
let names: Vec<&str> = checks.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec![
"project-root",
"snapshot-present",
"snapshot-drift",
"schema-rs-drift",
"provider-lock",
"snapshot-dialect-vs-db",
"pending-migrations",
"database-schema-drift",
"unmanaged-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,
&UnmanagedState::NotConfigured,
);
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_fk_action_change() {
use autumn_schema_core::{Column, ColumnType, ForeignKey, ForeignKeyAction};
let build = |action: Option<ForeignKeyAction>| {
let mut table = drift_posts_table(None);
let mut author = Column::new("author_id".to_string(), ColumnType::Int64);
author.references = Some(ForeignKey::new("users", "id").with_on_delete(action));
table.columns.push(author);
table
};
let snapshot = vec![build(None)];
let db = vec![build(Some(ForeignKeyAction::Cascade))];
for drift in [
compute_db_schema_drift(&snapshot, &db),
compute_db_schema_drift(&db, &snapshot),
] {
assert!(!drift.is_clean(), "a changed action 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
);
}
const POSTS_BLOCK: &str = "diesel::table! {
posts (id) {
id -> Int8,
title -> Text,
created_at -> Timestamp,
}
}
";
fn schema_rs_row(root: &Path) -> Check {
let local = gather_local_facts(root, None);
schema_rs_check(&local.schema_rs)
}
#[test]
fn schema_rs_drift_is_ok_when_each_managed_block_matches() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
std::fs::write(root.path().join("src/schema.rs"), POSTS_BLOCK).unwrap();
let row = schema_rs_row(root.path());
assert_eq!(row.name, "schema-rs-drift");
assert_eq!(row.status, Status::Ok, "{row:?}");
}
#[test]
fn schema_rs_drift_warns_on_a_stale_or_missing_block() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
std::fs::write(
root.path().join("src/schema.rs"),
"diesel::table! {\n posts (id) {\n id -> Int8,\n }\n}\n",
)
.unwrap();
let row = schema_rs_row(root.path());
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(row.detail.contains("posts"), "{row:?}");
assert!(
row.detail.contains("autumn schema diff --write-migration"),
"{row:?}"
);
}
#[test]
fn schema_rs_drift_skips_without_a_file_and_passes_without_managed_models() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
let row = schema_rs_row(root.path());
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(row.detail.contains("no src/schema.rs"), "{row:?}");
let unmanaged = POST_MODEL.replace("model(managed)", "model");
let root = scaffold(&unmanaged, Some(&posts_snapshot("Postgres")));
let row = schema_rs_row(root.path());
assert_eq!(row.status, Status::Ok, "{row:?}");
assert!(row.detail.contains("no managed model"), "{row:?}");
}
fn unmanaged_posts() -> ParsedSchema {
let unmanaged = POST_MODEL.replace("model(managed)", "model");
crate::schema::parse::parse_model_source(&unmanaged, Backend::Postgres).expect("parse")
}
fn db_posts() -> Table {
let mut t = unmanaged_posts().tables.remove(0);
t.managed = true;
t
}
#[test]
fn unmanaged_drift_is_empty_when_the_table_matches() {
assert!(
compute_unmanaged_drift(&unmanaged_posts(), &[db_posts()])
.drifted
.is_empty()
);
}
#[test]
fn unmanaged_drift_reports_a_missing_table_column_type_or_null_rule() {
let models = unmanaged_posts();
assert_eq!(
compute_unmanaged_drift(&models, &[]).drifted,
vec![("posts".to_owned(), 1)]
);
let mut no_title = db_posts();
no_title.columns.retain(|c| c.name != "title");
assert_eq!(
compute_unmanaged_drift(&models, &[no_title]).drifted,
vec![("posts".to_owned(), 1)]
);
let mut other = db_posts();
let title = other
.columns
.iter_mut()
.find(|c| c.name == "title")
.unwrap();
title.ty = autumn_schema_core::ColumnType::Int32;
title.nullable = true;
assert_eq!(
compute_unmanaged_drift(&models, &[other]).drifted,
vec![("posts".to_owned(), 2)]
);
}
#[test]
fn unmanaged_drift_ignores_table_only_columns_indexes_and_managed_models() {
let models = unmanaged_posts();
let mut wider = db_posts();
wider.columns.push(autumn_schema_core::Column::new(
"search_vector",
autumn_schema_core::ColumnType::Text,
));
wider.indexes.push(autumn_schema_core::Index::new(
"idx_posts_title",
vec!["title".to_owned()],
false,
));
assert!(
compute_unmanaged_drift(&models, &[wider])
.drifted
.is_empty()
);
let managed =
crate::schema::parse::parse_model_source(POST_MODEL, Backend::Postgres).expect("parse");
assert!(compute_unmanaged_drift(&managed, &[]).drifted.is_empty());
}
#[test]
fn unmanaged_drift_states_map_to_expected_status() {
let row = unmanaged_check(&UnmanagedState::Checked(UnmanagedDrift::default()));
assert_eq!(
(row.name.as_str(), row.status),
("unmanaged-drift", Status::Ok)
);
let row = unmanaged_check(&UnmanagedState::Checked(UnmanagedDrift {
drifted: vec![("posts".to_owned(), 2)],
unchecked: Vec::new(),
}));
assert_eq!(row.status, Status::Warn);
assert!(row.detail.contains("posts (2)"), "{row:?}");
assert!(row.detail.contains("autumn generate migration"), "{row:?}");
for state in [
UnmanagedState::NotConfigured,
UnmanagedState::Unreachable("refused".to_owned()),
UnmanagedState::Skipped("sqlite".to_owned()),
] {
assert_eq!(unmanaged_check(&state).status, Status::Warn, "{state:?}");
}
assert_eq!(
unmanaged_check(&UnmanagedState::NoUnmanaged).status,
Status::Ok
);
}
#[test]
fn one_introspection_feeds_both_database_rows() {
let unmanaged = POST_MODEL.replace("model(managed)", "model");
let root = scaffold(&unmanaged, None);
let (schema, models) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
Ok(Vec::new())
});
assert_eq!(schema, DbSchemaState::SnapshotMissing);
assert_eq!(
models,
UnmanagedState::Checked(UnmanagedDrift {
drifted: vec![("posts".to_owned(), 1)],
unchecked: Vec::new(),
})
);
let root = scaffold(&unmanaged, Some(&posts_snapshot("Postgres")));
let (schema, models) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
Ok(vec![db_posts()])
});
assert_eq!(schema, DbSchemaState::Clean);
assert_eq!(models, UnmanagedState::Checked(UnmanagedDrift::default()));
let (schema, models) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
Err(crate::schema::introspect::IntrospectError::Query(
"refused".to_owned(),
))
});
assert!(
matches!(schema, DbSchemaState::Unreachable(_)),
"{schema:?}"
);
assert!(
matches!(models, UnmanagedState::Unreachable(_)),
"{models:?}"
);
let root = scaffold(POST_MODEL, None);
let (schema, models) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
panic!("must not connect")
});
assert_eq!(schema, DbSchemaState::SnapshotMissing);
assert_eq!(models, UnmanagedState::NoUnmanaged);
}
#[test]
fn unmanaged_drift_ignores_the_implicit_created_at() {
let models = unmanaged_posts();
let mut db = db_posts();
db.columns.retain(|c| c.name != "created_at");
assert!(compute_unmanaged_drift(&models, &[db]).drifted.is_empty());
let declared = crate::schema::parse::parse_model_source(
"#[model] pub struct Post { #[id] pub id: i64, pub created_at: chrono::NaiveDateTime }",
Backend::Postgres,
)
.expect("parse");
let mut db = db_posts();
db.columns.retain(|c| c.name != "created_at");
assert_eq!(
compute_unmanaged_drift(&declared, &[db]).drifted,
vec![("posts".to_owned(), 1)]
);
}
#[test]
fn schema_rs_drift_names_unchecked_tables() {
let check = SchemaRsCheck {
stale: Vec::new(),
unchecked: vec![("posts".to_owned(), "enum field".to_owned())],
stale_macros: false,
};
let row = schema_rs_check(&SchemaRsState::Checked(check));
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(
row.detail.contains("not checked: posts (enum field)"),
"{row:?}"
);
}
#[test]
fn unmanaged_drift_ignores_an_opaque_database_type() {
let mut db = db_posts();
let title = db.columns.iter_mut().find(|c| c.name == "title").unwrap();
title.ty = autumn_schema_core::ColumnType::Opaque {
pg_type: "varchar(40)".to_owned(),
};
assert!(
compute_unmanaged_drift(&unmanaged_posts(), &[db])
.drifted
.is_empty()
);
}
#[test]
fn database_rows_keep_their_reason_when_the_database_is_down() {
let unmanaged = POST_MODEL.replace("model(managed)", "model");
let root = scaffold(&unmanaged, None);
let (schema, _) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
Err(crate::schema::introspect::IntrospectError::Query(
"refused".to_owned(),
))
});
assert_eq!(schema, DbSchemaState::SnapshotMissing);
let root = scaffold("pub struct {", Some(&posts_snapshot("Postgres")));
let (_, models) =
gather_db_facts_with(root.path(), "postgres://db", Backend::Postgres, |_| {
Ok(vec![db_posts()])
});
assert!(
matches!(models, UnmanagedState::ModelsError(_)),
"{models:?}"
);
assert_eq!(unmanaged_check(&models).status, Status::Warn);
}
#[test]
fn unmanaged_drift_names_a_model_with_a_skipped_field() {
let models = crate::schema::parse::parse_model_source(
"#[model] pub struct Post { #[id] pub id: i64, pub title: String, pub status: PostStatus }",
Backend::Postgres,
)
.expect("parse");
let drift = compute_unmanaged_drift(&models, &[db_posts()]);
assert!(drift.drifted.is_empty(), "{drift:?}");
assert_eq!(drift.unchecked, vec!["posts".to_owned()]);
let row = unmanaged_check(&UnmanagedState::Checked(drift));
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(row.detail.contains("not checked: posts"), "{row:?}");
}
#[test]
fn schema_rs_drift_reports_an_unreadable_file() {
let root = scaffold(POST_MODEL, Some(&posts_snapshot("Postgres")));
std::fs::create_dir_all(root.path().join("src/schema.rs")).unwrap();
let row = schema_rs_row(root.path());
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(
row.detail.contains("could not read src/schema.rs"),
"{row:?}"
);
}
#[test]
fn schema_rs_drift_warns_on_stale_macros_alone() {
let check = SchemaRsCheck {
stale_macros: true,
..SchemaRsCheck::default()
};
let row = schema_rs_check(&SchemaRsState::Checked(check));
assert_eq!(row.status, Status::Warn, "{row:?}");
assert!(row.detail.contains("joinable!"), "{row:?}");
}
#[test]
fn unmanaged_drift_counts_a_primary_key_change() {
let mut db = db_posts();
db.primary_key = vec!["title".to_owned()];
for c in &mut db.columns {
c.primary_key = c.name == "title";
c.serial = None;
}
assert_eq!(
compute_unmanaged_drift(&unmanaged_posts(), &[db]).drifted,
vec![("posts".to_owned(), 1)]
);
}
}