koth_core/config/mod.rs
1use serde::{Deserialize, Serialize};
2
3mod features;
4mod file;
5mod hills;
6mod scoring;
7
8// Re-export every config type at the `crate::config::*` path so that existing
9// import sites (`crate::config::FileConfig`, `crate::config::CosineAnchor`, …)
10// keep resolving unchanged after the split into submodules.
11pub use features::{CosineAnchor, FeaturesConfig};
12pub use file::{
13 FileConfig, ImToleranceType, MobilityScale, ToleranceType, SCALE_CALIBRATED, SCALE_LINEAR,
14};
15pub use hills::{HillsConfig, HillsMs2Overrides};
16pub use scoring::{OutputConfig, ScoringConfig};
17
18/// Output format for hills / features / matrix files: "tsv" or "parquet".
19/// Shared by [`OutputConfig`] (koth_ff) and `koth-ms`'s `AlignOutputConfig`
20/// (koth_align). This crate writes no files; the setting is carried for them.
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum OutputFormat {
24 #[default]
25 Tsv,
26 Parquet,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30#[serde(default, deny_unknown_fields)]
31pub struct KothConfig {
32 pub file: FileConfig,
33 pub hills: HillsConfig,
34 /// Optional per-field overrides for MS2 (DIA fragment) hill detection.
35 /// Absent (`None`) ⇒ MS2 hills use `[hills]` verbatim (byte-identical to the
36 /// pre-existing behavior). Present ⇒ MS2 hill detection inherits `[hills]`
37 /// and applies only the fields set here. See [`KothConfig::ms2_hills`].
38 #[serde(default)]
39 pub hills_ms2: Option<HillsMs2Overrides>,
40 pub features: FeaturesConfig,
41 pub scoring: ScoringConfig,
42 pub output: OutputConfig,
43}
44
45impl KothConfig {
46 pub fn from_toml(path: &std::path::Path) -> Result<Self, crate::error::Error> {
47 let content = std::fs::read_to_string(path)?;
48 let cfg: KothConfig =
49 toml::from_str(&content).map_err(|e| crate::error::Error::Config(e.to_string()))?;
50 cfg.features.validate()?;
51 Ok(cfg)
52 }
53
54 pub fn to_toml_string(&self) -> Result<String, crate::error::Error> {
55 toml::to_string(self).map_err(|e| crate::error::Error::Config(e.to_string()))
56 }
57
58 /// Resolve the [`HillsConfig`] to use for MS2 (DIA fragment) hill detection.
59 ///
60 /// When `[hills_ms2]` is absent this returns `self.hills` unchanged — the MS2
61 /// path is byte-for-byte identical to using `[hills]` directly. When present,
62 /// MS2 hill detection inherits every `[hills]` value and overwrites only the
63 /// fields set under `[hills_ms2]` (see [`HillsMs2Overrides`]).
64 ///
65 /// Pipeline entry points (`run_pipeline_with_ms2`, `run_pipeline_streaming`,
66 /// …) and the binary route MS2 detection through this method, so a single
67 /// `KothConfig` drives MS1 and MS2 hill settings independently.
68 pub fn ms2_hills(&self) -> HillsConfig {
69 match &self.hills_ms2 {
70 Some(ov) => ov.apply_to(&self.hills),
71 None => self.hills.clone(),
72 }
73 }
74}
75
76#[cfg(test)]
77mod config_parse_tests {
78 use super::KothConfig;
79
80 /// Every shipped config must parse under `deny_unknown_fields`. If a field
81 /// is renamed or removed in the code, the stale key in one of these files
82 /// makes this test fail — which is the point: config drift is now caught at
83 /// build time rather than silently ignored at runtime.
84 fn parse_koth(name: &str, toml_src: &str) {
85 if let Err(e) = toml::from_str::<KothConfig>(toml_src) {
86 panic!("shipped koth_ff config `{name}` failed to parse: {e}");
87 }
88 }
89
90 // The tuned per-platform configs live in pgarrett-scripps/koth-paper and are
91 // not build-checked from here; `#[serde(deny_unknown_fields)]` guards them at
92 // run time. `koth-ms` parses the shipped `koth_align` template.
93
94 #[test]
95 fn shipped_koth_configs_parse() {
96 parse_koth(
97 "example_config.toml",
98 include_str!("../../../example_config.toml"),
99 );
100 }
101}
102
103#[cfg(test)]
104mod hills_ms2_tests {
105 use super::KothConfig;
106
107 /// A valid `KothConfig` whose `[hills]` carries a few *non-default* values,
108 /// so tests can tell "inherited from `[hills]`" apart from "fell back to
109 /// `HillsConfig::default()`" (defaults are min_scans=3, max_gap=0,
110 /// split_hills=true, lfc_weight=0.5, smoothing_enabled=false,
111 /// smoothing_window=1). Built from `default()` + a valid `[file]` so we
112 /// don't have to hardcode every required `[file]` field.
113 fn base_config_toml() -> String {
114 let mut cfg = KothConfig::default();
115 cfg.hills.min_scans = 5;
116 cfg.hills.max_gap = 2;
117 cfg.hills.split_hills = false;
118 cfg.hills.lfc_weight = 0.9;
119 cfg.hills.smoothing_enabled = true;
120 cfg.hills.smoothing_window = 3;
121 cfg.file.mz_tolerance = 8.0;
122 cfg.to_toml_string().expect("serialize base config")
123 }
124
125 /// (a) Absent `[hills_ms2]` ⇒ `ms2_hills()` is byte-identical to `hills`.
126 /// This is the regression guard for the default path.
127 #[test]
128 fn absent_hills_ms2_equals_hills() {
129 let cfg: KothConfig = toml::from_str(&base_config_toml()).expect("parse");
130 assert!(cfg.hills_ms2.is_none());
131 let ms2 = cfg.ms2_hills();
132 // Compare via TOML serialization for a full field-by-field equality that
133 // does not depend on HillsConfig deriving PartialEq.
134 let a = toml::to_string(&cfg.hills).unwrap();
135 let b = toml::to_string(&ms2).unwrap();
136 assert_eq!(
137 a, b,
138 "ms2_hills() must equal [hills] when [hills_ms2] absent"
139 );
140 }
141
142 /// (b) A partial `[hills_ms2]` overrides only the named fields and inherits
143 /// every other field from `[hills]` (NOT from HillsConfig::default()).
144 #[test]
145 fn partial_hills_ms2_overrides_named_inherits_rest() {
146 let toml_src = format!(
147 "{}\n[hills_ms2]\nmin_scans = 2\nsplit_hills = true\n",
148 base_config_toml()
149 );
150 let cfg: KothConfig = toml::from_str(&toml_src).expect("parse");
151 assert!(cfg.hills_ms2.is_some());
152 let ms2 = cfg.ms2_hills();
153
154 // Overridden fields take the [hills_ms2] value.
155 assert_eq!(ms2.min_scans, 2);
156 assert!(ms2.split_hills);
157
158 // Un-overridden fields inherit from [hills] (max_gap=2, lfc_weight=0.9,
159 // smoothing on / window=3), which differ from the HillsConfig defaults
160 // (0 / 0.5 / false / 1) — proving inheritance, not default fallback.
161 assert_eq!(ms2.max_gap, 2, "inherited from [hills], not default");
162 assert_eq!(ms2.lfc_weight, 0.9, "inherited from [hills], not default");
163 assert!(ms2.smoothing_enabled, "inherited from [hills]");
164 assert_eq!(ms2.smoothing_window, 3, "inherited from [hills]");
165
166 // [hills] itself is untouched by the MS2 override.
167 assert_eq!(cfg.hills.min_scans, 5);
168 assert!(!cfg.hills.split_hills);
169 }
170
171 /// (d) `deny_unknown_fields` still rejects a typo inside `[hills_ms2]`.
172 #[test]
173 fn hills_ms2_rejects_unknown_field() {
174 let toml_src = format!("{}\n[hills_ms2]\nmin_scanz = 2\n", base_config_toml());
175 let err = toml::from_str::<KothConfig>(&toml_src);
176 assert!(err.is_err(), "typo inside [hills_ms2] must be rejected");
177 }
178}