Skip to main content

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}