1use serde::{Deserialize, Serialize};
2
3use crate::alignment::AlignmentConfig;
4use crate::lfq::LfqConfig;
5
6mod align;
7mod features;
8mod file;
9mod hills;
10mod scoring;
11
12pub use align::AlignOutputConfig;
16pub use features::{CosineAnchor, FeaturesConfig};
17pub use file::{FileConfig, ImToleranceType, ToleranceType};
18pub use hills::{HillsConfig, HillsMs2Overrides};
19pub use scoring::{OutputConfig, ScoringConfig};
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
24#[serde(rename_all = "snake_case")]
25pub enum OutputFormat {
26 #[default]
27 Tsv,
28 Parquet,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32#[serde(default, deny_unknown_fields)]
33pub struct KothConfig {
34 pub file: FileConfig,
35 pub hills: HillsConfig,
36 #[serde(default)]
41 pub hills_ms2: Option<HillsMs2Overrides>,
42 pub features: FeaturesConfig,
43 pub scoring: ScoringConfig,
44 pub output: OutputConfig,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49#[serde(default, deny_unknown_fields)]
50pub struct AlignConfig {
51 pub alignment: AlignmentConfig,
52 pub lfq: LfqConfig,
53 pub output: AlignOutputConfig,
54}
55
56impl AlignConfig {
57 pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
58 let content = std::fs::read_to_string(path)?;
59 let cfg: Self = toml::from_str(&content)
60 .map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
61 cfg.lfq.consensus.validate()?;
62 Ok(cfg)
63 }
64
65 pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
66 toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(e.to_string()))
67 }
68}
69
70impl KothConfig {
71 pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::KothError> {
72 let content = std::fs::read_to_string(path)?;
73 let cfg: KothConfig = toml::from_str(&content)
74 .map_err(|e| crate::error::KothError::ConfigError(e.to_string()))?;
75 cfg.features.validate()?;
76 Ok(cfg)
77 }
78
79 pub fn to_toml_string(&self) -> Result<String, crate::error::KothError> {
80 toml::to_string(self).map_err(|e| crate::error::KothError::ConfigError(e.to_string()))
81 }
82
83 pub fn ms2_hills(&self) -> HillsConfig {
94 match &self.hills_ms2 {
95 Some(ov) => ov.apply_to(&self.hills),
96 None => self.hills.clone(),
97 }
98 }
99}
100
101#[cfg(test)]
102mod config_parse_tests {
103 use super::{AlignConfig, KothConfig};
104
105 fn parse_koth(name: &str, toml_src: &str) {
110 if let Err(e) = toml::from_str::<KothConfig>(toml_src) {
111 panic!("shipped koth_ff config `{name}` failed to parse: {e}");
112 }
113 }
114
115 fn parse_align(name: &str, toml_src: &str) {
116 if let Err(e) = toml::from_str::<AlignConfig>(toml_src) {
117 panic!("shipped koth_align config `{name}` failed to parse: {e}");
118 }
119 }
120
121 #[test]
132 fn shipped_koth_configs_parse() {
133 parse_koth(
134 "example_config.toml",
135 include_str!("../../../example_config.toml"),
136 );
137 }
138
139 #[test]
140 fn shipped_align_configs_parse() {
141 parse_align(
142 "example_config_align.toml",
143 include_str!("../../../example_config_align.toml"),
144 );
145 }
146
147 #[test]
148 fn removed_consensus_gates_are_rejected_instead_of_ignored() {
149 for (key, value) in [
150 ("min_member_combined_score", "0.5"),
151 ("min_seed_combined_score", "0.75"),
152 ("min_group_size", "2"),
153 ("allow_replicated_weak_seeds", "true"),
154 ] {
155 let source = format!("[lfq.consensus]\n{key} = {value}\n");
156 let err = toml::from_str::<AlignConfig>(&source).unwrap_err();
157 assert!(err.to_string().contains(key));
158 }
159 }
160
161 #[test]
162 fn consensus_gate_rejects_invalid_values() {
163 let mut cfg = AlignConfig::default();
164 for value in [f64::NAN, f64::INFINITY, -0.01, 1.01] {
165 cfg.lfq.consensus.max_group_qvalue = value;
166 assert!(cfg.lfq.consensus.validate().is_err());
167 }
168 cfg.lfq.consensus.max_group_qvalue = 0.05;
169 cfg.lfq.consensus.rt_window_pct = -0.1;
170 assert!(cfg.lfq.consensus.validate().is_err());
171 }
172
173 #[test]
174 fn omitted_lfq_windows_use_release_defaults_without_changing_grouping() {
175 for text in [
176 "",
177 "[lfq]\n",
178 include_str!("../../../example_config_align.toml"),
179 ] {
180 let cfg: AlignConfig = toml::from_str(text).expect("valid alignment config");
181 assert_eq!(cfg.lfq.rt_window_pct, 0.005);
182 assert_eq!(cfg.lfq.im_tolerance, 0.015);
183 assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
184 assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
185 assert_eq!(cfg.alignment.im_tolerance, 0.05);
186 }
187 }
188
189 #[test]
190 fn explicit_lfq_windows_override_defaults_without_changing_grouping() {
191 let cfg: AlignConfig =
192 toml::from_str("[lfq]\nrt_window_pct = 0.01\nim_tolerance = 0.025\n")
193 .expect("valid custom extraction windows");
194 assert_eq!(cfg.lfq.rt_window_pct, 0.01);
195 assert_eq!(cfg.lfq.im_tolerance, 0.025);
196 assert_eq!(cfg.lfq.consensus.rt_window_pct, 0.02);
197 assert_eq!(cfg.lfq.consensus.im_tolerance, 0.05);
198 }
199}
200
201#[cfg(test)]
202mod hills_ms2_tests {
203 use super::KothConfig;
204
205 fn base_config_toml() -> String {
212 let mut cfg = KothConfig::default();
213 cfg.hills.min_scans = 5;
214 cfg.hills.max_gap = 2;
215 cfg.hills.split_hills = false;
216 cfg.hills.lfc_weight = 0.9;
217 cfg.hills.smoothing_enabled = true;
218 cfg.hills.smoothing_window = 3;
219 cfg.file.mz_tolerance = 8.0;
220 cfg.to_toml_string().expect("serialize base config")
221 }
222
223 #[test]
226 fn absent_hills_ms2_equals_hills() {
227 let cfg: KothConfig = toml::from_str(&base_config_toml()).expect("parse");
228 assert!(cfg.hills_ms2.is_none());
229 let ms2 = cfg.ms2_hills();
230 let a = toml::to_string(&cfg.hills).unwrap();
233 let b = toml::to_string(&ms2).unwrap();
234 assert_eq!(
235 a, b,
236 "ms2_hills() must equal [hills] when [hills_ms2] absent"
237 );
238 }
239
240 #[test]
243 fn partial_hills_ms2_overrides_named_inherits_rest() {
244 let toml_src = format!(
245 "{}\n[hills_ms2]\nmin_scans = 2\nsplit_hills = true\n",
246 base_config_toml()
247 );
248 let cfg: KothConfig = toml::from_str(&toml_src).expect("parse");
249 assert!(cfg.hills_ms2.is_some());
250 let ms2 = cfg.ms2_hills();
251
252 assert_eq!(ms2.min_scans, 2);
254 assert!(ms2.split_hills);
255
256 assert_eq!(ms2.max_gap, 2, "inherited from [hills], not default");
260 assert_eq!(ms2.lfc_weight, 0.9, "inherited from [hills], not default");
261 assert!(ms2.smoothing_enabled, "inherited from [hills]");
262 assert_eq!(ms2.smoothing_window, 3, "inherited from [hills]");
263
264 assert_eq!(cfg.hills.min_scans, 5);
266 assert!(!cfg.hills.split_hills);
267 }
268
269 #[test]
271 fn hills_ms2_rejects_unknown_field() {
272 let toml_src = format!("{}\n[hills_ms2]\nmin_scanz = 2\n", base_config_toml());
273 let err = toml::from_str::<KothConfig>(&toml_src);
274 assert!(err.is_err(), "typo inside [hills_ms2] must be rejected");
275 }
276}