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 {
if let Some(error_type) = code_to_error_type(code) {
config.set_override(error_type, ErrorOverride::SilentCorrect);
}
}
for code in &self.error_handling.reject {
if let Some(error_type) = code_to_error_type(code) {
config.set_override(error_type, ErrorOverride::Reject);
}
}
for code in &self.error_handling.warn {
if let Some(error_type) = code_to_error_type(code) {
config.set_override(error_type, ErrorOverride::WarnCorrect);
}
}
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 {
if let Some(error_type) = code_to_error_type(code) {
config.set_override(error_type, ErrorOverride::SilentCorrect);
}
}
for code in cli_reject {
if let Some(error_type) = code_to_error_type(code) {
config.set_override(error_type, ErrorOverride::Reject);
}
}
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)
}
fn code_to_error_type(code: &str) -> Option<ErrorType> {
match code.to_uppercase().as_str() {
"W1001" => Some(ErrorType::LowercaseAminoAcid),
"W1002" => Some(ErrorType::SingleLetterAminoAcid),
"W1003" => Some(ErrorType::LowercaseAccessionPrefix),
"W1004" => Some(ErrorType::MixedCaseEditType),
"W2001" => Some(ErrorType::WrongDashCharacter),
"W2002" => Some(ErrorType::WrongQuoteCharacter),
"W2003" => Some(ErrorType::ExtraWhitespace),
"W2004" => Some(ErrorType::InvalidUnicodeCharacter),
"W3001" => Some(ErrorType::MissingVersion),
"W3002" => Some(ErrorType::ProteinSubstitutionArrow),
"W3003" => Some(ErrorType::OldSubstitutionSyntax),
"W3004" => Some(ErrorType::OldAlleleFormat),
"W3005" => Some(ErrorType::TrailingAnnotation),
"W3006" => Some(ErrorType::MissingCoordinatePrefix),
"W4001" => Some(ErrorType::SwappedPositions),
"W4002" => Some(ErrorType::PositionZero),
"W5001" => Some(ErrorType::RefSeqMismatch),
_ => None,
}
}
#[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 = ["W4002"]
"#;
let config = FerroConfig::parse(content).unwrap();
assert_eq!(config.error_handling.reject, vec!["W4002"]);
}
#[test]
fn test_to_error_config() {
let content = r#"
[error-handling]
mode = "lenient"
ignore = ["W1001"]
reject = ["W4002"]
"#;
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::PositionZero));
}
#[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());
}
}