dictator_core/
config.rs

1//! Configuration loading for .dictate.toml
2
3use garde::Validate;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Per-rule ignore configuration.
8///
9/// Ignores are evaluated by the host (Regime) after decrees emit diagnostics.
10/// The match is based on:
11/// - `filenames`: exact filename match (e.g. `Makefile`)
12/// - `extensions`: file extension match (e.g. `md`, `mdx`)
13#[derive(Debug, Clone, Default, Deserialize, Serialize)]
14pub struct RuleIgnore {
15    /// Exact filenames to ignore this rule for.
16    #[serde(default)]
17    pub filenames: Vec<String>,
18    /// File extensions to ignore this rule for (case-insensitive).
19    #[serde(default)]
20    pub extensions: Vec<String>,
21}
22
23/// Root configuration from .dictate.toml
24#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25pub struct DictateConfig {
26    #[serde(default)]
27    pub decree: HashMap<String, DecreeSettings>,
28}
29
30/// Settings for a specific decree (language)
31#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
32#[garde(context(()))]
33pub struct DecreeSettings {
34    // Custom decree loading (for WASM/native plugins)
35    #[garde(skip)]
36    pub enabled: Option<bool>,
37    #[garde(skip)]
38    pub path: Option<String>,
39
40    // Supreme decree settings
41    #[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    // Language-specific settings
57    #[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    // Rust-specific settings
73    #[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    // Frontmatter decree settings
79    #[garde(skip)]
80    pub order: Option<Vec<String>>,
81    #[garde(skip)]
82    pub required: Option<Vec<String>>,
83
84    // Linter integration (for supremecourt mode)
85    #[garde(skip)]
86    pub linter: Option<LinterConfig>,
87
88    /// Ignore specific rules for specific files/extensions.
89    ///
90    /// Example:
91    /// ```toml
92    /// [decree.supreme.ignore.tab-character]
93    /// filenames = ["Makefile"]
94    /// extensions = ["md", "mdx"]
95    /// ```
96    #[serde(default)]
97    #[garde(skip)]
98    pub ignore: HashMap<String, RuleIgnore>,
99}
100
101/// External linter configuration
102///
103/// Only specify the command - Dictator controls the args for:
104/// - Autofix mode (-A, --fix, etc.)
105/// - Parseable output format (--format json, etc.)
106#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct LinterConfig {
108    pub command: String,
109}
110
111// ============================================================================
112// Custom Validators
113// Note: garde requires `&Option<T>` and `&()` signatures - clippy lints suppressed
114// ============================================================================
115
116#[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    // Accept semver-like versions: 1.70, 1.70.0, 1.83.1
273    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// ============================================================================
290// Config Error
291// ============================================================================
292
293/// Error type for configuration loading
294#[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
313// ============================================================================
314// Config Loading
315// ============================================================================
316
317impl DictateConfig {
318    /// Load configuration from a TOML file with validation.
319    ///
320    /// # Errors
321    ///
322    /// Returns `ConfigError::Io` if the file cannot be read.
323    /// Returns `ConfigError::Parse` if the TOML content is invalid.
324    /// Returns `ConfigError::Validation` if decree settings fail validation.
325    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        // Validate all decree settings
332        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    /// Load from default location (.dictate.toml in current directory)
342    #[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    /// Load from default location, returning error details on failure.
355    ///
356    /// # Errors
357    ///
358    /// Returns `ConfigError` with details if loading or validation fails.
359    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        // Validate all decrees
408        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), // Too small (min 40)
438            ..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        // Negative values fail at TOML parse for usize
450        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()), // Should be "tabs"
462            ..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()), // Should be "crlf"
475            ..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), // Max is 16
488            ..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), // Min is 50
501            ..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}