use crate::error::{DictatorContext, suggestions};
use garde::Validate;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RuleIgnore {
#[serde(default)]
pub filenames: Vec<String>,
#[serde(default)]
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileConfig {
pub inherits: Option<String>,
#[serde(default)]
pub decree: HashMap<String, DecreeSettings>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct DictateConfig {
#[serde(default)]
pub decree: HashMap<String, DecreeSettings>,
#[serde(default)]
pub profile: HashMap<String, ProfileConfig>,
pub active_profile: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, Validate)]
#[garde(context(()))]
pub struct DecreeSettings {
#[garde(skip)]
pub enabled: Option<bool>,
#[garde(skip)]
pub path: Option<String>,
#[garde(custom(validate_whitespace_policy))]
pub trailing_whitespace: Option<String>,
#[garde(custom(validate_tabs_vs_spaces))]
pub tabs_vs_spaces: Option<String>,
#[garde(custom(validate_tab_width))]
pub tab_width: Option<usize>,
#[garde(custom(validate_newline_policy))]
pub final_newline: Option<String>,
#[garde(custom(validate_line_endings))]
pub line_endings: Option<String>,
#[garde(custom(validate_max_line_length))]
pub max_line_length: Option<usize>,
#[garde(custom(validate_whitespace_policy))]
pub blank_line_whitespace: Option<String>,
#[garde(custom(validate_max_lines))]
pub max_lines: Option<usize>,
#[garde(skip)]
pub ignore_comments: Option<bool>,
#[garde(skip)]
pub ignore_blank_lines: Option<bool>,
#[garde(skip)]
pub method_visibility_order: Option<Vec<String>>,
#[garde(skip)]
pub comment_spacing: Option<bool>,
#[garde(skip)]
pub import_order: Option<Vec<String>>,
#[garde(skip)]
pub visibility_order: Option<Vec<String>>,
#[garde(custom(validate_rust_edition))]
pub min_edition: Option<String>,
#[garde(custom(validate_rust_version))]
pub min_rust_version: Option<String>,
#[garde(skip)]
pub order: Option<Vec<String>>,
#[garde(skip)]
pub required: Option<Vec<String>>,
#[garde(skip)]
pub linter: Option<LinterConfig>,
#[serde(default)]
#[garde(skip)]
pub ignore: HashMap<String, RuleIgnore>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LinterConfig {
pub command: String,
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_whitespace_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
match v.as_str() {
"deny" | "allow" => Ok(()),
_ => Err(garde::Error::new(format!(
"'{v}' is not a valid policy - try 'deny' or 'allow'"
))),
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_tabs_vs_spaces(value: &Option<String>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
match v.as_str() {
"tabs" | "spaces" | "either" => Ok(()),
_ => Err(garde::Error::new(format!(
"'{v}' is not valid - use 'tabs', 'spaces', or 'either'"
))),
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_newline_policy(value: &Option<String>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
match v.as_str() {
"require" | "allow" => Ok(()),
_ => Err(garde::Error::new(format!(
"'{v}' is not valid - use 'require' or 'allow'"
))),
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_line_endings(value: &Option<String>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
match v.as_str() {
"lf" | "crlf" | "either" => Ok(()),
_ => Err(garde::Error::new(format!(
"'{v}' is not valid - use 'lf', 'crlf', or 'either'"
))),
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_tab_width(value: &Option<usize>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
if *v >= 1 && *v <= 16 {
Ok(())
} else {
Err(garde::Error::new(format!(
"{v} is outside the range 1-16 - common values are 2, 4, or 8"
)))
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_max_line_length(value: &Option<usize>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
if *v >= 40 && *v <= 500 {
Ok(())
} else {
Err(garde::Error::new(format!(
"{v} is outside the range 40-500 - common values are 80, 100, or 120"
)))
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_max_lines(value: &Option<usize>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
if *v >= 50 && *v <= 5000 {
Ok(())
} else {
Err(garde::Error::new(format!(
"{v} is outside the range 50-5000 - common values are 300, 400, or 500"
)))
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_rust_edition(value: &Option<String>, _ctx: &()) -> garde::Result {
if let Some(v) = value {
match v.as_str() {
"2015" | "2018" | "2021" | "2024" => Ok(()),
_ => Err(garde::Error::new(format!(
"'{v}' is not a valid Rust edition - use '2015', '2018', '2021', or '2024'"
))),
}
} else {
Ok(())
}
}
#[allow(
clippy::ref_option,
clippy::trivially_copy_pass_by_ref,
clippy::option_if_let_else
)]
fn validate_rust_version(value: &Option<String>, _ctx: &()) -> garde::Result {
let Some(v) = value else {
return Ok(());
};
let parts: Vec<&str> = v.split('.').collect();
if parts.len() < 2 || parts.len() > 3 {
return Err(garde::Error::new(format!(
"'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
)));
}
for part in parts {
if part.parse::<u32>().is_err() {
return Err(garde::Error::new(format!(
"'{v}' is not a valid Rust version - use format like '1.83' or '1.83.0'"
)));
}
}
Ok(())
}
#[derive(Debug)]
pub enum ConfigError {
Io(String),
Parse(String),
Validation(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "config read error: {e}"),
Self::Parse(e) => write!(f, "config parse error: {e}"),
Self::Validation(e) => write!(f, "config validation error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
impl DictateConfig {
pub fn from_file(path: &std::path::Path) -> Result<Self, ConfigError> {
let path_buf = path.to_path_buf();
let content = std::fs::read_to_string(path)
.config_context(
path_buf.clone(),
None,
suggestions::config_suggestions("file_not_found"),
)
.map_err(|e| ConfigError::Io(e.to_string()))?;
let config: Self = toml::from_str(&content)
.config_context(
path_buf,
None,
suggestions::config_suggestions("invalid_toml"),
)
.map_err(|e| ConfigError::Parse(e.to_string()))?;
config.validate_all_settings()?;
Ok(config)
}
#[must_use]
pub fn load_default() -> Option<Self> {
let cwd = std::env::current_dir().ok()?;
let config_path = cwd.join(".dictate.toml");
if !config_path.exists() {
return None;
}
Self::from_file(&config_path).ok()
}
pub fn load_default_strict() -> Result<Option<Self>, ConfigError> {
let cwd = std::env::current_dir().map_err(|e| ConfigError::Io(e.to_string()))?;
let config_path = cwd.join(".dictate.toml");
if !config_path.exists() {
return Ok(None);
}
Self::from_file(&config_path).map(Some)
}
pub fn get_profile_config(&self, profile_name: &str) -> Result<Self, String> {
let mut result = self.clone();
let mut current_profile = profile_name;
let mut visited_profiles = std::collections::HashSet::new();
let mut inheritance_chain = Vec::new();
loop {
if !visited_profiles.insert(current_profile.to_string()) {
return Err(format!(
"Circular profile inheritance detected involving '{current_profile}'"
));
}
let Some(profile) = self.profile.get(current_profile) else {
return Err(format!("Profile '{current_profile}' not found"));
};
inheritance_chain.push(profile);
if let Some(parent) = &profile.inherits {
current_profile = parent;
} else {
break;
}
}
for profile in inheritance_chain.iter().rev() {
for (decree_name, profile_settings) in &profile.decree {
if let Some(base_settings) = result.decree.get_mut(decree_name) {
merge_decree_settings(base_settings, profile_settings);
} else {
result
.decree
.insert(decree_name.clone(), profile_settings.clone());
}
}
}
Ok(result)
}
#[must_use]
pub fn get_active_profile_config(&self) -> Self {
let profile_name = self.active_profile.as_deref().unwrap_or("default");
if self.profile.contains_key(profile_name) {
self.get_profile_config(profile_name).unwrap_or_else(|e| {
eprintln!(
"Warning: Failed to resolve profile '{profile_name}': {e}. \
Using base configuration."
);
self.clone()
})
} else {
self.clone()
}
}
fn validate_all_settings(&self) -> Result<(), ConfigError> {
for (name, settings) in &self.decree {
settings
.validate()
.map_err(|e| ConfigError::Validation(format!("decree.{name}: {e}")))?;
}
for (profile_name, profile) in &self.profile {
for (name, settings) in &profile.decree {
settings.validate().map_err(|e| {
ConfigError::Validation(format!("profile.{profile_name}.decree.{name}: {e}"))
})?;
}
}
Ok(())
}
}
fn merge_decree_settings(base: &mut DecreeSettings, profile: &DecreeSettings) {
if profile.enabled.is_some() {
base.enabled = profile.enabled;
}
if profile.path.is_some() {
base.path.clone_from(&profile.path);
}
if profile.trailing_whitespace.is_some() {
base.trailing_whitespace
.clone_from(&profile.trailing_whitespace);
}
if profile.tabs_vs_spaces.is_some() {
base.tabs_vs_spaces.clone_from(&profile.tabs_vs_spaces);
}
if profile.tab_width.is_some() {
base.tab_width = profile.tab_width;
}
if profile.final_newline.is_some() {
base.final_newline.clone_from(&profile.final_newline);
}
if profile.line_endings.is_some() {
base.line_endings.clone_from(&profile.line_endings);
}
if profile.max_line_length.is_some() {
base.max_line_length = profile.max_line_length;
}
if profile.blank_line_whitespace.is_some() {
base.blank_line_whitespace
.clone_from(&profile.blank_line_whitespace);
}
if profile.max_lines.is_some() {
base.max_lines = profile.max_lines;
}
if profile.ignore_comments.is_some() {
base.ignore_comments = profile.ignore_comments;
}
if profile.ignore_blank_lines.is_some() {
base.ignore_blank_lines = profile.ignore_blank_lines;
}
if profile.method_visibility_order.is_some() {
base.method_visibility_order
.clone_from(&profile.method_visibility_order);
}
if profile.comment_spacing.is_some() {
base.comment_spacing = profile.comment_spacing;
}
if profile.import_order.is_some() {
base.import_order.clone_from(&profile.import_order);
}
if profile.visibility_order.is_some() {
base.visibility_order.clone_from(&profile.visibility_order);
}
if profile.min_edition.is_some() {
base.min_edition.clone_from(&profile.min_edition);
}
if profile.min_rust_version.is_some() {
base.min_rust_version.clone_from(&profile.min_rust_version);
}
if profile.order.is_some() {
base.order.clone_from(&profile.order);
}
if profile.required.is_some() {
base.required.clone_from(&profile.required);
}
if profile.linter.is_some() {
base.linter.clone_from(&profile.linter);
}
for (rule, ignore) in &profile.ignore {
base.ignore.insert(rule.clone(), ignore.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_config() {
let toml = r#"
[decree.supreme]
trailing_whitespace = "deny"
tabs_vs_spaces = "spaces"
tab_width = 2
final_newline = "require"
line_endings = "lf"
max_line_length = 120
blank_line_whitespace = "deny"
[decree.supreme.ignore.tab-character]
filenames = ["Makefile"]
extensions = ["md", "mdx"]
[decree.ruby]
max_lines = 300
ignore_comments = true
ignore_blank_lines = true
method_visibility_order = ["public", "protected", "private"]
comment_spacing = true
[decree.typescript]
max_lines = 350
ignore_comments = true
ignore_blank_lines = true
import_order = ["system", "external", "internal"]
"#;
let config: DictateConfig = toml::from_str(toml).unwrap();
for (name, settings) in &config.decree {
settings.validate().unwrap_or_else(|e| {
panic!("decree.{name} validation failed: {e}");
});
}
assert!(config.decree.contains_key("supreme"));
assert!(config.decree.contains_key("ruby"));
assert!(config.decree.contains_key("typescript"));
let supreme = config.decree.get("supreme").unwrap();
assert_eq!(supreme.max_line_length, Some(120));
assert_eq!(supreme.tabs_vs_spaces, Some("spaces".to_string()));
assert!(supreme.ignore.contains_key("tab-character"));
let ignore = supreme.ignore.get("tab-character").unwrap();
assert_eq!(ignore.filenames, vec!["Makefile".to_string()]);
assert_eq!(ignore.extensions, vec!["md".to_string(), "mdx".to_string()]);
let ruby = config.decree.get("ruby").unwrap();
assert_eq!(ruby.max_lines, Some(300));
assert_eq!(ruby.ignore_comments, Some(true));
let ts = config.decree.get("typescript").unwrap();
assert_eq!(ts.max_lines, Some(350));
}
#[test]
fn rejects_invalid_max_line_length() {
let settings = DecreeSettings {
max_line_length: Some(10), ..Default::default()
};
let result = settings.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("40-500"));
}
#[test]
fn rejects_negative_max_line_length_at_parse() {
let toml = r"
[decree.supreme]
max_line_length = -340
";
let result: Result<DictateConfig, _> = toml::from_str(toml);
assert!(result.is_err());
}
#[test]
fn rejects_invalid_tabs_vs_spaces() {
let settings = DecreeSettings {
tabs_vs_spaces: Some("tab".to_string()), ..Default::default()
};
let result = settings.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("tabs"));
}
#[test]
fn rejects_invalid_line_endings() {
let settings = DecreeSettings {
line_endings: Some("windows".to_string()), ..Default::default()
};
let result = settings.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("lf"));
}
#[test]
fn rejects_tab_width_out_of_range() {
let settings = DecreeSettings {
tab_width: Some(32), ..Default::default()
};
let result = settings.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("1-16"));
}
#[test]
fn rejects_max_lines_out_of_range() {
let settings = DecreeSettings {
max_lines: Some(10), ..Default::default()
};
let result = settings.validate();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("50-5000"));
}
#[test]
fn accepts_valid_settings() {
let settings = DecreeSettings {
trailing_whitespace: Some("deny".to_string()),
tabs_vs_spaces: Some("spaces".to_string()),
tab_width: Some(4),
final_newline: Some("require".to_string()),
line_endings: Some("lf".to_string()),
max_line_length: Some(100),
blank_line_whitespace: Some("allow".to_string()),
max_lines: Some(500),
..Default::default()
};
assert!(settings.validate().is_ok());
}
#[test]
fn accepts_none_values() {
let settings = DecreeSettings::default();
assert!(settings.validate().is_ok());
}
#[test]
fn profile_inheritance_applies_parent_before_child() {
let toml = r#"
[decree.supreme]
max_line_length = 100
[profile.relaxed.decree.supreme]
max_line_length = 120
[profile.ci]
inherits = "relaxed"
[profile.ci.decree.supreme]
max_line_length = 80
"#;
let config: DictateConfig = toml::from_str(toml).unwrap();
let effective = config.get_profile_config("ci").unwrap();
assert_eq!(effective.decree["supreme"].max_line_length, Some(80));
}
#[test]
fn profile_inheritance_rejects_missing_parent() {
let toml = r#"
[profile.ci]
inherits = "missing"
"#;
let config: DictateConfig = toml::from_str(toml).unwrap();
let err = config.get_profile_config("ci").unwrap_err();
assert!(err.contains("missing"));
}
#[test]
fn from_file_validates_profile_decree_settings() {
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(
&mut file,
br#"
[profile.ci.decree.supreme]
max_line_length = 10
"#,
)
.unwrap();
let err = DictateConfig::from_file(file.path()).unwrap_err();
assert!(err.to_string().contains("profile.ci.decree.supreme"));
assert!(err.to_string().contains("40-500"));
}
}