use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::generation::manifest::CatalogManifest;
use crate::migration::diff::{ChangeKind, ChangeSafety, diff_manifests};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DriftSeverity {
Critical,
Warning,
#[default]
Info,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DriftItem {
pub severity: DriftSeverity,
pub kind: String,
pub schema: String,
pub table: String,
pub column: String,
pub object_name: String,
pub description: String,
pub suggestion: String,
pub blocked_reason: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DriftReport {
pub generated_at_unix: u64,
pub old_checksum: String,
pub new_checksum: String,
pub has_drift: bool,
pub total_issues: usize,
pub critical: usize,
pub warnings: usize,
pub info: usize,
pub auto_safe_count: usize,
pub requires_review_count: usize,
pub blocked_count: usize,
pub hint_warnings: usize,
pub proto_table_count: usize,
pub store_count: usize,
pub items: Vec<DriftItem>,
}
pub fn build_drift_report(old: Option<&CatalogManifest>, new: &CatalogManifest) -> DriftReport {
let changes = diff_manifests(old, new);
let old_checksum = old.map(|m| m.checksum_sha256.clone()).unwrap_or_default();
let has_drift = old
.map(|o| o.checksum_sha256 != new.checksum_sha256)
.unwrap_or(true);
let auto_safe_count = changes
.iter()
.filter(|c| c.safety == ChangeSafety::SafeAuto && c.kind != ChangeKind::HintWarning)
.count();
let requires_review_count = changes
.iter()
.filter(|c| c.safety == ChangeSafety::RequiresReview && c.kind != ChangeKind::HintWarning)
.count();
let blocked_count = changes
.iter()
.filter(|c| c.safety == ChangeSafety::Blocked)
.count();
let hint_warnings = changes
.iter()
.filter(|c| c.kind == ChangeKind::HintWarning)
.count();
let mut items: Vec<DriftItem> = changes
.iter()
.map(|change| {
let severity = classify_severity(change);
let suggestion = suggest_for_kind(&change.kind);
DriftItem {
severity,
kind: kind_label(&change.kind),
schema: change.schema.clone(),
table: change.table.clone(),
column: change.column.clone(),
object_name: change.object_name.clone(),
description: change.reason.clone(),
suggestion,
blocked_reason: change.blocked_reason.clone(),
fingerprint: change.fingerprint.clone(),
}
})
.collect();
for error in &new.validation_errors {
items.push(DriftItem {
severity: DriftSeverity::Critical,
kind: "manifest_validation_error".to_string(),
description: error.clone(),
suggestion: "Fix the referenced schema issue before applying migrations.".to_string(),
..DriftItem::default()
});
}
items.sort_by(|a, b| {
severity_ord(&a.severity)
.cmp(&severity_ord(&b.severity))
.then(a.schema.cmp(&b.schema))
.then(a.table.cmp(&b.table))
.then(a.kind.cmp(&b.kind))
});
let critical = items
.iter()
.filter(|i| i.severity == DriftSeverity::Critical)
.count();
let warnings = items
.iter()
.filter(|i| i.severity == DriftSeverity::Warning)
.count();
let info = items
.iter()
.filter(|i| i.severity == DriftSeverity::Info)
.count();
DriftReport {
generated_at_unix: generated_at_unix(),
old_checksum,
new_checksum: new.checksum_sha256.clone(),
has_drift,
total_issues: items.len(),
critical,
warnings,
info,
auto_safe_count,
requires_review_count,
blocked_count,
hint_warnings,
proto_table_count: new.tables.len(),
store_count: new.stores.len(),
items,
}
}
fn classify_severity(change: &crate::migration::diff::ChangeOperation) -> DriftSeverity {
use ChangeKind::*;
match (&change.kind, &change.safety) {
(ValidationError, _) => DriftSeverity::Critical,
(_, ChangeSafety::Blocked) => DriftSeverity::Critical,
(HintWarning, _) => DriftSeverity::Warning,
(_, ChangeSafety::RequiresReview) => DriftSeverity::Warning,
_ => DriftSeverity::Info,
}
}
fn suggest_for_kind(kind: &ChangeKind) -> String {
use ChangeKind::*;
match kind {
DropTable => {
"Set `allow_drop: true` on the table option after explicit review, then re-run the migration planner.".to_string()
}
DropColumn => {
"Set `allow_drop: true` on the column option after explicit review, then re-run the migration planner.".to_string()
}
ChangeColumnType => {
"Add `using_expression` to the column option to provide a safe USING cast expression.".to_string()
}
HintWarning => {
"Verify migration hints; remove stale `previous_table_name` / `previous_column_name` after the migration is applied.".to_string()
}
DisableRls => {
"Disabling RLS requires explicit migration approval — confirm no active tenant-isolation policies depend on it.".to_string()
}
SetTableUnlogged | SetTableLogged => {
"Changing table persistence (LOGGED/UNLOGGED) requires explicit review — data may be lost on crash for UNLOGGED tables.".to_string()
}
_ => String::new(),
}
}
fn kind_label(kind: &ChangeKind) -> String {
let s = format!("{kind:?}");
let mut out = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
out.push('_');
}
out.extend(ch.to_lowercase());
}
out
}
fn severity_ord(s: &DriftSeverity) -> u8 {
match s {
DriftSeverity::Critical => 0,
DriftSeverity::Warning => 1,
DriftSeverity::Info => 2,
}
}
fn generated_at_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default()
}