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}