use serde::{Deserialize, Serialize};
mod features;
mod file;
mod hills;
mod scoring;
pub use features::{CosineAnchor, FeaturesConfig};
pub use file::{
FileConfig, ImToleranceType, MobilityScale, ToleranceType, SCALE_CALIBRATED, SCALE_LINEAR,
};
pub use hills::{HillsConfig, HillsMs2Overrides};
pub use scoring::{OutputConfig, ScoringConfig};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OutputFormat {
#[default]
Tsv,
Parquet,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct KothConfig {
pub file: FileConfig,
pub hills: HillsConfig,
#[serde(default)]
pub hills_ms2: Option<HillsMs2Overrides>,
pub features: FeaturesConfig,
pub scoring: ScoringConfig,
pub output: OutputConfig,
}
impl KothConfig {
pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::Error> {
let content = std::fs::read_to_string(path)?;
let cfg: KothConfig =
toml::from_str(&content).map_err(|e| crate::error::Error::Config(e.to_string()))?;
cfg.features.validate()?;
Ok(cfg)
}
pub fn to_toml_string(&self) -> Result<String, crate::error::Error> {
toml::to_string(self).map_err(|e| crate::error::Error::Config(e.to_string()))
}
pub fn ms2_hills(&self) -> HillsConfig {
match &self.hills_ms2 {
Some(ov) => ov.apply_to(&self.hills),
None => self.hills.clone(),
}
}
}
#[cfg(test)]
mod config_parse_tests {
use super::KothConfig;
fn parse_koth(name: &str, toml_src: &str) {
if let Err(e) = toml::from_str::<KothConfig>(toml_src) {
panic!("shipped koth_ff config `{name}` failed to parse: {e}");
}
}
#[test]
fn shipped_koth_configs_parse() {
parse_koth(
"example_config.toml",
include_str!("../../../example_config.toml"),
);
}
}
#[cfg(test)]
mod hills_ms2_tests {
use super::KothConfig;
fn base_config_toml() -> String {
let mut cfg = KothConfig::default();
cfg.hills.min_scans = 5;
cfg.hills.max_gap = 2;
cfg.hills.split_hills = false;
cfg.hills.lfc_weight = 0.9;
cfg.hills.smoothing_enabled = true;
cfg.hills.smoothing_window = 3;
cfg.file.mz_tolerance = 8.0;
cfg.to_toml_string().expect("serialize base config")
}
#[test]
fn absent_hills_ms2_equals_hills() {
let cfg: KothConfig = toml::from_str(&base_config_toml()).expect("parse");
assert!(cfg.hills_ms2.is_none());
let ms2 = cfg.ms2_hills();
let a = toml::to_string(&cfg.hills).unwrap();
let b = toml::to_string(&ms2).unwrap();
assert_eq!(
a, b,
"ms2_hills() must equal [hills] when [hills_ms2] absent"
);
}
#[test]
fn partial_hills_ms2_overrides_named_inherits_rest() {
let toml_src = format!(
"{}\n[hills_ms2]\nmin_scans = 2\nsplit_hills = true\n",
base_config_toml()
);
let cfg: KothConfig = toml::from_str(&toml_src).expect("parse");
assert!(cfg.hills_ms2.is_some());
let ms2 = cfg.ms2_hills();
assert_eq!(ms2.min_scans, 2);
assert!(ms2.split_hills);
assert_eq!(ms2.max_gap, 2, "inherited from [hills], not default");
assert_eq!(ms2.lfc_weight, 0.9, "inherited from [hills], not default");
assert!(ms2.smoothing_enabled, "inherited from [hills]");
assert_eq!(ms2.smoothing_window, 3, "inherited from [hills]");
assert_eq!(cfg.hills.min_scans, 5);
assert!(!cfg.hills.split_hills);
}
#[test]
fn hills_ms2_rejects_unknown_field() {
let toml_src = format!("{}\n[hills_ms2]\nmin_scanz = 2\n", base_config_toml());
let err = toml::from_str::<KothConfig>(&toml_src);
assert!(err.is_err(), "typo inside [hills_ms2] must be rejected");
}
}