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