use crate::error_handling::{ErrorConfig, ErrorMode, ErrorOverride, ErrorType};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct FerroConfig {
pub error_handling: ErrorHandlingConfig,
}
#[derive(Debug, Clone, Default)]
pub struct ErrorHandlingConfig {
pub mode: Option<String>,
pub ignore: Vec<String>,
pub reject: Vec<String>,
pub warn: Vec<String>,
}
impl FerroConfig {
pub fn load() -> Option<Self> {
let cwd_config = PathBuf::from(".ferro.toml");
if cwd_config.exists() {
if let Ok(config) = Self::load_from_path(&cwd_config) {
return Some(config);
}
}
if let Some(home) = dirs_home() {
let home_config = home.join(".config").join("ferro").join("config.toml");
if home_config.exists() {
if let Ok(config) = Self::load_from_path(&home_config) {
return Some(config);
}
}
}
None
}
pub fn load_from_path(path: &PathBuf) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self, ConfigError> {
let mut config = FerroConfig::default();
let mut in_error_handling = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
let section = &line[1..line.len() - 1];
in_error_handling = section == "error-handling";
continue;
}
if !in_error_handling {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"mode" => {
let mode = value.trim_matches('"').trim_matches('\'');
config.error_handling.mode = Some(mode.to_string());
}
"ignore" => {
config.error_handling.ignore = parse_string_array(value);
}
"reject" => {
config.error_handling.reject = parse_string_array(value);
}
"warn" => {
config.error_handling.warn = parse_string_array(value);
}
_ => {}
}
}
}
Ok(config)
}
pub fn to_error_config(&self) -> ErrorConfig {
let mode = match self.error_handling.mode.as_deref() {
Some("lenient") => ErrorMode::Lenient,
Some("silent") => ErrorMode::Silent,
_ => ErrorMode::Strict,
};
let mut config = ErrorConfig::new(mode);
for code in &self.error_handling.ignore {
apply_override(
&mut config,
code,
ErrorOverride::SilentCorrect,
"ignore",
OverrideSource::ConfigFile,
);
}
for code in &self.error_handling.reject {
apply_override(
&mut config,
code,
ErrorOverride::Reject,
"reject",
OverrideSource::ConfigFile,
);
}
for code in &self.error_handling.warn {
apply_override(
&mut config,
code,
ErrorOverride::WarnCorrect,
"warn",
OverrideSource::ConfigFile,
);
}
config
}
pub fn merge_with_cli(
&self,
cli_mode: Option<&str>,
cli_ignore: &[String],
cli_reject: &[String],
) -> ErrorConfig {
let mut config = self.to_error_config();
if let Some(mode_str) = cli_mode {
config.mode = match mode_str {
"lenient" => ErrorMode::Lenient,
"silent" => ErrorMode::Silent,
_ => ErrorMode::Strict,
};
}
for code in cli_ignore {
apply_override(
&mut config,
code,
ErrorOverride::SilentCorrect,
"ignore",
OverrideSource::Cli,
);
}
for code in cli_reject {
apply_override(
&mut config,
code,
ErrorOverride::Reject,
"reject",
OverrideSource::Cli,
);
}
config
}
}
#[derive(Debug, Clone)]
pub enum ConfigError {
Io(String),
Parse(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io(msg) => write!(f, "Config IO error: {}", msg),
ConfigError::Parse(msg) => write!(f, "Config parse error: {}", msg),
}
}
}
impl std::error::Error for ConfigError {}
fn parse_string_array(value: &str) -> Vec<String> {
let value = value.trim();
if !value.starts_with('[') || !value.ends_with(']') {
return Vec::new();
}
let inner = &value[1..value.len() - 1];
inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn dirs_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OverrideSource {
ConfigFile,
Cli,
}
pub fn warn_unmapped_override(code: &str, list: &str, source: OverrideSource) {
let normalised = code.to_uppercase();
let detail = if normalised == "W4002" {
" (retired in issue #269; `c.0…` is now rejected at parse time \
under `E1003 InvalidPosition` — drop this entry)"
} else {
" (unknown code; the override has no effect)"
};
let prefix = match source {
OverrideSource::ConfigFile => format!("`.ferro.toml` `[error-handling].{list}` entry"),
OverrideSource::Cli => format!("`--{list}` flag value"),
};
eprintln!("warning: {prefix} `{code}` does not match a known ErrorType{detail}");
}
pub fn warn_inert_override(code: &str, list: &str, source: OverrideSource) {
let Some(error_type) = ErrorType::from_code(code) else {
return;
};
let detail = if error_type.is_correctable() {
"no site emits this code, so the override has no effect"
} else {
"this code is a parse-time rejection with no auto-correction; every mode \
rejects it and the override has no effect"
};
let prefix = match source {
OverrideSource::ConfigFile => format!("`.ferro.toml` `[error-handling].{list}` entry"),
OverrideSource::Cli => format!("`--{list}` flag value"),
};
eprintln!("warning: {prefix} `{code}` ({error_type:?}): {detail}");
}
pub fn apply_override(
config: &mut ErrorConfig,
code: &str,
override_: ErrorOverride,
list: &str,
source: OverrideSource,
) {
match ErrorType::from_code(code) {
Some(error_type) => {
if !error_type.consults_error_config() {
warn_inert_override(code, list, source);
}
config.set_override(error_type, override_);
}
None => warn_unmapped_override(code, list, source),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_empty_config() {
let config = FerroConfig::parse("").unwrap();
assert!(config.error_handling.mode.is_none());
assert!(config.error_handling.ignore.is_empty());
}
#[test]
fn test_parse_mode() {
let content = r#"
[error-handling]
mode = "lenient"
"#;
let config = FerroConfig::parse(content).unwrap();
assert_eq!(config.error_handling.mode.as_deref(), Some("lenient"));
}
#[test]
fn test_parse_ignore_list() {
let content = r#"
[error-handling]
ignore = ["W1001", "W2001"]
"#;
let config = FerroConfig::parse(content).unwrap();
assert_eq!(config.error_handling.ignore, vec!["W1001", "W2001"]);
}
#[test]
fn test_parse_reject_list() {
let content = r#"
[error-handling]
reject = ["W3003"]
"#;
let config = FerroConfig::parse(content).unwrap();
assert_eq!(config.error_handling.reject, vec!["W3003"]);
}
#[test]
fn test_to_error_config() {
let content = r#"
[error-handling]
mode = "lenient"
ignore = ["W1001"]
reject = ["W3003"]
"#;
let config = FerroConfig::parse(content).unwrap();
let error_config = config.to_error_config();
assert_eq!(error_config.mode, ErrorMode::Lenient);
assert!(error_config.should_correct(ErrorType::LowercaseAminoAcid));
assert!(!error_config.should_warn(ErrorType::LowercaseAminoAcid));
assert!(error_config.should_reject(ErrorType::OldSubstitutionSyntax));
}
#[test]
fn test_to_error_config_resolves_codes_previously_missing() {
let content = r#"
[error-handling]
mode = "strict"
ignore = ["W3007", "W3020"]
reject = ["W3015", "W4003"]
"#;
let config = FerroConfig::parse(content).unwrap();
let error_config = config.to_error_config();
assert!(error_config.should_correct(ErrorType::DeprecatedStopCodonStar));
assert!(!error_config.should_warn(ErrorType::DeprecatedStopCodonStar));
assert!(error_config.should_correct(ErrorType::RnaThymineCanonicalized));
assert!(!error_config.should_warn(ErrorType::RnaThymineCanonicalized));
assert!(error_config.should_reject(ErrorType::DeprecatedConSyntax));
assert!(error_config.should_reject(ErrorType::SinglePositionRange));
}
#[test]
fn test_merge_with_cli() {
let content = r#"
[error-handling]
mode = "lenient"
ignore = ["W1001"]
"#;
let config = FerroConfig::parse(content).unwrap();
let merged = config.merge_with_cli(Some("strict"), &[], &[]);
assert_eq!(merged.mode, ErrorMode::Strict);
let merged = config.merge_with_cli(None, &[], &["W2001".to_string()]);
assert!(merged.should_reject(ErrorType::WrongDashCharacter));
}
#[test]
fn test_parse_string_array() {
assert_eq!(
parse_string_array(r#"["W1001", "W2001"]"#),
vec!["W1001", "W2001"]
);
assert_eq!(parse_string_array(r#"["W1001"]"#), vec!["W1001"]);
assert!(parse_string_array("").is_empty());
assert!(parse_string_array("not an array").is_empty());
}
#[test]
fn test_comments_ignored() {
let content = r#"
# This is a comment
[error-handling]
# Another comment
mode = "silent" # inline comment not supported but shouldn't break
"#;
let config = FerroConfig::parse(content).unwrap();
assert!(config.error_handling.mode.is_some());
}
}