Skip to main content

safe_migrate/rules/
conflict.rs

1// FILE: src/rules/conflict.rs
2
3use crate::analysis::mutations::Mutation;
4use crate::analysis::state::MutationResult;
5use crate::engine::config::Config;
6use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
7use crate::rules::Rule;
8
9pub struct ConflictRule;
10
11impl ConflictRule {
12    const ID: &'static str = "chain-conflict";
13    const DEFAULT_TIER: ViolationTier = ViolationTier::Tier1;
14    const RECIPE: &'static str = "Refactor the migration chain so each column is added only once with a consistent type, or consolidate into a single DDL statement.";
15
16    fn extract_conflict_reason(result: &MutationResult) -> Option<&str> {
17        match result {
18            MutationResult::Conflict { reason } => Some(reason.as_str()),
19            _ => None,
20        }
21    }
22}
23
24impl Rule for ConflictRule {
25    fn id(&self) -> &'static str {
26        Self::ID
27    }
28
29    fn default_tier(&self) -> ViolationTier {
30        Self::DEFAULT_TIER
31    }
32
33    fn recipe(&self) -> &'static str {
34        Self::RECIPE
35    }
36
37    fn evaluate(
38        &self,
39        _mutation: &Mutation,
40        result: &MutationResult,
41        _pre_state: &crate::analysis::state::PreState,
42        _state: &crate::analysis::state::AnalysisState,
43        _config: &Config,
44        _cascade_closure: Option<&crate::analysis::state::CascadeResult>,
45    ) -> Vec<Violation> {
46        match Self::extract_conflict_reason(result) {
47            Some(reason) => vec![Violation {
48                source_range: None,
49                rule_id: Self::ID,
50                operation_kind: OperationKind::Other("conflict".to_string()),
51                object_kind: ObjectKind::Unknown,
52                object_name: "unknown".to_string(),
53                tier: Self::DEFAULT_TIER,
54                reason: format!("Migration chain conflict: {}", reason),
55                recipe: Self::RECIPE,
56                dedup_key: None,
57                sql: None,
58            }],
59            None => Vec::new(),
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::analysis::mutations::Mutation;
68    use crate::analysis::state::MutationResult;
69    use std::collections::HashMap;
70
71    #[test]
72    fn test_conflict_rule_emits_tier1_on_conflict() {
73        let rule = ConflictRule;
74        let result = MutationResult::Conflict {
75            reason:
76                "column 'x' already added with type int, this file adds it again with type text"
77                    .to_string(),
78        };
79        let violations = rule.evaluate(
80            &Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::DynamicSql),
81            &result,
82            &crate::analysis::state::PreState {
83                relations: HashMap::new(),
84                functions: HashMap::new(),
85                roles: HashMap::new(),
86                publications: HashMap::new(),
87                subscriptions: HashMap::new(),
88                sequences: HashMap::new(),
89                types: HashMap::new(),
90                indexes: Vec::new(),
91            },
92            &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()),
93            &Config::default(),
94            None,
95        );
96        assert_eq!(violations.len(), 1);
97        assert_eq!(violations[0].rule_id, "chain-conflict");
98        assert_eq!(violations[0].tier, ViolationTier::Tier1);
99        assert!(violations[0].reason.contains("Migration chain conflict"));
100    }
101
102    #[test]
103    fn test_conflict_rule_silent_on_applied() {
104        let rule = ConflictRule;
105        let result = MutationResult::Applied;
106        let violations = rule.evaluate(
107            &Mutation::Opaque(crate::analysis::mutations::OpaqueMutation::DynamicSql),
108            &result,
109            &crate::analysis::state::PreState {
110                relations: HashMap::new(),
111                functions: HashMap::new(),
112                roles: HashMap::new(),
113                publications: HashMap::new(),
114                subscriptions: HashMap::new(),
115                sequences: HashMap::new(),
116                types: HashMap::new(),
117                indexes: Vec::new(),
118            },
119            &crate::analysis::state::AnalysisState::new(crate::db::cache::DbCache::new()),
120            &Config::default(),
121            None,
122        );
123        assert!(violations.is_empty());
124    }
125}