use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::formatter::LineEnding;
use crate::linter::Severity;
pub const CONFIG_FILE_NAME: &str = "fatou.toml";
const DEFAULT_LINE_WIDTH: u32 = 92;
const DEFAULT_INDENT_WIDTH: u32 = 4;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Config {
pub format: FormatConfig,
pub lint: LintConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatConfig {
pub line_width: u32,
pub indent_width: u32,
pub line_ending: LineEnding,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LintConfig {
pub select: Option<Vec<String>>,
pub ignore: Vec<String>,
pub severity: BTreeMap<String, Severity>,
}
impl Default for FormatConfig {
fn default() -> Self {
Self {
line_width: DEFAULT_LINE_WIDTH,
indent_width: DEFAULT_INDENT_WIDTH,
line_ending: LineEnding::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum LineEndingConfig {
#[default]
Auto,
Lf,
Crlf,
Native,
}
impl From<LineEndingConfig> for LineEnding {
fn from(value: LineEndingConfig) -> Self {
match value {
LineEndingConfig::Auto => LineEnding::Auto,
LineEndingConfig::Lf => LineEnding::Lf,
LineEndingConfig::Crlf => LineEnding::Crlf,
LineEndingConfig::Native => LineEnding::Native,
}
}
}
#[derive(Debug)]
pub enum ConfigError {
Read { path: PathBuf, message: String },
Parse { path: PathBuf, message: String },
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Read { path, message } => {
write!(f, "failed to read {}: {message}", path.display())
}
ConfigError::Parse { path, message } => {
write!(f, "failed to parse {}: {message}", path.display())
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
format: RawFormat,
#[serde(default)]
lint: RawLint,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFormat {
#[serde(rename = "line-width")]
line_width: Option<u32>,
#[serde(rename = "indent-width")]
indent_width: Option<u32>,
#[serde(rename = "line_width")]
line_width_snake: Option<u32>,
#[serde(rename = "indent_width")]
indent_width_snake: Option<u32>,
#[serde(rename = "line-ending")]
line_ending: Option<LineEndingConfig>,
}
impl RawFormat {
fn resolve(self, defaults: &FormatConfig, warnings: &mut Vec<String>) -> FormatConfig {
if self.line_width_snake.is_some() {
warnings.push(deprecated_key("line_width", "line-width"));
}
if self.indent_width_snake.is_some() {
warnings.push(deprecated_key("indent_width", "indent-width"));
}
FormatConfig {
line_width: self
.line_width
.or(self.line_width_snake)
.unwrap_or(defaults.line_width),
indent_width: self
.indent_width
.or(self.indent_width_snake)
.unwrap_or(defaults.indent_width),
line_ending: self
.line_ending
.map(LineEnding::from)
.unwrap_or(defaults.line_ending),
}
}
}
fn deprecated_key(old: &str, new: &str) -> String {
format!("`{old}` in [format] is deprecated; use `{new}`")
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLint {
select: Option<Vec<String>>,
#[serde(default)]
ignore: Vec<String>,
#[serde(default)]
severity: BTreeMap<String, Severity>,
}
impl Config {
pub fn resolve(
explicit: Option<&Path>,
no_config: bool,
anchor: &Path,
) -> Result<(Self, Option<PathBuf>, Vec<String>), ConfigError> {
if no_config {
return Ok((Self::default(), None, Vec::new()));
}
if let Some(path) = explicit {
let (config, warnings) = Self::load(path)?;
return Ok((config, Some(path.to_path_buf()), warnings));
}
match discover(anchor) {
Some(path) => {
let (config, warnings) = Self::load(&path)?;
Ok((config, Some(path), warnings))
}
None => Ok((Self::default(), None, Vec::new())),
}
}
fn load(path: &Path) -> Result<(Self, Vec<String>), ConfigError> {
let text = std::fs::read_to_string(path).map_err(|err| ConfigError::Read {
path: path.to_path_buf(),
message: err.to_string(),
})?;
let raw: RawConfig = toml::from_str(&text).map_err(|err| ConfigError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
})?;
Ok(raw.into_config())
}
}
impl RawConfig {
fn into_config(self) -> (Config, Vec<String>) {
let defaults = FormatConfig::default();
let mut warnings = Vec::new();
let config = Config {
format: self.format.resolve(&defaults, &mut warnings),
lint: LintConfig {
select: self.lint.select,
ignore: self.lint.ignore,
severity: self.lint.severity,
},
};
(config, warnings)
}
}
fn discover(anchor: &Path) -> Option<PathBuf> {
for dir in anchor.ancestors() {
let candidate = dir.join(CONFIG_FILE_NAME);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_julia_conventions() {
let config = Config::default();
assert_eq!(config.format.line_width, 92);
assert_eq!(config.format.indent_width, 4);
assert_eq!(config.format.line_ending, LineEnding::Auto);
}
#[test]
fn line_ending_defaults_to_auto() {
let raw: RawConfig = toml::from_str("[format]\n").unwrap();
let (config, _) = raw.into_config();
assert_eq!(config.format.line_ending, LineEnding::Auto);
}
#[test]
fn parses_line_ending_variants() {
for (key, expected) in [
("auto", LineEnding::Auto),
("lf", LineEnding::Lf),
("crlf", LineEnding::Crlf),
("native", LineEnding::Native),
] {
let text = format!("[format]\nline-ending = \"{key}\"\n");
let raw: RawConfig = toml::from_str(&text).unwrap();
let (config, _) = raw.into_config();
assert_eq!(config.format.line_ending, expected, "for {key}");
}
}
#[test]
fn rejects_unknown_line_ending() {
toml::from_str::<RawConfig>("[format]\nline-ending = \"mac\"\n")
.expect_err("unknown variant should be rejected");
}
#[test]
fn parses_partial_toml() {
let raw: RawConfig = toml::from_str("[format]\nline-width = 100\n").unwrap();
let (config, warnings) = raw.into_config();
assert_eq!(config.format.line_width, 100);
assert_eq!(config.format.indent_width, 4);
assert!(warnings.is_empty());
}
#[test]
fn snake_case_keys_are_accepted_with_a_warning() {
let raw: RawConfig =
toml::from_str("[format]\nline_width = 100\nindent_width = 2\n").unwrap();
let (config, warnings) = raw.into_config();
assert_eq!(config.format.line_width, 100);
assert_eq!(config.format.indent_width, 2);
assert_eq!(
warnings,
vec![
"`line_width` in [format] is deprecated; use `line-width`".to_string(),
"`indent_width` in [format] is deprecated; use `indent-width`".to_string(),
],
);
}
#[test]
fn kebab_case_wins_when_both_forms_present() {
let raw: RawConfig =
toml::from_str("[format]\nline-width = 100\nline_width = 80\n").unwrap();
let (config, warnings) = raw.into_config();
assert_eq!(config.format.line_width, 100);
assert_eq!(warnings.len(), 1);
}
#[test]
fn parses_lint_severity_table() {
let raw: RawConfig = toml::from_str(
"[lint.severity]\nunused-binding = \"error\"\nunused-import = \"hint\"\n",
)
.unwrap();
let (config, _) = raw.into_config();
assert_eq!(
config.lint.severity.get("unused-binding"),
Some(&Severity::Error)
);
assert_eq!(
config.lint.severity.get("unused-import"),
Some(&Severity::Hint)
);
}
#[test]
fn rejects_unknown_severity_value() {
toml::from_str::<RawConfig>("[lint.severity]\nunused-binding = \"fatal\"\n")
.expect_err("unknown severity should be rejected");
}
#[test]
fn no_config_returns_defaults() {
let (config, path, warnings) =
Config::resolve(None, true, Path::new("/nonexistent")).unwrap();
assert_eq!(config, Config::default());
assert!(path.is_none());
assert!(warnings.is_empty());
}
}