arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! Migration lint finding types and the [`LintReport`] (PROGRAM.md AP2.1-6).
//!
//! A [`Finding`] classifies one real PostgreSQL migration risk in a single
//! SQL statement. [`LintReport`] aggregates the findings for a set of
//! statements and is serializable for `arc db lint --json`.

use std::fmt;

use serde::Serialize;

/// The severity of a migration lint finding.
///
/// Severity is a deployment-safety ranking, not a linter style nit:
/// `Critical` findings can cause data loss or block a rolling deployment;
/// `Warning` findings require operator review before applying; `Info`
/// findings are surfaced for awareness but are not inherently unsafe.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LintSeverity {
    /// A destructive or deployment-blocking change (e.g. `DROP TABLE`,
    /// `ADD COLUMN ... NOT NULL` without a `DEFAULT`). Data loss or a
    /// rolling-upgrade failure is possible.
    Critical,
    /// A risky change requiring operator review before applying (e.g. a
    /// blocking `CREATE INDEX`, a `RENAME`, a `SET NOT NULL`, an
    /// `ALTER COLUMN ... TYPE`).
    Warning,
    /// A change surfaced for awareness (e.g. a type cast that may narrow).
    Info,
}

impl fmt::Display for LintSeverity {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Critical => write!(formatter, "critical"),
            Self::Warning => write!(formatter, "warning"),
            Self::Info => write!(formatter, "info"),
        }
    }
}

/// A category of real PostgreSQL migration risk (PROGRAM.md AP2.1-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LintCategory {
    /// `DROP TABLE` / `DROP COLUMN` / `DROP INDEX` / `DROP SCHEMA` /
    /// `DROP CONSTRAINT` — destructive, irreversible data loss.
    DestructiveDrop,
    /// `ADD COLUMN ... NOT NULL` without a `DEFAULT` — fails on existing rows
    /// and breaks rolling upgrades (old app instances still write the column).
    AddNotNullNoDefault,
    /// `ALTER ... SET NOT NULL` without a preceding default — table rewrite,
    /// fails on existing `NULL` values, blocks writes during the rewrite.
    UnsafeNotNull,
    /// `ALTER COLUMN ... TYPE` — may narrow the value domain or rewrite the
    /// table; requires review for compatibility.
    TypeNarrowing,
    /// `RENAME TABLE` / `RENAME COLUMN` — breaks code referencing the old name
    /// and complicates rolling upgrades.
    DangerousRename,
    /// `CREATE INDEX` (without `CONCURRENTLY`) — blocks writes for the
    /// duration of the build; a rolling-upgrade hazard on large tables.
    BlockingIndex,
}

impl fmt::Display for LintCategory {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let name = match self {
            Self::DestructiveDrop => "destructive-drop",
            Self::AddNotNullNoDefault => "add-not-null-no-default",
            Self::UnsafeNotNull => "unsafe-not-null",
            Self::TypeNarrowing => "type-narrowing",
            Self::DangerousRename => "dangerous-rename",
            Self::BlockingIndex => "blocking-index",
        };
        write!(formatter, "{name}")
    }
}

/// One migration lint finding: a classified risk in a single statement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Finding {
    /// The severity (deployment-safety ranking).
    pub severity: LintSeverity,
    /// The risk category.
    pub category: LintCategory,
    /// A human-readable explanation of the risk.
    pub message: String,
    /// A concrete fix hint or mitigation (what to do instead).
    pub fix: String,
    /// The 0-based index of the statement in the analyzed set.
    pub statement: usize,
}

/// The aggregated lint report for a set of SQL statements.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct LintReport {
    /// The findings, in statement order.
    pub findings: Vec<Finding>,
    /// The number of statements analyzed.
    pub statements: usize,
}

impl LintReport {
    /// `true` when there are no findings.
    #[must_use]
    pub fn is_clean(&self) -> bool {
        self.findings.is_empty()
    }

    /// The count of findings at the given severity.
    #[must_use]
    pub fn count_at(&self, severity: LintSeverity) -> usize {
        self.findings
            .iter()
            .filter(|finding| finding.severity == severity)
            .count()
    }

    /// `true` when at least one critical finding is present.
    #[must_use]
    pub fn has_critical(&self) -> bool {
        self.count_at(LintSeverity::Critical) > 0
    }
}

impl fmt::Display for LintReport {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.findings.is_empty() {
            return write!(
                formatter,
                "no migration lint findings ({} statements analyzed)",
                self.statements
            );
        }
        writeln!(
            formatter,
            "{} finding(s) across {} statement(s):",
            self.findings.len(),
            self.statements
        )?;
        for finding in &self.findings {
            writeln!(
                formatter,
                "  statement #{} [{}] {}: {}",
                finding.statement, finding.severity, finding.category, finding.message,
            )?;
            writeln!(formatter, "    fix: {}", finding.fix)?;
        }
        Ok(())
    }
}