Skip to main content

dictator_core/
config.rs

1//! Configuration loading for .dictate.toml
2
3use crate::error::{DictatorContext, suggestions};
4use garde::Validate;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Per-rule ignore configuration.
9///
10/// Ignores are evaluated by the host (Regime) after decrees emit diagnostics.
11/// The match is based on:
12/// - `filenames`: exact filename match (e.g. `Makefile`)
13/// - `extensions`: file extension match (e.g. `md`, `mdx`)
14#[derive(Debug, Clone, Default, Deserialize, Serialize)]
15pub struct RuleIgnore {
16    /// Exact filenames to ignore this rule for.
17    #[serde(default)]
18    pub filenames: Vec<String>,
19    /// File extensions to ignore this rule for (case-insensitive).
20    #[serde(default)]
21    pub extensions: Vec<String>,
22}
23
24/// Configuration profile for different environments (strict, relaxed, etc.)
25#[derive(Debug, Clone, Default, Deserialize, Serialize)]
26pub struct ProfileConfig {
27    /// Inherit from another profile (optional)
28    pub inherits: Option<String>,
29
30    /// Override decree settings for this profile
31    #[serde(default)]
32    pub decree: HashMap<String, DecreeSettings>,
33
34    /// Profile description
35    pub description: Option<String>,
36}
37
38/// Root configuration from .dictate.toml
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct DictateConfig {
41    #[serde(default)]
42    pub decree: HashMap<String, DecreeSettings>,
43
44    /// Configuration profiles for different environments
45    #[serde(default)]
46    pub profile: HashMap<String, ProfileConfig>,
47
48    /// Active profile (defaults to "default" if not specified)
49    pub active_profile: Option<String>,
50}
51
52/// Settings for a specific decree (language)
53#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
54#[garde(context(()))]
55pub struct DecreeSettings {
56    // Custom decree loading (for WASM/native plugins)
57    #[garde(skip)]
58    pub enabled: Option<bool>,
59    #[garde(skip)]
60    pub path: Option<String>,
61
62    // Supreme decree settings
63    #[garde(custom(validate_whitespace_policy))]
64    pub trailing_whitespace: Option<String>,
65    #[garde(custom(validate_tabs_vs_spaces))]
66    pub tabs_vs_spaces: Option<String>,
67    #[garde(custom(validate_tab_width))]
68    pub tab_width: Option<usize>,
69    #[garde(custom(validate_newline_policy))]
70    pub final_newline: Option<String>,
71    #[garde(custom(validate_line_endings))]
72    pub line_endings: Option<String>,
73    #[garde(custom(validate_max_line_length))]
74    pub max_line_length: Option<usize>,
75    #[garde(custom(validate_whitespace_policy))]
76    pub blank_line_whitespace: Option<String>,
77
78    // Language-specific settings
79    #[garde(custom(validate_max_lines))]
80    pub max_lines: Option<usize>,
81    #[garde(skip)]
82    pub ignore_comments: Option<bool>,
83    #[garde(skip)]
84    pub ignore_blank_lines: Option<bool>,
85    #[garde(skip)]
86    pub ignore_heredocs: Option<bool>,
87    #[garde(skip)]
88    pub method_visibility_order: Option<Vec<String>>,
89    #[garde(skip)]
90    pub comment_spacing: Option<bool>,
91    #[garde(skip)]
92    pub import_order: Option<Vec<String>>,
93    #[garde(skip)]
94    pub visibility_order: Option<Vec<String>>,
95
96    // Rust-specific settings
97    #[garde(custom(validate_rust_edition))]
98    pub min_edition: Option<String>,
99    #[garde(custom(validate_rust_version))]
100    pub min_rust_version: Option<String>,
101
102    // Frontmatter decree settings
103    #[garde(skip)]
104    pub order: Option<Vec<String>>,
105    #[garde(skip)]
106    pub required: Option<Vec<String>>,
107
108    // Linter integration (for supremecourt mode)
109    #[garde(skip)]
110    pub linter: Option<LinterConfig>,
111
112    /// Ignore specific rules for specific files/extensions.
113    ///
114    /// Example:
115    /// ```toml
116    /// [decree.supreme.ignore.tab-character]
117    /// filenames = ["Makefile"]
118    /// extensions = ["md", "mdx"]
119    /// ```
120    #[serde(default)]
121    #[garde(skip)]
122    pub ignore: HashMap<String, RuleIgnore>,
123}
124
125/// External linter configuration
126///
127/// Only specify the command - Dictator controls the args for:
128/// - Autofix mode (-A, --fix, etc.)
129/// - Parseable output format (--format json, etc.)
130#[derive(Debug, Clone, Deserialize, Serialize)]
131pub struct LinterConfig {
132    pub command: String,
133}
134
135// ============================================================================
136// Custom Validators
137// Note: garde requires `&Option<T>` and `&()` signatures - clippy lints suppressed
138// ============================================================================
139
140#[allow(
141    clippy::ref_option,
142    clippy::trivially_copy_pass_by_ref,
143    clippy::option_if_let_else
144)]
145fn validate_whitespace_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
146    if let Some(v) = value {
147        match v.as_str() {
148            "deny" | "allow" => Ok(()),
149            _ => Err(garde::Error::new(format!(
150                "'{v}' is not a valid policy - try 'deny' or 'allow'"
151            ))),
152        }
153    } else {
154        Ok(())
155    }
156}
157
158#[allow(
159    clippy::ref_option,
160    clippy::trivially_copy_pass_by_ref,
161    clippy::option_if_let_else
162)]
163fn validate_tabs_vs_spaces(value: &Option<String>, _ctx: &()) -> garde::Result {
164    if let Some(v) = value {
165        match v.as_str() {
166            "tabs" | "spaces" | "either" => Ok(()),
167            _ => Err(garde::Error::new(format!(
168                "'{v}' is not valid - use 'tabs', 'spaces', or 'either'"
169            ))),
170        }
171    } else {
172        Ok(())
173    }
174}
175
176#[allow(
177    clippy::ref_option,
178    clippy::trivially_copy_pass_by_ref,
179    clippy::option_if_let_else
180)]
181fn validate_newline_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
182    if let Some(v) = value {
183        match v.as_str() {
184            "require" | "allow" => Ok(()),
185            _ => Err(garde::Error::new(format!(
186                "'{v}' is not valid - use 'require' or 'allow'"
187            ))),
188        }
189    } else {
190        Ok(())
191    }
192}
193
194#[allow(
195    clippy::ref_option,
196    clippy::trivially_copy_pass_by_ref,
197    clippy::option_if_let_else
198)]
199fn validate_line_endings(value: &Option<String>, _ctx: &()) -> garde::Result {
200    if let Some(v) = value {
201        match v.as_str() {
202            "lf" | "crlf" | "either" => Ok(()),
203            _ => Err(garde::Error::new(format!(
204                "'{v}' is not valid - use 'lf', 'crlf', or 'either'"
205            ))),
206        }
207    } else {
208        Ok(())
209    }
210}
211
212#[allow(
213    clippy::ref_option,
214    clippy::trivially_copy_pass_by_ref,
215    clippy::option_if_let_else
216)]
217fn validate_tab_width(value: &Option<usize>, _ctx: &()) -> garde::Result {
218    if let Some(v) = value {
219        if *v >= 1 && *v <= 16 {
220            Ok(())
221        } else {
222            Err(garde::Error::new(format!(
223                "{v} is outside the range 1-16 - common values are 2, 4, or 8"
224            )))
225        }
226    } else {
227        Ok(())
228    }
229}
230
231#[allow(
232    clippy::ref_option,
233    clippy::trivially_copy_pass_by_ref,
234    clippy::option_if_let_else
235)]
236fn validate_max_line_length(value: &Option<usize>, _ctx: &()) -> garde::Result {
237    if let Some(v) = value {
238        if *v >= 40 && *v <= 500 {
239            Ok(())
240        } else {
241            Err(garde::Error::new(format!(
242                "{v} is outside the range 40-500 - common values are 80, 100, or 120"
243            )))
244        }
245    } else {
246        Ok(())
247    }
248}
249
250#[allow(
251    clippy::ref_option,
252    clippy::trivially_copy_pass_by_ref,
253    clippy::option_if_let_else
254)]
255fn validate_max_lines(value: &Option<usize>, _ctx: &()) -> garde::Result {
256    if let Some(v) = value {
257        if *v == 0 || (50..=5000).contains(v) {
258            Ok(())
259        } else {
260            Err(garde::Error::new(format!(
261                "{v} is outside the range 50-5000 - common values are 300, 400, or 500 \
262                 (0 disables the check)"
263            )))
264        }
265    } else {
266        Ok(())
267    }
268}
269
270#[allow(
271    clippy::ref_option,
272    clippy::trivially_copy_pass_by_ref,
273    clippy::option_if_let_else
274)]
275fn validate_rust_edition(value: &Option<String>, _ctx: &()) -> garde::Result {
276    if let Some(v) = value {
277        match v.as_str() {
278            "2015" | "2018" | "2021" | "2024" => Ok(()),
279            _ => Err(garde::Error::new(format!(
280                "'{v}' is not a valid Rust edition - use '2015', '2018', '2021', or '2024'"
281            ))),
282        }
283    } else {
284        Ok(())
285    }
286}
287
288#[allow(
289    clippy::ref_option,
290    clippy::trivially_copy_pass_by_ref,
291    clippy::option_if_let_else
292)]
293fn validate_rust_version(value: &Option<String>, _ctx: &()) -> garde::Result {
294    let Some(v) = value else {
295        return Ok(());
296    };
297    // Accept semver-like versions: 1.70, 1.70.0, 1.83.1
298    let parts: Vec<&str> = v.split('.').collect();
299    if parts.len() < 2 || parts.len() > 3 {
300        return Err(garde::Error::new(format!(
301            "'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
302        )));
303    }
304    for part in parts {
305        if part.parse::<u32>().is_err() {
306            return Err(garde::Error::new(format!(
307                "'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
308            )));
309        }
310    }
311    Ok(())
312}
313
314// ============================================================================
315// Config Error
316// ============================================================================
317
318/// Error type for configuration loading
319#[derive(Debug)]
320pub enum ConfigError {
321    Io(String),
322    Parse(String),
323    Validation(String),
324}
325
326impl std::fmt::Display for ConfigError {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Self::Io(e) => write!(f, "config read error: {e}"),
330            Self::Parse(e) => write!(f, "config parse error: {e}"),
331            Self::Validation(e) => write!(f, "config validation error: {e}"),
332        }
333    }
334}
335
336impl std::error::Error for ConfigError {}
337
338// ============================================================================
339// Config Loading
340// ============================================================================
341
342impl DictateConfig {
343    /// Load configuration from a TOML file with validation.
344    ///
345    /// # Errors
346    ///
347    /// Returns `ConfigError::Io` if the file cannot be read.
348    /// Returns `ConfigError::Parse` if the TOML content is invalid.
349    /// Returns `ConfigError::Validation` if decree settings fail validation.
350    pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
351        let path_buf = path.to_path_buf();
352        let content = std::fs::read_to_string(path)
353            .config_context(
354                path_buf.clone(),
355                None,
356                suggestions::config_suggestions("file_not_found"),
357            )
358            .map_err(|e| ConfigError::Io(e.to_string()))?;
359
360        let config: Self = toml::from_str(&content)
361            .config_context(
362                path_buf,
363                None,
364                suggestions::config_suggestions("invalid_toml"),
365            )
366            .map_err(|e| ConfigError::Parse(e.to_string()))?;
367
368        config.validate_all_settings()?;
369
370        Ok(config)
371    }
372
373    /// Load from default location (.dictate.toml in current directory)
374    #[must_use]
375    pub fn load_default() -> Option<Self> {
376        let cwd = std::env::current_dir().ok()?;
377        Self::load_from_dir(&cwd)
378    }
379
380    /// Load `.dictate.toml` from a specific directory, bypassing the process cwd.
381    ///
382    /// Lets MCP handlers honor a caller-supplied `workspace` override instead of
383    /// always reading the server process's own working directory.
384    #[must_use]
385    pub fn load_from_dir(dir: &std::path::Path) -> Option<Self> {
386        let config_path = dir.join(".dictate.toml");
387
388        if !config_path.exists() {
389            return None;
390        }
391
392        Self::from_file(&config_path).ok()
393    }
394
395    /// Load from default location, returning error details on failure.
396    ///
397    /// # Errors
398    ///
399    /// Returns `ConfigError` with details if loading or validation fails.
400    pub fn load_default_strict() -> Result<Option<Self>, ConfigError> {
401        let cwd = std::env::current_dir().map_err(|e| ConfigError::Io(e.to_string()))?;
402        let config_path = cwd.join(".dictate.toml");
403
404        if !config_path.exists() {
405            return Ok(None);
406        }
407
408        Self::from_file(&config_path).map(Some)
409    }
410
411    /// Get the effective configuration for a specific profile
412    ///
413    /// This resolves profile inheritance and merges profile-specific settings
414    /// with the base configuration.
415    pub fn get_profile_config(&self, profile_name: &str) -> Result<Self, String> {
416        let mut result = self.clone();
417
418        // Resolve parent profiles first so the requested profile remains most specific.
419        let mut current_profile = profile_name;
420        let mut visited_profiles = std::collections::HashSet::new();
421        let mut inheritance_chain = Vec::new();
422
423        loop {
424            if !visited_profiles.insert(current_profile.to_string()) {
425                return Err(format!(
426                    "Circular profile inheritance detected involving '{current_profile}'"
427                ));
428            }
429
430            let Some(profile) = self.profile.get(current_profile) else {
431                return Err(format!("Profile '{current_profile}' not found"));
432            };
433            inheritance_chain.push(profile);
434
435            if let Some(parent) = &profile.inherits {
436                current_profile = parent;
437            } else {
438                break;
439            }
440        }
441
442        for profile in inheritance_chain.iter().rev() {
443            // Merge profile decree settings
444            for (decree_name, profile_settings) in &profile.decree {
445                if let Some(base_settings) = result.decree.get_mut(decree_name) {
446                    // Merge settings (profile overrides base)
447                    merge_decree_settings(base_settings, profile_settings);
448                } else {
449                    // Add new decree settings from profile
450                    result
451                        .decree
452                        .insert(decree_name.clone(), profile_settings.clone());
453                }
454            }
455        }
456
457        Ok(result)
458    }
459
460    /// Get the active profile configuration
461    #[must_use]
462    pub fn get_active_profile_config(&self) -> Self {
463        let profile_name = self.active_profile.as_deref().unwrap_or("default");
464
465        if self.profile.contains_key(profile_name) {
466            self.get_profile_config(profile_name).unwrap_or_else(|e| {
467                eprintln!(
468                    "Warning: Failed to resolve profile '{profile_name}': {e}. \
469                     Using base configuration."
470                );
471                self.clone()
472            })
473        } else {
474            // No active profile or profile doesn't exist, use base config
475            self.clone()
476        }
477    }
478
479    fn validate_all_settings(&self) -> Result<(), ConfigError> {
480        for (name, settings) in &self.decree {
481            settings
482                .validate()
483                .map_err(|e| ConfigError::Validation(format!("decree.{name}: {e}")))?;
484        }
485
486        for (profile_name, profile) in &self.profile {
487            for (name, settings) in &profile.decree {
488                settings.validate().map_err(|e| {
489                    ConfigError::Validation(format!("profile.{profile_name}.decree.{name}: {e}"))
490                })?;
491            }
492        }
493
494        Ok(())
495    }
496}
497
498/// Merge profile settings into base settings (profile overrides base)
499fn merge_decree_settings(base: &mut DecreeSettings, profile: &DecreeSettings) {
500    // Override each field if profile provides a value
501    if profile.enabled.is_some() {
502        base.enabled = profile.enabled;
503    }
504    if profile.path.is_some() {
505        base.path.clone_from(&profile.path);
506    }
507    if profile.trailing_whitespace.is_some() {
508        base.trailing_whitespace
509            .clone_from(&profile.trailing_whitespace);
510    }
511    if profile.tabs_vs_spaces.is_some() {
512        base.tabs_vs_spaces.clone_from(&profile.tabs_vs_spaces);
513    }
514    if profile.tab_width.is_some() {
515        base.tab_width = profile.tab_width;
516    }
517    if profile.final_newline.is_some() {
518        base.final_newline.clone_from(&profile.final_newline);
519    }
520    if profile.line_endings.is_some() {
521        base.line_endings.clone_from(&profile.line_endings);
522    }
523    if profile.max_line_length.is_some() {
524        base.max_line_length = profile.max_line_length;
525    }
526    if profile.blank_line_whitespace.is_some() {
527        base.blank_line_whitespace
528            .clone_from(&profile.blank_line_whitespace);
529    }
530    if profile.max_lines.is_some() {
531        base.max_lines = profile.max_lines;
532    }
533    if profile.ignore_comments.is_some() {
534        base.ignore_comments = profile.ignore_comments;
535    }
536    if profile.ignore_blank_lines.is_some() {
537        base.ignore_blank_lines = profile.ignore_blank_lines;
538    }
539    if profile.ignore_heredocs.is_some() {
540        base.ignore_heredocs = profile.ignore_heredocs;
541    }
542    if profile.method_visibility_order.is_some() {
543        base.method_visibility_order
544            .clone_from(&profile.method_visibility_order);
545    }
546    if profile.comment_spacing.is_some() {
547        base.comment_spacing = profile.comment_spacing;
548    }
549    if profile.import_order.is_some() {
550        base.import_order.clone_from(&profile.import_order);
551    }
552    if profile.visibility_order.is_some() {
553        base.visibility_order.clone_from(&profile.visibility_order);
554    }
555    if profile.min_edition.is_some() {
556        base.min_edition.clone_from(&profile.min_edition);
557    }
558    if profile.min_rust_version.is_some() {
559        base.min_rust_version.clone_from(&profile.min_rust_version);
560    }
561    if profile.order.is_some() {
562        base.order.clone_from(&profile.order);
563    }
564    if profile.required.is_some() {
565        base.required.clone_from(&profile.required);
566    }
567    if profile.linter.is_some() {
568        base.linter.clone_from(&profile.linter);
569    }
570    // Merge ignore rules (profile adds to base)
571    for (rule, ignore) in &profile.ignore {
572        base.ignore.insert(rule.clone(), ignore.clone());
573    }
574}
575
576#[cfg(test)]
577mod tests;