Skip to main content

safe_migrate/engine/
config.rs

1// FILE: src/engine/config.rs
2use anyhow::{Result, bail};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeSet, HashMap};
5use std::fs;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9#[serde(deny_unknown_fields)]
10pub struct RuleConfig {
11    pub disabled: Option<bool>,
12    pub tier1_threshold_rows: Option<u64>,
13    pub tier2_threshold_rows: Option<u64>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct Config {
19    pub tier1_threshold_rows: u64,
20    pub tier2_threshold_rows: u64,
21    pub stale_stats_days: u64,
22    pub toast_width_threshold_bytes: i32,
23    pub default_rows: u64, // Fallback for offline/unanalyzed tables
24    pub auto_sync: bool,
25    pub cache_encryption: bool,
26    pub rules: HashMap<String, RuleConfig>, // Per-rule configuration
27    pub assume_pg_version: u32,
28    pub disabled_rules: Vec<String>,
29    pub schemas: Option<Vec<String>>,
30}
31
32impl Default for Config {
33    fn default() -> Self {
34        Self {
35            tier1_threshold_rows: 100_000,
36            tier2_threshold_rows: 10_000,
37            stale_stats_days: 7,
38            toast_width_threshold_bytes: 2048,
39            default_rows: 10_000,
40            auto_sync: false,
41            cache_encryption: false,
42            assume_pg_version: 100000,
43            disabled_rules: Vec::new(),
44            rules: HashMap::new(),
45            schemas: None,
46        }
47    }
48}
49
50impl Config {
51    pub fn load_from_file(path: &Path) -> Result<Self, anyhow::Error> {
52        if path.exists() {
53            let contents = fs::read_to_string(path)?;
54            match toml::from_str(&contents) {
55                Ok(config) => return Ok(config),
56                Err(e) => {
57                    return Err(anyhow::anyhow!(
58                        "Failed to parse config at {}: {}",
59                        path.display(),
60                        e
61                    ));
62                }
63            }
64        }
65        Ok(Self::default())
66    }
67
68    /// Checks if a rule is completely disabled
69    pub fn is_rule_disabled(&self, rule_id: &str) -> bool {
70        if self.disabled_rules.contains(&rule_id.to_string()) {
71            return true;
72        }
73        self.rules
74            .get(rule_id)
75            .and_then(|r| r.disabled)
76            .unwrap_or(false)
77    }
78
79    /// Gets the Tier 1 threshold for a specific rule, falling back to the global default
80    pub fn rule_tier1_threshold(&self, rule_id: &str) -> u64 {
81        self.rules
82            .get(rule_id)
83            .and_then(|r| r.tier1_threshold_rows)
84            .unwrap_or(self.tier1_threshold_rows)
85    }
86
87    /// Gets the Tier 2 threshold for a specific rule, falling back to the global default
88    pub fn rule_tier2_threshold(&self, rule_id: &str) -> u64 {
89        self.rules
90            .get(rule_id)
91            .and_then(|r| r.tier2_threshold_rows)
92            .unwrap_or(self.tier2_threshold_rows)
93    }
94
95    /// Returns the schema filter for a direct sync. An explicit CLI value wins
96    /// over the team-wide configuration default.
97    pub fn sync_schemas<'a>(
98        &'a self,
99        cli_schemas: Option<&'a [String]>,
100    ) -> Result<Option<&'a [String]>> {
101        let schemas = cli_schemas.or(self.schemas.as_deref());
102        if schemas.is_some_and(|schemas| {
103            schemas.is_empty() || schemas.iter().any(|schema| schema.trim().is_empty())
104        }) {
105            bail!("schemas must contain at least one non-empty schema name");
106        }
107        Ok(schemas)
108    }
109
110    /// Reject misspelled primary rule IDs instead of silently accepting no-op
111    /// configuration. The engine supplies its canonical IDs so this module
112    /// does not maintain a second rule catalog.
113    pub fn validate_rule_ids<'a>(
114        &self,
115        primary_rule_ids: impl IntoIterator<Item = &'a str>,
116    ) -> Result<(), anyhow::Error> {
117        let valid: BTreeSet<String> = primary_rule_ids.into_iter().map(str::to_owned).collect();
118        let unknown: BTreeSet<&str> = self
119            .rules
120            .keys()
121            .map(String::as_str)
122            .chain(self.disabled_rules.iter().map(String::as_str))
123            .filter(|rule_id| !valid.contains(*rule_id))
124            .collect();
125
126        if unknown.is_empty() {
127            return Ok(());
128        }
129
130        Err(anyhow::anyhow!(
131            "Unknown primary rule ID(s): {}. Valid primary rule IDs: {}",
132            unknown.into_iter().collect::<Vec<_>>().join(", "),
133            valid.into_iter().collect::<Vec<_>>().join(", ")
134        ))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::io::Write;
142    use tempfile::NamedTempFile;
143
144    #[test]
145    fn test_granular_rule_config() {
146        let mut file = NamedTempFile::new().expect("Failed to create temp file");
147        writeln!(
148            file,
149            r#"
150            tier1_threshold_rows = 500000
151
152            [rules.blocking-constraint]
153            tier1_threshold_rows = 5000
154
155            [rules.missing-idempotency]
156            disabled = true
157        "#
158        )
159        .expect("Failed to write temp config");
160
161        let config = Config::load_from_file(file.path()).expect("Failed to load valid config");
162
163        // Assert Global Overrides
164        assert_eq!(config.tier1_threshold_rows, 500_000);
165
166        // Assert Granular Fallbacks
167        assert_eq!(config.rule_tier1_threshold("blocking-constraint"), 5000);
168        assert_eq!(config.rule_tier1_threshold("unspecified-rule"), 500_000);
169        assert!(!config.auto_sync);
170        assert!(!config.cache_encryption);
171
172        // Assert Rule Disabling
173        assert!(config.is_rule_disabled("missing-idempotency"));
174        assert!(!config.is_rule_disabled("blocking-constraint"));
175    }
176
177    #[test]
178    fn test_direct_sync_prefers_cli_schema_filter_over_configured_default() {
179        let config = Config {
180            schemas: Some(vec!["public".to_string()]),
181            ..Config::default()
182        };
183        let cli_schemas = vec!["auth".to_string()];
184
185        assert_eq!(
186            config.sync_schemas(None).unwrap(),
187            Some(["public".to_string()].as_slice())
188        );
189        assert_eq!(
190            config.sync_schemas(Some(&cli_schemas)).unwrap(),
191            Some(["auth".to_string()].as_slice())
192        );
193    }
194
195    #[test]
196    fn test_direct_sync_rejects_empty_schema_scope() {
197        let config = Config::default();
198        assert!(config.sync_schemas(Some(&[])).is_err());
199        assert!(config.sync_schemas(Some(&["".to_string()])).is_err());
200    }
201
202    #[test]
203    fn rule_id_validation_rejects_unknown_rule_keys_and_disabled_ids() {
204        let mut config = Config::default();
205        config
206            .rules
207            .insert("typo-rule".to_string(), RuleConfig::default());
208        config.disabled_rules = vec!["known-rule".to_string(), "other-typo".to_string()];
209
210        let error = config
211            .validate_rule_ids(["known-rule"])
212            .expect_err("unknown rule IDs must fail validation")
213            .to_string();
214
215        assert!(error.contains("other-typo, typo-rule"));
216        assert!(error.contains("Valid primary rule IDs: known-rule"));
217    }
218
219    #[test]
220    fn rule_id_validation_accepts_known_rule_keys_and_disabled_ids() {
221        let mut config = Config::default();
222        config
223            .rules
224            .insert("known-rule".to_string(), RuleConfig::default());
225        config.disabled_rules = vec!["known-rule".to_string()];
226
227        config
228            .validate_rule_ids(["known-rule"])
229            .expect("known rule IDs must pass validation");
230    }
231
232    #[test]
233    fn config_rejects_unknown_top_level_setting() {
234        let error = toml::from_str::<Config>("auto_syn = true")
235            .expect_err("unknown top-level settings must fail")
236            .to_string();
237
238        assert!(error.contains("unknown field `auto_syn`"));
239        assert!(error.contains("auto_sync"));
240    }
241
242    #[test]
243    fn config_rejects_unknown_per_rule_setting() {
244        let error =
245            toml::from_str::<Config>("[rules.blocking-constraint]\ntier1_threshold_row = 1")
246                .expect_err("unknown per-rule settings must fail")
247                .to_string();
248
249        assert!(error.contains("unknown field `tier1_threshold_row`"));
250        assert!(error.contains("tier1_threshold_rows"));
251    }
252}