1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, BTreeSet};
3use std::fs;
4use std::path::Path;
5
6use super::Error;
7
8#[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 pub fn new() -> Self {
20 Self::default()
21 }
22
23 pub fn disabled(mut self, disabled: bool) -> Self {
25 self.disabled = Some(disabled);
26 self
27 }
28
29 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 pub fn disabled_override(&self) -> Option<bool> {
38 self.disabled
39 }
40
41 pub fn tier1_threshold_rows(&self) -> Option<u64> {
43 self.tier1_threshold_rows
44 }
45
46 pub fn tier2_threshold_rows(&self) -> Option<u64> {
48 self.tier2_threshold_rows
49 }
50}
51
52#[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 pub fn validate(&self) -> Result<(), Error> {
99 super::validate_config(self)
100 }
101
102 pub fn cache_encryption(&self) -> bool {
104 self.cache_encryption
105 }
106
107 pub fn auto_sync(&self) -> bool {
109 self.auto_sync
110 }
111
112 pub fn stale_stats_days(&self) -> u64 {
114 self.stale_stats_days
115 }
116
117 pub fn tier1_threshold_rows(&self) -> u64 {
119 self.tier1_threshold_rows
120 }
121
122 pub fn tier2_threshold_rows(&self) -> u64 {
124 self.tier2_threshold_rows
125 }
126
127 pub fn default_rows(&self) -> u64 {
129 self.default_rows
130 }
131
132 pub fn toast_width_threshold_bytes(&self) -> i32 {
134 self.toast_width_threshold_bytes
135 }
136
137 pub fn assumed_postgres_version(&self) -> u32 {
143 self.assume_pg_version
144 }
145
146 pub fn schema_scope(&self) -> Option<&[String]> {
148 self.schemas.as_deref()
149 }
150
151 pub fn with_cache_encryption(mut self, enabled: bool) -> Self {
153 self.cache_encryption = enabled;
154 self
155 }
156
157 pub fn with_auto_sync(mut self, enabled: bool) -> Self {
159 self.auto_sync = enabled;
160 self
161 }
162
163 pub fn with_stale_stats_days(mut self, days: u64) -> Self {
165 self.stale_stats_days = days;
166 self
167 }
168
169 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 pub fn with_default_rows(mut self, rows: u64) -> Self {
178 self.default_rows = rows;
179 self
180 }
181
182 pub fn with_toast_width_threshold_bytes(mut self, bytes: i32) -> Self {
184 self.toast_width_threshold_bytes = bytes;
185 self
186 }
187
188 pub fn with_assumed_postgres_version(mut self, version_num: u32) -> Self {
193 self.assume_pg_version = version_num;
194 self
195 }
196
197 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 pub fn with_all_non_system_schemas(mut self) -> Self {
208 self.schemas = None;
209 self
210 }
211
212 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 pub fn rule_config(&self, rule_id: &str) -> Option<&RuleConfig> {
220 self.rules.get(rule_id)
221 }
222
223 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 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 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 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 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 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 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 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}