1use serde::Deserialize;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5#[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#[derive(Deserialize, Default, Debug)]
27#[serde(deny_unknown_fields)]
28pub struct SqrustConfig {
29 pub dialect: Option<String>,
33
34 #[serde(default)]
37 pub include: Vec<String>,
38
39 #[serde(default)]
42 pub exclude: Vec<String>,
43}
44
45#[derive(Deserialize, Default, Debug)]
47#[serde(deny_unknown_fields)]
48pub struct RulesConfig {
49 #[serde(default)]
61 pub disable: Vec<String>,
62
63 }
76
77impl Config {
78 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 pub fn rule_enabled(&self, name: &str) -> bool {
94 !self.rules.disable.iter().any(|d| d == name)
95 }
96}
97
98fn 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}