1use crate::error::{DictatorContext, suggestions};
4use garde::Validate;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Default, Deserialize, Serialize)]
15pub struct RuleIgnore {
16 #[serde(default)]
18 pub filenames: Vec<String>,
19 #[serde(default)]
21 pub extensions: Vec<String>,
22}
23
24#[derive(Debug, Clone, Default, Deserialize, Serialize)]
26pub struct ProfileConfig {
27 pub inherits: Option<String>,
29
30 #[serde(default)]
32 pub decree: HashMap<String, DecreeSettings>,
33
34 pub description: Option<String>,
36}
37
38#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct DictateConfig {
41 #[serde(default)]
42 pub decree: HashMap<String, DecreeSettings>,
43
44 #[serde(default)]
46 pub profile: HashMap<String, ProfileConfig>,
47
48 pub active_profile: Option<String>,
50}
51
52#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
54#[garde(context(()))]
55pub struct DecreeSettings {
56 #[garde(skip)]
58 pub enabled: Option<bool>,
59 #[garde(skip)]
60 pub path: Option<String>,
61
62 #[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 #[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 #[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 #[garde(skip)]
104 pub order: Option<Vec<String>>,
105 #[garde(skip)]
106 pub required: Option<Vec<String>>,
107
108 #[garde(skip)]
110 pub linter: Option<LinterConfig>,
111
112 #[serde(default)]
121 #[garde(skip)]
122 pub ignore: HashMap<String, RuleIgnore>,
123}
124
125#[derive(Debug, Clone, Deserialize, Serialize)]
131pub struct LinterConfig {
132 pub command: String,
133}
134
135#[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 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#[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
338impl DictateConfig {
343 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 #[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 #[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 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 pub fn get_profile_config(&self, profile_name: &str) -> Result<Self, String> {
416 let mut result = self.clone();
417
418 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 for (decree_name, profile_settings) in &profile.decree {
445 if let Some(base_settings) = result.decree.get_mut(decree_name) {
446 merge_decree_settings(base_settings, profile_settings);
448 } else {
449 result
451 .decree
452 .insert(decree_name.clone(), profile_settings.clone());
453 }
454 }
455 }
456
457 Ok(result)
458 }
459
460 #[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 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
498fn merge_decree_settings(base: &mut DecreeSettings, profile: &DecreeSettings) {
500 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 for (rule, ignore) in &profile.ignore {
572 base.ignore.insert(rule.clone(), ignore.clone());
573 }
574}
575
576#[cfg(test)]
577mod tests;