safe_migrate/engine/
config.rs1use 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, pub auto_sync: bool,
25 pub cache_encryption: bool,
26 pub rules: HashMap<String, RuleConfig>, 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 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 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 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 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 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_eq!(config.tier1_threshold_rows, 500_000);
165
166 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!(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}