Skip to main content

arcature_data/lint/
finding.rs

1//! Migration lint finding types and the [`LintReport`] (PROGRAM.md AP2.1-6).
2//!
3//! A [`Finding`] classifies one real PostgreSQL migration risk in a single
4//! SQL statement. [`LintReport`] aggregates the findings for a set of
5//! statements and is serializable for `arc db lint --json`.
6
7use std::fmt;
8
9use serde::Serialize;
10
11/// The severity of a migration lint finding.
12///
13/// Severity is a deployment-safety ranking, not a linter style nit:
14/// `Critical` findings can cause data loss or block a rolling deployment;
15/// `Warning` findings require operator review before applying; `Info`
16/// findings are surfaced for awareness but are not inherently unsafe.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum LintSeverity {
20    /// A destructive or deployment-blocking change (e.g. `DROP TABLE`,
21    /// `ADD COLUMN ... NOT NULL` without a `DEFAULT`). Data loss or a
22    /// rolling-upgrade failure is possible.
23    Critical,
24    /// A risky change requiring operator review before applying (e.g. a
25    /// blocking `CREATE INDEX`, a `RENAME`, a `SET NOT NULL`, an
26    /// `ALTER COLUMN ... TYPE`).
27    Warning,
28    /// A change surfaced for awareness (e.g. a type cast that may narrow).
29    Info,
30}
31
32impl fmt::Display for LintSeverity {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::Critical => write!(formatter, "critical"),
36            Self::Warning => write!(formatter, "warning"),
37            Self::Info => write!(formatter, "info"),
38        }
39    }
40}
41
42/// A category of real PostgreSQL migration risk (PROGRAM.md AP2.1-6).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum LintCategory {
46    /// `DROP TABLE` / `DROP COLUMN` / `DROP INDEX` / `DROP SCHEMA` /
47    /// `DROP CONSTRAINT` — destructive, irreversible data loss.
48    DestructiveDrop,
49    /// `ADD COLUMN ... NOT NULL` without a `DEFAULT` — fails on existing rows
50    /// and breaks rolling upgrades (old app instances still write the column).
51    AddNotNullNoDefault,
52    /// `ALTER ... SET NOT NULL` without a preceding default — table rewrite,
53    /// fails on existing `NULL` values, blocks writes during the rewrite.
54    UnsafeNotNull,
55    /// `ALTER COLUMN ... TYPE` — may narrow the value domain or rewrite the
56    /// table; requires review for compatibility.
57    TypeNarrowing,
58    /// `RENAME TABLE` / `RENAME COLUMN` — breaks code referencing the old name
59    /// and complicates rolling upgrades.
60    DangerousRename,
61    /// `CREATE INDEX` (without `CONCURRENTLY`) — blocks writes for the
62    /// duration of the build; a rolling-upgrade hazard on large tables.
63    BlockingIndex,
64}
65
66impl fmt::Display for LintCategory {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        let name = match self {
69            Self::DestructiveDrop => "destructive-drop",
70            Self::AddNotNullNoDefault => "add-not-null-no-default",
71            Self::UnsafeNotNull => "unsafe-not-null",
72            Self::TypeNarrowing => "type-narrowing",
73            Self::DangerousRename => "dangerous-rename",
74            Self::BlockingIndex => "blocking-index",
75        };
76        write!(formatter, "{name}")
77    }
78}
79
80/// One migration lint finding: a classified risk in a single statement.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct Finding {
83    /// The severity (deployment-safety ranking).
84    pub severity: LintSeverity,
85    /// The risk category.
86    pub category: LintCategory,
87    /// A human-readable explanation of the risk.
88    pub message: String,
89    /// A concrete fix hint or mitigation (what to do instead).
90    pub fix: String,
91    /// The 0-based index of the statement in the analyzed set.
92    pub statement: usize,
93}
94
95/// The aggregated lint report for a set of SQL statements.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
97pub struct LintReport {
98    /// The findings, in statement order.
99    pub findings: Vec<Finding>,
100    /// The number of statements analyzed.
101    pub statements: usize,
102}
103
104impl LintReport {
105    /// `true` when there are no findings.
106    #[must_use]
107    pub fn is_clean(&self) -> bool {
108        self.findings.is_empty()
109    }
110
111    /// The count of findings at the given severity.
112    #[must_use]
113    pub fn count_at(&self, severity: LintSeverity) -> usize {
114        self.findings
115            .iter()
116            .filter(|finding| finding.severity == severity)
117            .count()
118    }
119
120    /// `true` when at least one critical finding is present.
121    #[must_use]
122    pub fn has_critical(&self) -> bool {
123        self.count_at(LintSeverity::Critical) > 0
124    }
125}
126
127impl fmt::Display for LintReport {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        if self.findings.is_empty() {
130            return write!(
131                formatter,
132                "no migration lint findings ({} statements analyzed)",
133                self.statements
134            );
135        }
136        writeln!(
137            formatter,
138            "{} finding(s) across {} statement(s):",
139            self.findings.len(),
140            self.statements
141        )?;
142        for finding in &self.findings {
143            writeln!(
144                formatter,
145                "  statement #{} [{}] {}: {}",
146                finding.statement, finding.severity, finding.category, finding.message,
147            )?;
148            writeln!(formatter, "    fix: {}", finding.fix)?;
149        }
150        Ok(())
151    }
152}