Skip to main content

safe_migrate/_internal/analysis/
evidence.rs

1use serde::Serialize;
2
3/// Stable, machine-readable causes for conservative analysis.
4///
5/// New variants are additive. Callers should use the serialized snake-case
6/// value as the compatibility contract rather than matching display text.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum EvidenceCode {
10    BaselineUnavailable,
11    BaselineStale,
12    CatalogCoverageIncomplete,
13    UnsupportedStatement,
14    UnsupportedSemantics,
15    UnresolvedReference,
16    UnknownObjectState,
17    TransactionStateUnknown,
18    UnmodeledState,
19}
20
21impl EvidenceCode {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::BaselineUnavailable => "baseline_unavailable",
25            Self::BaselineStale => "baseline_stale",
26            Self::CatalogCoverageIncomplete => "catalog_coverage_incomplete",
27            Self::UnsupportedStatement => "unsupported_statement",
28            Self::UnsupportedSemantics => "unsupported_semantics",
29            Self::UnresolvedReference => "unresolved_reference",
30            Self::UnknownObjectState => "unknown_object_state",
31            Self::TransactionStateUnknown => "transaction_state_unknown",
32            Self::UnmodeledState => "unmodeled_state",
33        }
34    }
35
36    pub const fn summary(self) -> &'static str {
37        match self {
38            Self::BaselineUnavailable => "no synchronized baseline was available",
39            Self::BaselineStale => "the synchronized baseline may be stale",
40            Self::CatalogCoverageIncomplete => {
41                "the synchronized catalog does not contain all required evidence"
42            }
43            Self::UnsupportedStatement => "the statement has no typed semantic model",
44            Self::UnsupportedSemantics => "the statement contains unsupported semantics",
45            Self::UnresolvedReference => "an object reference could not be resolved exactly",
46            Self::UnknownObjectState => "the required object state is unknown",
47            Self::TransactionStateUnknown => "transaction state cannot be modeled exactly",
48            Self::UnmodeledState => {
49                "required PostgreSQL state is deliberately outside the semantic model"
50            }
51        }
52    }
53}
54
55/// Whether uncertainty affects only one transition or subsequent statements.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum EvidenceScope {
59    Statement,
60    Chain,
61}
62
63/// Safe source context attached by the engine while it evaluates a statement.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
65pub struct EvidenceLocation {
66    pub file: String,
67    pub statement_index: usize,
68}
69
70/// One durable reason why the analyzer had to be conservative.
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
72pub struct EvidenceRecord {
73    pub code: EvidenceCode,
74    pub scope: EvidenceScope,
75    pub summary: &'static str,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub location: Option<EvidenceLocation>,
78}
79
80impl EvidenceRecord {
81    pub fn new(code: EvidenceCode, scope: EvidenceScope) -> Self {
82        Self {
83            code,
84            scope,
85            summary: code.summary(),
86            location: None,
87        }
88    }
89
90    pub fn at(mut self, location: EvidenceLocation) -> Self {
91        self.location = Some(location);
92        self
93    }
94}
95
96/// Ordered, deduplicated evidence carried by analysis state.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub struct EvidenceLog {
99    records: Vec<EvidenceRecord>,
100}
101
102impl EvidenceLog {
103    pub fn records(&self) -> &[EvidenceRecord] {
104        &self.records
105    }
106
107    pub fn contains(&self, record: &EvidenceRecord) -> bool {
108        self.records.contains(record)
109    }
110
111    /// Returns whether the record was newly inserted.
112    pub fn insert(&mut self, record: EvidenceRecord) -> bool {
113        if self.contains(&record) {
114            return false;
115        }
116        self.records.push(record);
117        self.records.sort();
118        true
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn evidence_is_deduplicated_and_stably_ordered() {
128        let mut log = EvidenceLog::default();
129        let stale = EvidenceRecord::new(EvidenceCode::BaselineStale, EvidenceScope::Chain);
130        let unsupported =
131            EvidenceRecord::new(EvidenceCode::UnsupportedStatement, EvidenceScope::Statement).at(
132                EvidenceLocation {
133                    file: "001.sql".to_string(),
134                    statement_index: 2,
135                },
136            );
137
138        assert!(log.insert(unsupported.clone()));
139        assert!(log.insert(stale.clone()));
140        assert!(!log.insert(unsupported.clone()));
141        assert_eq!(log.records(), &[stale, unsupported]);
142    }
143}