use serde::{Deserialize, Serialize};
use crate::alignment::AlignmentConfig;
use crate::lfq::LfqConfig;
mod align;
mod features;
mod file;
mod hills;
mod scoring;
pub use align::AlignOutputConfig;
pub use features::{CosineAnchor, FeaturesConfig};
pub use file::{FileConfig, ImToleranceType, ToleranceType};
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,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct AlignConfig {
pub alignment: AlignmentConfig,
pub lfq: LfqConfig,
pub output: AlignOutputConfig,
}
impl AlignConfig {
pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
let content = std::fs::read_to_string(path)?;
let cfg: Self = toml::from_str(&content)
.map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
cfg.lfq.consensus.validate()?;
Ok(cfg)
}
pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(e.to_string()))
}
}
impl KothConfig {
pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
let content = std::fs::read_to_string(path)?;
let cfg: KothConfig = toml::from_str(&content)
.map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
cfg.features.validate()?;
Ok(cfg)
}
pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(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::{AlignConfig, 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}");
}
}
fn parse_align(name: &str, toml_src: &str) {
if let Err(e) = toml::from_str::<AlignConfig>(toml_src) {
panic!("shipped koth_align config `{name}` failed to parse: {e}");
}
}
#[test]
fn shipped_koth_configs_parse() {
parse_koth(
"example_config.toml",
include_str!("../../../example_config.toml"),
);
}
#[test]
fn shipped_align_configs_parse() {
parse_align(
"example_config_align.toml",
include_str!("../../../example_config_align.toml"),
);
}
#[test]
fn removed_consensus_gates_are_rejected_instead_of_ignored() {
for (key, value) in [
("min_member_combined_score", "0.5"),
("min_seed_combined_score", "0.75"),
("min_group_size", "2"),
("allow_replicated_weak_seeds", "true"),
] {
let source = format!("[lfq.consensus]\n{key} = {value}\n");
let err = toml::from_str::<AlignConfig>(&source).unwrap_err();
assert!(err.to_string().contains(key));
}
}
#[test]
fn consensus_gate_rejects_invalid_values() {
let mut cfg = AlignConfig::default();
for value in [f64::NAN, f64::INFINITY, -0.01, 1.01] {
cfg.lfq.consensus.max_group_qvalue = value;
assert!(cfg.lfq.consensus.validate().is_err());
}
cfg.lfq.consensus.max_group_qvalue = 0.05;
cfg.lfq.consensus.rt_window_pct = -0.1;
assert!(cfg.lfq.consensus.validate().is_err());
}
#[test]
fn omitted_lfq_windows_use_release_defaults_without_changing_grouping() {
for text in [
"",
"[lfq]\n",
include_str!("../../../example_config_align.toml"),
] {
let cfg: AlignConfig = toml::from_str(text).expect("valid alignment config");
assert_eq!(cfg.lfq.rt_window_pct, 0.005);
assert_eq!(cfg.lfq.im_tolerance, 0.015);
assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
assert_eq!(cfg.alignment.im_tolerance, 0.05);
}
}
#[test]
fn explicit_lfq_windows_override_defaults_without_changing_grouping() {
let cfg: AlignConfig =
toml::from_str("[lfq]\nrt_window_pct = 0.01\nim_tolerance = 0.025\n")
.expect("valid custom extraction windows");
assert_eq!(cfg.lfq.rt_window_pct, 0.01);
assert_eq!(cfg.lfq.im_tolerance, 0.025);
assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
}
}
#[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");
}
}