Skip to main content

sqrust_core/
config.rs

1use serde::Deserialize;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// Top-level `sqrust.toml` structure.
6///
7/// # v0.1.0 — Option A (denylist)
8/// All rules are enabled by default. Use `[rules] disable = [...]` to turn
9/// specific rules off.
10///
11/// # Planned — Option B (Ruff-style allowlist, v0.2.0)
12/// The fields `select` and `ignore` inside `[rules]` are reserved for a
13/// future allowlist model where you opt into rule categories or individual
14/// rules rather than opting out. When that lands, existing `disable` configs
15/// will continue to work without changes.
16#[derive(Deserialize, Default, Debug)]
17#[serde(deny_unknown_fields)]
18pub struct Config {
19    #[serde(default)]
20    pub sqrust: SqrustConfig,
21    #[serde(default)]
22    pub rules: RulesConfig,
23}
24
25/// `[sqrust]` section — global linter settings.
26#[derive(Deserialize, Default, Debug)]
27#[serde(deny_unknown_fields)]
28pub struct SqrustConfig {
29    /// SQL dialect. Currently informational only; ANSI parser is always used.
30    /// Future: "ansi" | "bigquery" | "snowflake" | "duckdb" | "postgres"
31    pub dialect: Option<String>,
32
33    /// Glob patterns for files to include. Default: all `.sql` files found
34    /// by walking the given path.
35    #[serde(default)]
36    pub include: Vec<String>,
37
38    /// Glob patterns for paths to exclude.
39    /// Example: `["dbt_packages/**", "target/**"]`
40    #[serde(default)]
41    pub exclude: Vec<String>,
42}
43
44/// `[rules]` section — rule selection.
45#[derive(Deserialize, Default, Debug)]
46#[serde(deny_unknown_fields)]
47pub struct RulesConfig {
48    /// Rules to disable. All other rules remain active.
49    /// Use the full rule name: `"Convention/SelectStar"`.
50    ///
51    /// # Example
52    /// ```toml
53    /// [rules]
54    /// disable = [
55    ///     "Convention/SelectStar",
56    ///     "Layout/LongLines",
57    /// ]
58    /// ```
59    #[serde(default)]
60    pub disable: Vec<String>,
61
62    // ── Option B (reserved, not yet active) ─────────────────────────────────
63    // The fields below are planned for v0.2.0. They are commented out so the
64    // parser rejects them with a clear error rather than silently ignoring them,
65    // giving users an early signal when they try to use them.
66    //
67    // /// Enable only these rules or categories (allowlist).
68    // /// `"Convention"` enables all convention rules.
69    // /// `"Convention/SelectStar"` enables one specific rule.
70    // pub select: Option<Vec<String>>,
71    //
72    // /// Disable rules even when they appear in `select` (Ruff-style override).
73    // pub ignore: Vec<String>,
74}
75
76impl Config {
77    /// Load `sqrust.toml` by walking up from `start` to the filesystem root.
78    /// Returns `Config::default()` (all rules enabled, no excludes) if no
79    /// config file is found.
80    pub fn load(start: &Path) -> Result<Self, String> {
81        if let Some(path) = find_config(start) {
82            let content = fs::read_to_string(&path)
83                .map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
84            toml::from_str(&content)
85                .map_err(|e| format!("Invalid sqrust.toml: {}", e))
86        } else {
87            Ok(Config::default())
88        }
89    }
90
91    /// Returns true if the rule with this name should run.
92    pub fn rule_enabled(&self, name: &str) -> bool {
93        !self.rules.disable.iter().any(|d| d == name)
94    }
95}
96
97/// Walk up from `start` looking for `sqrust.toml`.
98fn find_config(start: &Path) -> Option<PathBuf> {
99    let mut dir = if start.is_file() {
100        start.parent()?.to_path_buf()
101    } else {
102        start.to_path_buf()
103    };
104
105    loop {
106        let candidate = dir.join("sqrust.toml");
107        if candidate.exists() {
108            return Some(candidate);
109        }
110        match dir.parent() {
111            Some(parent) => dir = parent.to_path_buf(),
112            None => return None,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn parse(toml: &str) -> Config {
122        toml::from_str(toml).expect("valid toml")
123    }
124
125    #[test]
126    fn empty_config_is_default() {
127        let cfg = parse("");
128        assert!(cfg.rules.disable.is_empty());
129        assert!(cfg.sqrust.exclude.is_empty());
130    }
131
132    #[test]
133    fn disable_list_parsed() {
134        let cfg = parse(r#"
135[rules]
136disable = ["Convention/SelectStar", "Layout/LongLines"]
137"#);
138        assert_eq!(cfg.rules.disable.len(), 2);
139        assert!(cfg.rules.disable.contains(&"Convention/SelectStar".to_string()));
140    }
141
142    #[test]
143    fn rule_enabled_respects_disable() {
144        let cfg = parse(r#"
145[rules]
146disable = ["Convention/SelectStar"]
147"#);
148        assert!(!cfg.rule_enabled("Convention/SelectStar"));
149        assert!(cfg.rule_enabled("Layout/LongLines"));
150    }
151
152    #[test]
153    fn exclude_patterns_parsed() {
154        let cfg = parse(r#"
155[sqrust]
156exclude = ["dbt_packages/**", "target/**"]
157"#);
158        assert_eq!(cfg.sqrust.exclude.len(), 2);
159    }
160
161    #[test]
162    fn dialect_parsed() {
163        let cfg = parse(r#"
164[sqrust]
165dialect = "bigquery"
166"#);
167        assert_eq!(cfg.sqrust.dialect.as_deref(), Some("bigquery"));
168    }
169
170    #[test]
171    fn unknown_field_rejected() {
172        let result: Result<Config, _> = toml::from_str(r#"
173[rules]
174select = ["Convention"]
175"#);
176        assert!(result.is_err(), "select is not yet supported and should be rejected");
177    }
178}