use std::fmt;
use std::fs;
use std::path::Path;
pub mod model;
pub mod scenario;
pub mod solver;
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
UnsupportedFormat(String),
Parse(String),
Validation(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "config I/O error: {e}"),
ConfigError::UnsupportedFormat(ext) => {
write!(
f,
"unsupported config format '{ext}' (use .yml, .yaml or .json)"
)
}
ConfigError::Parse(msg) => write!(f, "config parse error: {msg}"),
ConfigError::Validation(msg) => write!(f, "config validation error: {msg}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Format {
Yaml,
Json,
}
pub(crate) fn format_from_path(path: &str) -> Result<Format, ConfigError> {
match Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("yml") | Some("yaml") => Ok(Format::Yaml),
Some("json") => Ok(Format::Json),
Some(other) => Err(ConfigError::UnsupportedFormat(other.to_string())),
None => Err(ConfigError::UnsupportedFormat(String::new())),
}
}
pub(crate) fn load_from_file<T>(path: &str) -> Result<T, ConfigError>
where
T: serde::de::DeserializeOwned,
{
let format = format_from_path(path)?;
let content = fs::read_to_string(path)?;
match format {
Format::Yaml => {
serde_yaml::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))
}
Format::Json => {
serde_json::from_str(&content).map_err(|e| ConfigError::Parse(e.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_yml() {
assert_eq!(format_from_path("model.yml").unwrap(), Format::Yaml);
}
#[test]
fn test_format_yaml() {
assert_eq!(
format_from_path("config/solver.yaml").unwrap(),
Format::Yaml
);
}
#[test]
fn test_format_json() {
assert_eq!(
format_from_path("results/model.json").unwrap(),
Format::Json
);
}
#[test]
fn test_format_case_insensitive() {
assert_eq!(format_from_path("model.YML").unwrap(), Format::Yaml);
assert_eq!(format_from_path("model.JSON").unwrap(), Format::Json);
}
#[test]
fn test_format_unsupported_extension() {
let err = format_from_path("model.toml").unwrap_err();
assert!(matches!(err, ConfigError::UnsupportedFormat(ref s) if s == "toml"));
}
#[test]
fn test_format_no_extension() {
let err = format_from_path("model").unwrap_err();
assert!(matches!(err, ConfigError::UnsupportedFormat(ref s) if s.is_empty()));
}
#[test]
fn test_display_unsupported_format() {
let e = ConfigError::UnsupportedFormat("toml".to_string());
assert!(e.to_string().contains("toml"));
}
#[test]
fn test_display_parse() {
let e = ConfigError::Parse("unexpected field".to_string());
assert!(e.to_string().contains("unexpected field"));
}
#[test]
fn test_display_validation() {
let e = ConfigError::Validation("species 'X' not found".to_string());
assert!(e.to_string().contains("species 'X'"));
}
#[test]
fn test_display_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let e = ConfigError::from(io_err);
assert!(e.to_string().contains("I/O"));
}
#[test]
fn test_load_from_file_yaml() {
use std::io::Write;
let mut f = tempfile::Builder::new().suffix(".yml").tempfile().unwrap();
writeln!(
f,
"solver_type: !TimeEvolution\n total_time: 10.0\n time_steps: 100"
)
.unwrap();
use crate::solver::SolverConfiguration;
let config: SolverConfiguration = load_from_file(f.path().to_str().unwrap()).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_load_from_file_json() {
use std::io::Write;
let mut f = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
writeln!(
f,
r#"{{"solver_type":{{"TimeEvolution":{{"total_time":10.0,"time_steps":100}}}}}}"#
)
.unwrap();
use crate::solver::SolverConfiguration;
let config: SolverConfiguration = load_from_file(f.path().to_str().unwrap()).unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn test_load_from_file_missing_file() {
use crate::solver::SolverConfiguration;
let result: Result<SolverConfiguration, _> =
load_from_file("/tmp/does_not_exist_chrom_rs_config.yml");
assert!(matches!(result, Err(ConfigError::Io(_))));
}
#[test]
fn test_load_from_file_invalid_yaml() {
use std::io::Write;
let mut f = tempfile::Builder::new().suffix(".yml").tempfile().unwrap();
writeln!(f, "not: valid: yaml: at: all: [").unwrap();
use crate::solver::SolverConfiguration;
let result: Result<SolverConfiguration, _> = load_from_file(f.path().to_str().unwrap());
assert!(matches!(result, Err(ConfigError::Parse(_))));
}
}