Skip to main content

safe_migrate/api/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fs;
4use std::path::Path;
5
6use super::Error;
7
8/// Per-rule configuration accepted by `safe-migrate.toml`.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
10#[serde(deny_unknown_fields)]
11pub struct RuleConfig {
12    pub(crate) disabled: Option<bool>,
13    pub(crate) tier1_threshold_rows: Option<u64>,
14    pub(crate) tier2_threshold_rows: Option<u64>,
15}
16
17impl RuleConfig {
18    /// Start an empty per-rule override.
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    /// Enable or disable this rule without changing its thresholds.
24    pub fn disabled(mut self, disabled: bool) -> Self {
25        self.disabled = Some(disabled);
26        self
27    }
28
29    /// Override the row thresholds supported by this rule.
30    pub fn tier_thresholds(mut self, tier1_rows: Option<u64>, tier2_rows: Option<u64>) -> Self {
31        self.tier1_threshold_rows = tier1_rows;
32        self.tier2_threshold_rows = tier2_rows;
33        self
34    }
35
36    /// Return the explicit enabled/disabled override, if one was configured.
37    pub fn disabled_override(&self) -> Option<bool> {
38        self.disabled
39    }
40
41    /// Return the explicit Tier 1 row threshold, if one was configured.
42    pub fn tier1_threshold_rows(&self) -> Option<u64> {
43        self.tier1_threshold_rows
44    }
45
46    /// Return the explicit Tier 2 row threshold, if one was configured.
47    pub fn tier2_threshold_rows(&self) -> Option<u64> {
48        self.tier2_threshold_rows
49    }
50}
51
52/// Complete safe-migrate configuration.
53///
54/// This type is defined by the supported API. Internal engine modules consume
55/// it, so configuration behavior does not depend on an implementation type
56/// leaking through a re-export.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct Config {
60    pub(crate) tier1_threshold_rows: u64,
61    pub(crate) tier2_threshold_rows: u64,
62    pub(crate) stale_stats_days: u64,
63    pub(crate) toast_width_threshold_bytes: i32,
64    pub(crate) default_rows: u64,
65    pub(crate) auto_sync: bool,
66    pub(crate) cache_encryption: bool,
67    pub(crate) rules: BTreeMap<String, RuleConfig>,
68    pub(crate) assume_pg_version: u32,
69    pub(crate) disabled_rules: Vec<String>,
70    pub(crate) schemas: Option<Vec<String>>,
71}
72
73impl Default for Config {
74    fn default() -> Self {
75        Self {
76            tier1_threshold_rows: 100_000,
77            tier2_threshold_rows: 10_000,
78            stale_stats_days: 7,
79            toast_width_threshold_bytes: 2048,
80            default_rows: 10_000,
81            auto_sync: false,
82            cache_encryption: false,
83            assume_pg_version: 100000,
84            disabled_rules: Vec::new(),
85            rules: BTreeMap::new(),
86            schemas: None,
87        }
88    }
89}
90
91impl Config {
92    /// Validate rule IDs, per-rule settings, thresholds, and schema scope.
93    ///
94    /// # Errors
95    ///
96    /// Returns a configuration error describing every invalid rule ID or the
97    /// first invalid setting.
98    pub fn validate(&self) -> Result<(), Error> {
99        super::validate_config(self)
100    }
101
102    /// Return whether baseline caches are expected to be encrypted.
103    pub fn cache_encryption(&self) -> bool {
104        self.cache_encryption
105    }
106
107    /// Return whether a CLI caller may refresh stale baselines automatically.
108    pub fn auto_sync(&self) -> bool {
109        self.auto_sync
110    }
111
112    /// Return the maximum accepted age of catalog statistics, in days.
113    pub fn stale_stats_days(&self) -> u64 {
114        self.stale_stats_days
115    }
116
117    /// Return the default Tier 1 row threshold.
118    pub fn tier1_threshold_rows(&self) -> u64 {
119        self.tier1_threshold_rows
120    }
121
122    /// Return the default Tier 2 row threshold.
123    pub fn tier2_threshold_rows(&self) -> u64 {
124        self.tier2_threshold_rows
125    }
126
127    /// Return the conservative row estimate used when statistics are absent.
128    pub fn default_rows(&self) -> u64 {
129        self.default_rows
130    }
131
132    /// Return the TOAST-width threshold used by rewrite analysis.
133    pub fn toast_width_threshold_bytes(&self) -> i32 {
134        self.toast_width_threshold_bytes
135    }
136
137    /// Return the PostgreSQL version assumed when no connected baseline provides one.
138    ///
139    /// The default `100000` is a conservative compatibility fallback, not a
140    /// claim that PostgreSQL 10 is supported. A configured value must name a
141    /// supported PostgreSQL 14–18 version.
142    pub fn assumed_postgres_version(&self) -> u32 {
143        self.assume_pg_version
144    }
145
146    /// Return the configured schema scope, or `None` for all non-system schemas.
147    pub fn schema_scope(&self) -> Option<&[String]> {
148        self.schemas.as_deref()
149    }
150
151    /// Set whether baseline caches are encrypted.
152    pub fn with_cache_encryption(mut self, enabled: bool) -> Self {
153        self.cache_encryption = enabled;
154        self
155    }
156
157    /// Set whether a CLI caller may refresh stale baselines automatically.
158    pub fn with_auto_sync(mut self, enabled: bool) -> Self {
159        self.auto_sync = enabled;
160        self
161    }
162
163    /// Set the maximum accepted age of catalog statistics, in days.
164    pub fn with_stale_stats_days(mut self, days: u64) -> Self {
165        self.stale_stats_days = days;
166        self
167    }
168
169    /// Set the default row thresholds for Tier 1 and Tier 2 findings.
170    pub fn with_tier_thresholds(mut self, tier1_rows: u64, tier2_rows: u64) -> Self {
171        self.tier1_threshold_rows = tier1_rows;
172        self.tier2_threshold_rows = tier2_rows;
173        self
174    }
175
176    /// Set the conservative row estimate used when statistics are absent.
177    pub fn with_default_rows(mut self, rows: u64) -> Self {
178        self.default_rows = rows;
179        self
180    }
181
182    /// Set the TOAST-width threshold used by rewrite analysis.
183    pub fn with_toast_width_threshold_bytes(mut self, bytes: i32) -> Self {
184        self.toast_width_threshold_bytes = bytes;
185        self
186    }
187
188    /// Set the PostgreSQL version assumed when no connected baseline provides one.
189    ///
190    /// Use a PostgreSQL 14–18 server version number only when the deployment
191    /// target is known. [`Config::validate`] rejects unsupported values.
192    pub fn with_assumed_postgres_version(mut self, version_num: u32) -> Self {
193        self.assume_pg_version = version_num;
194        self
195    }
196
197    /// Restrict synchronization and analysis to the supplied non-empty schema names.
198    pub fn with_schema_scope(
199        mut self,
200        schemas: impl IntoIterator<Item = impl Into<String>>,
201    ) -> Self {
202        self.schemas = Some(schemas.into_iter().map(Into::into).collect());
203        self
204    }
205
206    /// Synchronize all visible non-system schemas.
207    pub fn with_all_non_system_schemas(mut self) -> Self {
208        self.schemas = None;
209        self
210    }
211
212    /// Add or replace a per-rule configuration override.
213    pub fn with_rule(mut self, rule_id: impl Into<String>, rule: RuleConfig) -> Self {
214        self.rules.insert(rule_id.into(), rule);
215        self
216    }
217
218    /// Return an explicit per-rule override, if configured.
219    pub fn rule_config(&self, rule_id: &str) -> Option<&RuleConfig> {
220        self.rules.get(rule_id)
221    }
222
223    /// Disable one primary rule by ID.
224    pub fn disable_rule(mut self, rule_id: impl Into<String>) -> Self {
225        let rule_id = rule_id.into();
226        self.disabled_rules.retain(|disabled| disabled != &rule_id);
227        self.rules.entry(rule_id).or_default().disabled = Some(true);
228        self
229    }
230
231    /// Enable one primary rule by ID while preserving its threshold overrides.
232    pub fn enable_rule(mut self, rule_id: impl Into<String>) -> Self {
233        let rule_id = rule_id.into();
234        self.disabled_rules.retain(|disabled| disabled != &rule_id);
235        self.rules.entry(rule_id).or_default().disabled = Some(false);
236        self
237    }
238
239    /// Load a TOML configuration, returning defaults when the file is absent.
240    ///
241    /// # Errors
242    ///
243    /// Returns a configuration error when an existing file cannot be read,
244    /// parsed, or validated.
245    pub fn load_from_file(path: &Path) -> Result<Self, Error> {
246        match fs::read_to_string(path) {
247            Ok(contents) => Self::parse_file(path, &contents),
248            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
249            Err(error) => Err(Error::with_source(
250                super::ErrorKind::Configuration,
251                format!("failed to read {}", path.display()),
252                error,
253            )),
254        }
255    }
256
257    /// Load a TOML configuration and fail when the file is absent.
258    ///
259    /// # Errors
260    ///
261    /// Returns a configuration error when the file cannot be read, parsed, or
262    /// validated.
263    pub fn load_required_from_file(path: &Path) -> Result<Self, Error> {
264        let contents = fs::read_to_string(path).map_err(|error| {
265            Error::with_source(
266                super::ErrorKind::Configuration,
267                format!("failed to read {}", path.display()),
268                error,
269            )
270        })?;
271        Self::parse_file(path, &contents)
272    }
273
274    fn parse_file(path: &Path, contents: &str) -> Result<Self, Error> {
275        let config: Self = toml::from_str(contents).map_err(|error| {
276            Error::with_source(
277                super::ErrorKind::Configuration,
278                format!("failed to parse {}", path.display()),
279                error,
280            )
281        })?;
282        config.validate()?;
283        Ok(config)
284    }
285
286    /// Return whether a rule is disabled by either configuration form.
287    pub fn is_rule_disabled(&self, rule_id: &str) -> bool {
288        if self
289            .disabled_rules
290            .iter()
291            .any(|disabled| disabled == rule_id)
292        {
293            return true;
294        }
295        self.rules
296            .get(rule_id)
297            .and_then(|rule| rule.disabled)
298            .unwrap_or(false)
299    }
300
301    /// Return a rule's effective Tier 1 threshold.
302    pub fn rule_tier1_threshold(&self, rule_id: &str) -> u64 {
303        self.rules
304            .get(rule_id)
305            .and_then(|rule| rule.tier1_threshold_rows)
306            .unwrap_or(self.tier1_threshold_rows)
307    }
308
309    /// Return a rule's effective Tier 2 threshold.
310    pub fn rule_tier2_threshold(&self, rule_id: &str) -> u64 {
311        self.rules
312            .get(rule_id)
313            .and_then(|rule| rule.tier2_threshold_rows)
314            .unwrap_or(self.tier2_threshold_rows)
315    }
316
317    /// Resolve a direct sync's schema scope. Explicit command input wins over
318    /// the shared configuration.
319    pub(crate) fn sync_schemas<'a>(
320        &'a self,
321        command_schemas: Option<&'a [String]>,
322    ) -> Result<Option<&'a [String]>, Error> {
323        let schemas = command_schemas.or(self.schemas.as_deref());
324        if schemas.is_some_and(|schemas| {
325            schemas.is_empty() || schemas.iter().any(|schema| schema.trim().is_empty())
326        }) {
327            return Err(Error::configuration(
328                "schemas must not be empty and no schema name may be blank",
329            ));
330        }
331        Ok(schemas)
332    }
333
334    pub(crate) fn validate_rule_ids<'a>(
335        &self,
336        primary_rule_ids: impl IntoIterator<Item = &'a str>,
337    ) -> Result<(), Error> {
338        let valid: BTreeSet<String> = primary_rule_ids.into_iter().map(str::to_owned).collect();
339        let unknown: BTreeSet<&str> = self
340            .rules
341            .keys()
342            .map(String::as_str)
343            .chain(self.disabled_rules.iter().map(String::as_str))
344            .filter(|rule_id| !valid.contains(*rule_id))
345            .collect();
346
347        if unknown.is_empty() {
348            return Ok(());
349        }
350
351        Err(Error::configuration(format!(
352            "Unknown primary rule ID(s): {}. Valid primary rule IDs: {}",
353            unknown.into_iter().collect::<Vec<_>>().join(", "),
354            valid.into_iter().collect::<Vec<_>>().join(", ")
355        )))
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::io::Write;
363    use tempfile::NamedTempFile;
364
365    #[test]
366    fn granular_rule_configuration_is_loaded() {
367        let mut file = NamedTempFile::new().expect("create temporary config");
368        writeln!(
369            file,
370            r#"
371            tier1_threshold_rows = 500000
372
373            [rules.blocking-constraint]
374            tier1_threshold_rows = 50000
375
376            [rules.missing-idempotency]
377            disabled = true
378        "#
379        )
380        .expect("write temporary config");
381
382        let config = Config::load_from_file(file.path()).expect("load valid config");
383        assert_eq!(config.tier1_threshold_rows, 500_000);
384        assert_eq!(config.rule_tier1_threshold("blocking-constraint"), 50_000);
385        assert_eq!(config.rule_tier1_threshold("unspecified-rule"), 500_000);
386        assert!(!config.auto_sync);
387        assert!(!config.cache_encryption);
388        assert!(config.is_rule_disabled("missing-idempotency"));
389        assert!(!config.is_rule_disabled("blocking-constraint"));
390    }
391
392    #[test]
393    fn command_schema_filter_takes_precedence() {
394        let config = Config {
395            schemas: Some(vec!["public".to_owned()]),
396            ..Config::default()
397        };
398        let command_schemas = vec!["auth".to_owned()];
399
400        assert_eq!(
401            config.sync_schemas(None).unwrap(),
402            Some(["public".to_owned()].as_slice())
403        );
404        assert_eq!(
405            config.sync_schemas(Some(&command_schemas)).unwrap(),
406            Some(["auth".to_owned()].as_slice())
407        );
408    }
409
410    #[test]
411    fn empty_schema_scope_is_rejected() {
412        let config = Config::default();
413        assert!(config.sync_schemas(Some(&[])).is_err());
414        assert!(config.sync_schemas(Some(&[String::new()])).is_err());
415    }
416
417    #[test]
418    fn optional_and_required_missing_config_have_distinct_behavior() {
419        let directory = tempfile::tempdir().unwrap();
420        let missing = directory.path().join("missing.toml");
421
422        assert_eq!(
423            Config::load_from_file(&missing)
424                .unwrap()
425                .tier1_threshold_rows,
426            Config::default().tier1_threshold_rows
427        );
428        assert!(Config::load_required_from_file(&missing).is_err());
429    }
430
431    #[test]
432    fn unknown_rule_ids_are_reported_together() {
433        let mut config = Config::default();
434        config
435            .rules
436            .insert("typo-rule".to_owned(), RuleConfig::default());
437        config.disabled_rules = vec!["known-rule".to_owned(), "other-typo".to_owned()];
438
439        let error = config
440            .validate_rule_ids(["known-rule"])
441            .expect_err("unknown rule IDs must fail validation")
442            .to_string();
443        assert!(error.contains("other-typo, typo-rule"));
444        assert!(error.contains("Valid primary rule IDs: known-rule"));
445    }
446
447    #[test]
448    fn known_rule_ids_are_accepted() {
449        let mut config = Config::default();
450        config
451            .rules
452            .insert("known-rule".to_owned(), RuleConfig::default());
453        config.disabled_rules = vec!["known-rule".to_owned()];
454        config.validate_rule_ids(["known-rule"]).unwrap();
455    }
456
457    #[test]
458    fn unknown_configuration_fields_are_rejected() {
459        let top_level = toml::from_str::<Config>("auto_syn = true")
460            .expect_err("unknown top-level settings must fail")
461            .to_string();
462        assert!(top_level.contains("unknown field `auto_syn`"));
463        assert!(top_level.contains("auto_sync"));
464
465        let per_rule =
466            toml::from_str::<Config>("[rules.blocking-constraint]\ntier1_threshold_row = 1")
467                .expect_err("unknown per-rule settings must fail")
468                .to_string();
469        assert!(per_rule.contains("unknown field `tier1_threshold_row`"));
470        assert!(per_rule.contains("tier1_threshold_rows"));
471    }
472}