sql_dialect_fmt_config/
lib.rs1use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Deserializer};
14use sql_dialect_fmt_formatter::{FormatOptions, KeywordCase, LineEnding};
15use sql_dialect_fmt_parser::Dialect;
16
17pub const CONFIG_FILE_NAME: &str = "sql-dialect-fmt.toml";
19
20#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields, rename_all = "snake_case")]
24pub struct Config {
25 pub line_width: Option<usize>,
27 pub indent_width: Option<usize>,
29 pub uppercase_keywords: Option<bool>,
31 #[serde(default, deserialize_with = "deserialize_keyword_case")]
33 pub keyword_case: Option<KeywordCase>,
34 #[serde(default, deserialize_with = "deserialize_line_ending")]
36 pub line_ending: Option<LineEnding>,
37 #[serde(default, deserialize_with = "deserialize_dialect")]
39 pub dialect: Option<Dialect>,
40 #[serde(default)]
42 pub exclude: Vec<String>,
43}
44
45pub fn parse_dialect(value: &str) -> Result<Dialect, String> {
46 match value.to_ascii_lowercase().as_str() {
47 "snowflake" => Ok(Dialect::Snowflake),
48 "databricks" => Ok(Dialect::Databricks),
49 _ => Err(format!(
50 "dialect expects one of: snowflake, databricks; got {value:?}"
51 )),
52 }
53}
54
55pub fn parse_keyword_case(value: &str) -> Result<KeywordCase, String> {
56 match value.to_ascii_lowercase().as_str() {
57 "upper" => Ok(KeywordCase::Upper),
58 "lower" => Ok(KeywordCase::Lower),
59 "preserve" => Ok(KeywordCase::Preserve),
60 _ => Err(format!(
61 "keyword_case expects one of: upper, lower, preserve; got {value:?}"
62 )),
63 }
64}
65
66pub fn parse_line_ending(value: &str) -> Result<LineEnding, String> {
67 match value.to_ascii_lowercase().as_str() {
68 "auto" => Ok(LineEnding::Auto),
69 "lf" => Ok(LineEnding::Lf),
70 "crlf" => Ok(LineEnding::Crlf),
71 _ => Err(format!(
72 "line_ending expects one of: auto, lf, crlf; got {value:?}"
73 )),
74 }
75}
76
77fn deserialize_dialect<'de, D>(deserializer: D) -> Result<Option<Dialect>, D::Error>
78where
79 D: Deserializer<'de>,
80{
81 let value = Option::<String>::deserialize(deserializer)?;
82 value
83 .map(|value| parse_dialect(&value).map_err(serde::de::Error::custom))
84 .transpose()
85}
86
87fn deserialize_keyword_case<'de, D>(deserializer: D) -> Result<Option<KeywordCase>, D::Error>
88where
89 D: Deserializer<'de>,
90{
91 let value = Option::<String>::deserialize(deserializer)?;
92 value
93 .map(|value| parse_keyword_case(&value).map_err(serde::de::Error::custom))
94 .transpose()
95}
96
97fn deserialize_line_ending<'de, D>(deserializer: D) -> Result<Option<LineEnding>, D::Error>
98where
99 D: Deserializer<'de>,
100{
101 let value = Option::<String>::deserialize(deserializer)?;
102 value
103 .map(|value| parse_line_ending(&value).map_err(serde::de::Error::custom))
104 .transpose()
105}
106
107impl Config {
108 pub fn parse(text: &str) -> Result<Config, String> {
110 toml::from_str(text).map_err(|err| {
111 err.message().trim_end().to_string()
113 })
114 }
115
116 pub fn load(path: &Path) -> Result<Config, String> {
118 let text = std::fs::read_to_string(path)
119 .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
120 Config::parse(&text).map_err(|err| format!("invalid config {}: {err}", path.display()))
121 }
122
123 pub fn apply_to(&self, options: &mut FormatOptions) {
125 if let Some(line_width) = self.line_width {
126 options.line_width = line_width;
127 }
128 if let Some(indent_width) = self.indent_width {
129 options.indent_width = indent_width;
130 }
131 if let Some(uppercase_keywords) = self.uppercase_keywords {
132 *options = (*options).with_uppercase_keywords(uppercase_keywords);
133 }
134 if let Some(keyword_case) = self.keyword_case {
135 *options = (*options).with_keyword_case(keyword_case);
136 }
137 if let Some(line_ending) = self.line_ending {
138 options.line_ending = line_ending;
139 }
140 if let Some(dialect) = self.dialect {
141 options.dialect = dialect;
142 }
143 }
144}
145
146pub fn discover(start: &Path) -> Option<PathBuf> {
151 let mut dir: PathBuf = if start.is_dir() {
152 start.to_path_buf()
153 } else {
154 start.parent().map(Path::to_path_buf).unwrap_or_default()
155 };
156
157 if dir.as_os_str().is_empty() {
159 dir = PathBuf::from(".");
160 }
161
162 loop {
163 let candidate = dir.join(CONFIG_FILE_NAME);
164 if candidate.is_file() {
165 return Some(candidate);
166 }
167 if !dir.pop() {
168 return discover_from_cwd_if_relative(start);
170 }
171 }
172}
173
174fn discover_from_cwd_if_relative(start: &Path) -> Option<PathBuf> {
177 if start.is_absolute() {
178 return None;
179 }
180 let cwd = std::env::current_dir().ok()?;
181 let mut dir = if start.is_dir() {
182 cwd.join(start)
183 } else {
184 match start.parent() {
185 Some(parent) if !parent.as_os_str().is_empty() => cwd.join(parent),
186 _ => cwd,
187 }
188 };
189 loop {
190 let candidate = dir.join(CONFIG_FILE_NAME);
191 if candidate.is_file() {
192 return Some(candidate);
193 }
194 if !dir.pop() {
195 return None;
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn parses_all_keys() {
206 let cfg = Config::parse(
207 "line_width = 80\nindent_width = 2\nuppercase_keywords = false\nkeyword_case = \"lower\"\nline_ending = \"crlf\"\ndialect = \"databricks\"\nexclude = [\"target/**\"]\n",
208 )
209 .expect("valid");
210 assert_eq!(cfg.line_width, Some(80));
211 assert_eq!(cfg.indent_width, Some(2));
212 assert_eq!(cfg.uppercase_keywords, Some(false));
213 assert_eq!(cfg.keyword_case, Some(KeywordCase::Lower));
214 assert_eq!(cfg.line_ending, Some(LineEnding::Crlf));
215 assert_eq!(cfg.dialect, Some(Dialect::Databricks));
216 assert_eq!(cfg.exclude, vec!["target/**"]);
217 }
218
219 #[test]
220 fn empty_config_is_all_none() {
221 assert_eq!(Config::parse("").expect("valid"), Config::default());
222 }
223
224 #[test]
225 fn partial_config_leaves_other_fields_default() {
226 let cfg = Config::parse("indent_width = 8\n").expect("valid");
227 assert_eq!(cfg.indent_width, Some(8));
228 assert_eq!(cfg.line_width, None);
229 assert_eq!(cfg.uppercase_keywords, None);
230 assert_eq!(cfg.keyword_case, None);
231 assert_eq!(cfg.line_ending, None);
232 assert_eq!(cfg.dialect, None);
233 assert!(cfg.exclude.is_empty());
234 }
235
236 #[test]
237 fn unknown_keys_are_rejected() {
238 assert!(Config::parse("tab_width = 4\n").is_err());
239 }
240
241 #[test]
242 fn malformed_toml_is_rejected() {
243 assert!(Config::parse("line_width = \n").is_err());
244 }
245
246 #[test]
247 fn apply_overrides_only_set_fields() {
248 let mut options = FormatOptions::default();
249 let cfg = Config::parse(
250 "line_width = 60\ndialect = \"databricks\"\nkeyword_case = \"lower\"\nline_ending = \"crlf\"\n",
251 )
252 .expect("valid");
253 cfg.apply_to(&mut options);
254 assert_eq!(options.line_width, 60);
255 assert_eq!(options.dialect, Dialect::Databricks);
256 assert_eq!(options.keyword_case, KeywordCase::Lower);
257 assert_eq!(options.line_ending, LineEnding::Crlf);
258 assert_eq!(options.indent_width, 4);
260 }
261
262 #[test]
263 fn invalid_dialect_is_rejected() {
264 assert!(Config::parse("dialect = \"oracle\"\n").is_err());
265 assert!(parse_dialect("oracle").is_err());
266 }
267
268 #[test]
269 fn invalid_keyword_case_and_line_ending_are_rejected() {
270 assert!(Config::parse("keyword_case = \"title\"\n").is_err());
271 assert!(Config::parse("line_ending = \"native\"\n").is_err());
272 assert!(parse_keyword_case("title").is_err());
273 assert!(parse_line_ending("native").is_err());
274 }
275}