1use garde::Validate;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct RuleIgnore {
15 #[serde(default)]
17 pub filenames: Vec<String>,
18 #[serde(default)]
20 pub extensions: Vec<String>,
21}
22
23#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25pub struct DictateConfig {
26 #[serde(default)]
27 pub decree: HashMap<String, DecreeSettings>,
28}
29
30#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
32#[garde(context(()))]
33pub struct DecreeSettings {
34 #[garde(skip)]
36 pub enabled: Option<bool>,
37 #[garde(skip)]
38 pub path: Option<String>,
39
40 #[garde(custom(validate_whitespace_policy))]
42 pub trailing_whitespace: Option<String>,
43 #[garde(custom(validate_tabs_vs_spaces))]
44 pub tabs_vs_spaces: Option<String>,
45 #[garde(custom(validate_tab_width))]
46 pub tab_width: Option<usize>,
47 #[garde(custom(validate_newline_policy))]
48 pub final_newline: Option<String>,
49 #[garde(custom(validate_line_endings))]
50 pub line_endings: Option<String>,
51 #[garde(custom(validate_max_line_length))]
52 pub max_line_length: Option<usize>,
53 #[garde(custom(validate_whitespace_policy))]
54 pub blank_line_whitespace: Option<String>,
55
56 #[garde(custom(validate_max_lines))]
58 pub max_lines: Option<usize>,
59 #[garde(skip)]
60 pub ignore_comments: Option<bool>,
61 #[garde(skip)]
62 pub ignore_blank_lines: Option<bool>,
63 #[garde(skip)]
64 pub method_visibility_order: Option<Vec<String>>,
65 #[garde(skip)]
66 pub comment_spacing: Option<bool>,
67 #[garde(skip)]
68 pub import_order: Option<Vec<String>>,
69 #[garde(skip)]
70 pub visibility_order: Option<Vec<String>>,
71
72 #[garde(custom(validate_rust_edition))]
74 pub min_edition: Option<String>,
75 #[garde(custom(validate_rust_version))]
76 pub min_rust_version: Option<String>,
77
78 #[garde(skip)]
80 pub order: Option<Vec<String>>,
81 #[garde(skip)]
82 pub required: Option<Vec<String>>,
83
84 #[garde(skip)]
86 pub linter: Option<LinterConfig>,
87
88 #[serde(default)]
97 #[garde(skip)]
98 pub ignore: HashMap<String, RuleIgnore>,
99}
100
101#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct LinterConfig {
108 pub command: String,
109}
110
111#[allow(
117 clippy::ref_option,
118 clippy::trivially_copy_pass_by_ref,
119 clippy::option_if_let_else
120)]
121fn validate_whitespace_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
122 if let Some(v) = value {
123 match v.as_str() {
124 "deny" | "allow" => Ok(()),
125 _ => Err(garde::Error::new(format!(
126 "'{v}' is not a valid policy - try 'deny' or 'allow'"
127 ))),
128 }
129 } else {
130 Ok(())
131 }
132}
133
134#[allow(
135 clippy::ref_option,
136 clippy::trivially_copy_pass_by_ref,
137 clippy::option_if_let_else
138)]
139fn validate_tabs_vs_spaces(value: &Option<String>, _ctx: &()) -> garde::Result {
140 if let Some(v) = value {
141 match v.as_str() {
142 "tabs" | "spaces" | "either" => Ok(()),
143 _ => Err(garde::Error::new(format!(
144 "'{v}' is not valid - use 'tabs', 'spaces', or 'either'"
145 ))),
146 }
147 } else {
148 Ok(())
149 }
150}
151
152#[allow(
153 clippy::ref_option,
154 clippy::trivially_copy_pass_by_ref,
155 clippy::option_if_let_else
156)]
157fn validate_newline_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
158 if let Some(v) = value {
159 match v.as_str() {
160 "require" | "allow" => Ok(()),
161 _ => Err(garde::Error::new(format!(
162 "'{v}' is not valid - use 'require' or 'allow'"
163 ))),
164 }
165 } else {
166 Ok(())
167 }
168}
169
170#[allow(
171 clippy::ref_option,
172 clippy::trivially_copy_pass_by_ref,
173 clippy::option_if_let_else
174)]
175fn validate_line_endings(value: &Option<String>, _ctx: &()) -> garde::Result {
176 if let Some(v) = value {
177 match v.as_str() {
178 "lf" | "crlf" | "either" => Ok(()),
179 _ => Err(garde::Error::new(format!(
180 "'{v}' is not valid - use 'lf', 'crlf', or 'either'"
181 ))),
182 }
183 } else {
184 Ok(())
185 }
186}
187
188#[allow(
189 clippy::ref_option,
190 clippy::trivially_copy_pass_by_ref,
191 clippy::option_if_let_else
192)]
193fn validate_tab_width(value: &Option<usize>, _ctx: &()) -> garde::Result {
194 if let Some(v) = value {
195 if *v >= 1 && *v <= 16 {
196 Ok(())
197 } else {
198 Err(garde::Error::new(format!(
199 "{v} is outside the range 1-16 - common values are 2, 4, or 8"
200 )))
201 }
202 } else {
203 Ok(())
204 }
205}
206
207#[allow(
208 clippy::ref_option,
209 clippy::trivially_copy_pass_by_ref,
210 clippy::option_if_let_else
211)]
212fn validate_max_line_length(value: &Option<usize>, _ctx: &()) -> garde::Result {
213 if let Some(v) = value {
214 if *v >= 40 && *v <= 500 {
215 Ok(())
216 } else {
217 Err(garde::Error::new(format!(
218 "{v} is outside the range 40-500 - common values are 80, 100, or 120"
219 )))
220 }
221 } else {
222 Ok(())
223 }
224}
225
226#[allow(
227 clippy::ref_option,
228 clippy::trivially_copy_pass_by_ref,
229 clippy::option_if_let_else
230)]
231fn validate_max_lines(value: &Option<usize>, _ctx: &()) -> garde::Result {
232 if let Some(v) = value {
233 if *v >= 50 && *v <= 5000 {
234 Ok(())
235 } else {
236 Err(garde::Error::new(format!(
237 "{v} is outside the range 50-5000 - common values are 300, 400, or 500"
238 )))
239 }
240 } else {
241 Ok(())
242 }
243}
244
245#[allow(
246 clippy::ref_option,
247 clippy::trivially_copy_pass_by_ref,
248 clippy::option_if_let_else
249)]
250fn validate_rust_edition(value: &Option<String>, _ctx: &()) -> garde::Result {
251 if let Some(v) = value {
252 match v.as_str() {
253 "2015" | "2018" | "2021" | "2024" => Ok(()),
254 _ => Err(garde::Error::new(format!(
255 "'{v}' is not a valid Rust edition - use '2015', '2018', '2021', or '2024'"
256 ))),
257 }
258 } else {
259 Ok(())
260 }
261}
262
263#[allow(
264 clippy::ref_option,
265 clippy::trivially_copy_pass_by_ref,
266 clippy::option_if_let_else
267)]
268fn validate_rust_version(value: &Option<String>, _ctx: &()) -> garde::Result {
269 let Some(v) = value else {
270 return Ok(());
271 };
272 let parts: Vec<&str> = v.split('.').collect();
274 if parts.len() < 2 || parts.len() > 3 {
275 return Err(garde::Error::new(format!(
276 "'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
277 )));
278 }
279 for part in parts {
280 if part.parse::<u32>().is_err() {
281 return Err(garde::Error::new(format!(
282 "'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
283 )));
284 }
285 }
286 Ok(())
287}
288
289#[derive(Debug)]
295pub enum ConfigError {
296 Io(String),
297 Parse(String),
298 Validation(String),
299}
300
301impl std::fmt::Display for ConfigError {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 match self {
304 Self::Io(e) => write!(f, "config read error: {e}"),
305 Self::Parse(e) => write!(f, "config parse error: {e}"),
306 Self::Validation(e) => write!(f, "config validation error: {e}"),
307 }
308 }
309}
310
311impl std::error::Error for ConfigError {}
312
313impl DictateConfig {
318 pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
326 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
327
328 let config: Self =
329 toml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))?;
330
331 for (name, settings) in &config.decree {
333 settings
334 .validate()
335 .map_err(|e| ConfigError::Validation(format!("decree.{name}: {e}")))?;
336 }
337
338 Ok(config)
339 }
340
341 #[must_use]
343 pub fn load_default() -> Option<Self> {
344 let cwd = std::env::current_dir().ok()?;
345 let config_path = cwd.join(".dictate.toml");
346
347 if !config_path.exists() {
348 return None;
349 }
350
351 Self::from_file(&config_path).ok()
352 }
353
354 pub fn load_default_strict() -> Result<Option<Self>, ConfigError> {
360 let cwd = std::env::current_dir().map_err(|e| ConfigError::Io(e.to_string()))?;
361 let config_path = cwd.join(".dictate.toml");
362
363 if !config_path.exists() {
364 return Ok(None);
365 }
366
367 Self::from_file(&config_path).map(Some)
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn parses_valid_config() {
377 let toml = r#"
378[decree.supreme]
379trailing_whitespace = "deny"
380tabs_vs_spaces = "spaces"
381tab_width = 2
382final_newline = "require"
383line_endings = "lf"
384max_line_length = 120
385blank_line_whitespace = "deny"
386
387[decree.supreme.ignore.tab-character]
388filenames = ["Makefile"]
389extensions = ["md", "mdx"]
390
391[decree.ruby]
392max_lines = 300
393ignore_comments = true
394ignore_blank_lines = true
395method_visibility_order = ["public", "protected", "private"]
396comment_spacing = true
397
398[decree.typescript]
399max_lines = 350
400ignore_comments = true
401ignore_blank_lines = true
402import_order = ["system", "external", "internal"]
403"#;
404
405 let config: DictateConfig = toml::from_str(toml).unwrap();
406
407 for (name, settings) in &config.decree {
409 settings.validate().unwrap_or_else(|e| {
410 panic!("decree.{name} validation failed: {e}");
411 });
412 }
413
414 assert!(config.decree.contains_key("supreme"));
415 assert!(config.decree.contains_key("ruby"));
416 assert!(config.decree.contains_key("typescript"));
417
418 let supreme = config.decree.get("supreme").unwrap();
419 assert_eq!(supreme.max_line_length, Some(120));
420 assert_eq!(supreme.tabs_vs_spaces, Some("spaces".to_string()));
421 assert!(supreme.ignore.contains_key("tab-character"));
422 let ignore = supreme.ignore.get("tab-character").unwrap();
423 assert_eq!(ignore.filenames, vec!["Makefile".to_string()]);
424 assert_eq!(ignore.extensions, vec!["md".to_string(), "mdx".to_string()]);
425
426 let ruby = config.decree.get("ruby").unwrap();
427 assert_eq!(ruby.max_lines, Some(300));
428 assert_eq!(ruby.ignore_comments, Some(true));
429
430 let ts = config.decree.get("typescript").unwrap();
431 assert_eq!(ts.max_lines, Some(350));
432 }
433
434 #[test]
435 fn rejects_invalid_max_line_length() {
436 let settings = DecreeSettings {
437 max_line_length: Some(10), ..Default::default()
439 };
440
441 let result = settings.validate();
442 assert!(result.is_err());
443 let err = result.unwrap_err().to_string();
444 assert!(err.contains("40-500"));
445 }
446
447 #[test]
448 fn rejects_negative_max_line_length_at_parse() {
449 let toml = r"
451[decree.supreme]
452max_line_length = -340
453";
454 let result: Result<DictateConfig, _> = toml::from_str(toml);
455 assert!(result.is_err());
456 }
457
458 #[test]
459 fn rejects_invalid_tabs_vs_spaces() {
460 let settings = DecreeSettings {
461 tabs_vs_spaces: Some("tab".to_string()), ..Default::default()
463 };
464
465 let result = settings.validate();
466 assert!(result.is_err());
467 let err = result.unwrap_err().to_string();
468 assert!(err.contains("tabs"));
469 }
470
471 #[test]
472 fn rejects_invalid_line_endings() {
473 let settings = DecreeSettings {
474 line_endings: Some("windows".to_string()), ..Default::default()
476 };
477
478 let result = settings.validate();
479 assert!(result.is_err());
480 let err = result.unwrap_err().to_string();
481 assert!(err.contains("lf"));
482 }
483
484 #[test]
485 fn rejects_tab_width_out_of_range() {
486 let settings = DecreeSettings {
487 tab_width: Some(32), ..Default::default()
489 };
490
491 let result = settings.validate();
492 assert!(result.is_err());
493 let err = result.unwrap_err().to_string();
494 assert!(err.contains("1-16"));
495 }
496
497 #[test]
498 fn rejects_max_lines_out_of_range() {
499 let settings = DecreeSettings {
500 max_lines: Some(10), ..Default::default()
502 };
503
504 let result = settings.validate();
505 assert!(result.is_err());
506 let err = result.unwrap_err().to_string();
507 assert!(err.contains("50-5000"));
508 }
509
510 #[test]
511 fn accepts_valid_settings() {
512 let settings = DecreeSettings {
513 trailing_whitespace: Some("deny".to_string()),
514 tabs_vs_spaces: Some("spaces".to_string()),
515 tab_width: Some(4),
516 final_newline: Some("require".to_string()),
517 line_endings: Some("lf".to_string()),
518 max_line_length: Some(100),
519 blank_line_whitespace: Some("allow".to_string()),
520 max_lines: Some(500),
521 ..Default::default()
522 };
523
524 assert!(settings.validate().is_ok());
525 }
526
527 #[test]
528 fn accepts_none_values() {
529 let settings = DecreeSettings::default();
530 assert!(settings.validate().is_ok());
531 }
532}