1use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21use crate::fmt::{FormatOptions, IndentStyle};
22
23pub const MANIFEST: &str = "bynk.toml";
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct FmtConfig {
30 pub indent: Option<IndentStyle>,
31 pub max_line_width: Option<u32>,
32 pub trailing_comma: Option<bool>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ConfigError {
38 Read(String),
40 Parse(String),
42 Indent(String),
44 MaxLineWidth(u32),
46}
47
48impl std::fmt::Display for ConfigError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Read(e) => write!(f, "{e}"),
52 Self::Parse(e) => write!(f, "{e}"),
53 Self::Indent(found) => write!(
54 f,
55 "`[fmt] indent` must be \"tab\" or \"spaces\", found \"{found}\""
56 ),
57 Self::MaxLineWidth(n) => {
58 write!(f, "`[fmt] max_line_width` must be at least 1, found {n}")
59 }
60 }
61 }
62}
63
64impl std::error::Error for ConfigError {}
65
66#[derive(Debug, Deserialize, Default)]
72struct RawManifest {
73 #[serde(default)]
74 fmt: RawFmt,
75}
76
77#[derive(Debug, Deserialize, Default)]
85#[serde(deny_unknown_fields)]
86struct RawFmt {
87 indent: Option<String>,
88 indent_width: Option<u8>,
89 max_line_width: Option<u32>,
90 trailing_comma: Option<bool>,
91}
92
93impl FmtConfig {
94 pub fn from_manifest_str(text: &str) -> Result<Self, ConfigError> {
98 let raw: RawManifest =
101 toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
102 let indent = match raw.fmt.indent.as_deref() {
103 None => None,
104 Some("tab") => Some(IndentStyle::Tab),
105 Some("spaces") => Some(IndentStyle::Spaces(raw.fmt.indent_width.unwrap_or(2))),
111 Some(other) => return Err(ConfigError::Indent(other.to_string())),
112 };
113 if let Some(0) = raw.fmt.max_line_width {
114 return Err(ConfigError::MaxLineWidth(0));
115 }
116 Ok(Self {
117 indent,
118 max_line_width: raw.fmt.max_line_width,
119 trailing_comma: raw.fmt.trailing_comma,
120 })
121 }
122
123 pub fn apply(&self, base: FormatOptions) -> FormatOptions {
131 FormatOptions {
132 indent: self.indent.unwrap_or(base.indent),
133 max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
134 trailing_comma: self.trailing_comma.unwrap_or(base.trailing_comma),
135 }
136 }
137}
138
139pub fn find_manifest(start: &Path) -> Option<PathBuf> {
148 let mut dir = if start.is_dir() {
149 Some(start)
150 } else {
151 start.parent()
152 };
153 while let Some(d) = dir {
154 let candidate = d.join(MANIFEST);
155 if candidate.is_file() {
156 return Some(candidate);
157 }
158 dir = d.parent();
159 }
160 None
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn an_absent_fmt_section_defers_every_field() {
169 let cfg = FmtConfig::from_manifest_str("[project]\nname = \"x\"\n").expect("parses");
170 assert_eq!(cfg, FmtConfig::default());
171 let base = FormatOptions {
173 indent: IndentStyle::Spaces(3),
174 max_line_width: 77,
175 trailing_comma: false,
176 };
177 let out = cfg.apply(base);
178 assert_eq!(out.indent, base.indent);
179 assert_eq!(out.max_line_width, base.max_line_width);
180 assert_eq!(out.trailing_comma, base.trailing_comma);
181 }
182
183 #[test]
184 fn a_partial_section_overrides_only_what_it_states() {
185 let cfg = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 120\n").expect("parses");
186 let out = cfg.apply(FormatOptions::default());
187 assert_eq!(out.max_line_width, 120);
188 assert_eq!(out.indent, IndentStyle::Tab);
190 assert!(out.trailing_comma);
191 }
192
193 #[test]
194 fn spaces_takes_indent_width_and_defaults_to_two() {
195 let four = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\nindent_width = 4\n")
196 .expect("parses");
197 assert_eq!(four.indent, Some(IndentStyle::Spaces(4)));
198 let bare = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\n").expect("parses");
199 assert_eq!(bare.indent, Some(IndentStyle::Spaces(2)));
200 }
201
202 #[test]
203 fn indent_width_beside_tab_is_ignored_not_refused() {
204 let cfg = FmtConfig::from_manifest_str("[fmt]\nindent = \"tab\"\nindent_width = 4\n")
206 .expect("parses");
207 assert_eq!(cfg.indent, Some(IndentStyle::Tab));
208 }
209
210 #[test]
211 fn an_unknown_indent_word_is_an_error() {
212 let err = FmtConfig::from_manifest_str("[fmt]\nindent = \"tabs\"\n").expect_err("refused");
213 assert_eq!(err, ConfigError::Indent("tabs".into()));
214 assert!(err.to_string().contains("\"tab\" or \"spaces\""), "{err}");
215 }
216
217 #[test]
218 fn a_zero_max_line_width_is_an_error() {
219 let err = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 0\n").expect_err("refused");
220 assert_eq!(err, ConfigError::MaxLineWidth(0));
221 }
222
223 #[test]
224 fn a_misspelled_fmt_key_is_an_error_not_a_silent_no_op() {
225 let err =
228 FmtConfig::from_manifest_str("[fmt]\nmax_line_length = 120\n").expect_err("refused");
229 assert!(
230 matches!(err, ConfigError::Parse(_)),
231 "expected a parse error, got {err:?}"
232 );
233 }
234
235 #[test]
236 fn other_manifest_sections_are_left_to_their_own_readers() {
237 let cfg = FmtConfig::from_manifest_str(
240 "[project]\nname = \"x\"\n\n[paths]\ninclude = [\"src\"]\n\n[lsp]\ndiagnostics_mode = \"live\"\n\n[fmt]\ntrailing_comma = false\n",
241 )
242 .expect("parses");
243 assert_eq!(cfg.trailing_comma, Some(false));
244 }
245
246 #[test]
247 fn malformed_toml_is_reported_not_ignored() {
248 let err = FmtConfig::from_manifest_str("[fmt\nindent = \"tab\"\n").expect_err("refused");
249 assert!(matches!(err, ConfigError::Parse(_)), "{err:?}");
250 }
251}