Skip to main content

linesmith_core/runtime/
config.rs

1//! Runtime config-load wrapper. Both driver and doctor parse user
2//! config through [`Config::load_validated`] so unknown-key warnings
3//! ride alongside the parsed config (or the parse error). Doctor's
4//! read is documented in `docs/specs/doctor.md` §Config.
5//!
6//! Pre-routing, doctor used bare `toml::from_str` and silently
7//! accepted typos like `[segmnts.echo]` that the runtime warned
8//! about — the parity gap this module closes.
9
10use std::path::PathBuf;
11
12use crate::config::{Config, ConfigError, ConfigPath};
13
14/// Outcome of loading the user config from a resolved path. Five
15/// variants; runtime and doctor render each differently.
16///
17/// Outer `path` is authoritative. `ConfigError`'s inner `path` is
18/// `Option<PathBuf>` upstream because `from_str_validated` parses
19/// in-memory strings without one — the outer field guarantees a
20/// path here regardless of the source variant.
21///
22/// `ParseError` carries `warnings` because [`Config::load_validated`]
23/// calls `validate_keys` between the syntactic TOML parse and the
24/// typed `try_into`: unknown-key warnings can fire and *then* a
25/// type-mismatch error returns. Dropping them would force users to
26/// fix typos one at a time. `IoError` carries the field too for
27/// shape symmetry; it'll always be empty (the read fails before any
28/// parsing).
29#[derive(Debug)]
30#[non_exhaustive]
31pub enum ConfigLoadOutcome {
32    /// No config-path source resolved. Doctor renders this as
33    /// "Unresolved" (different from "NotFound" — the cascade itself
34    /// failed); driver treats it as "use defaults silently."
35    Unresolved,
36    /// Path resolved but file doesn't exist. `explicit` is `true`
37    /// when the path came from `--config` / `LINESMITH_CONFIG` so
38    /// the diagnostic can warn loudly; implicit XDG/HOME paths are
39    /// silent for first-run users.
40    NotFound { path: PathBuf, explicit: bool },
41    /// `fs::read_to_string` failed for a reason other than NotFound
42    /// (permission denied, invalid UTF-8, etc.). `source` carries
43    /// the underlying [`ConfigError`] for verbatim diagnostic
44    /// rendering — `Display` includes the path. `warnings` is
45    /// always empty (the read fails before validation runs).
46    IoError {
47        path: PathBuf,
48        source: ConfigError,
49        warnings: Vec<String>,
50    },
51    /// TOML parse or type-mismatch failed. `source` carries the
52    /// underlying [`ConfigError::Parse`] verbatim so the line/column
53    /// span survives to the renderer. `warnings` carries any
54    /// unknown-key diagnostics that fired before the typed
55    /// `try_into` rejected the document — see the type-level doc
56    /// for why this matters.
57    ParseError {
58        path: PathBuf,
59        source: ConfigError,
60        warnings: Vec<String>,
61    },
62    /// Loaded successfully. `warnings` contains one entry per
63    /// unknown key encountered by [`Config::load_validated`]; empty
64    /// for clean configs.
65    Loaded {
66        path: PathBuf,
67        config: Box<Config>,
68        warnings: Vec<String>,
69    },
70}
71
72/// Single config-load entry both runtime and doctor route through.
73/// Calls [`Config::load_validated`] so unknown-key warnings flow to
74/// the caller via the [`ConfigLoadOutcome::Loaded`] variant rather
75/// than being collected by side effect on a sink.
76#[must_use]
77pub fn load_config(resolved: Option<&ConfigPath>) -> ConfigLoadOutcome {
78    let Some(cp) = resolved else {
79        return ConfigLoadOutcome::Unresolved;
80    };
81    let mut warnings = Vec::new();
82    // The match arms below assume `ConfigError`'s only variants are
83    // `Io` and `Parse`. That holds because `ConfigError` lives in
84    // this same crate; a new variant would surface as a missing-arm
85    // compile error. If `config.rs` ever moves to `linesmith-core`
86    // (per ADR-0018), this match becomes a silent-drop hazard and
87    // needs an explicit fallthrough.
88    match Config::load_validated(&cp.path, |msg| warnings.push(msg.to_string())) {
89        Ok(Some(config)) => ConfigLoadOutcome::Loaded {
90            path: cp.path.clone(),
91            config: Box::new(config),
92            warnings,
93        },
94        Ok(None) => ConfigLoadOutcome::NotFound {
95            path: cp.path.clone(),
96            explicit: cp.explicit,
97        },
98        Err(source @ ConfigError::Io { .. }) => ConfigLoadOutcome::IoError {
99            path: cp.path.clone(),
100            source,
101            warnings,
102        },
103        Err(source @ ConfigError::Parse { .. }) => ConfigLoadOutcome::ParseError {
104            path: cp.path.clone(),
105            source,
106            warnings,
107        },
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::io::Write;
115
116    #[test]
117    fn parse_error_preserves_validation_warnings_collected_before_type_mismatch() {
118        // `validate_keys` runs between toml::from_str and try_into,
119        // so an unknown key + a typed parse failure must surface
120        // both — users shouldn't have to fix typos one at a time.
121        let mut tmp = tempfile::NamedTempFile::new().unwrap();
122        tmp.write_all(b"unknown_top = 1\ntheme = 123\n").unwrap();
123        let cp = ConfigPath {
124            path: tmp.path().to_owned(),
125            explicit: true,
126        };
127        match load_config(Some(&cp)) {
128            ConfigLoadOutcome::ParseError { warnings, .. } => {
129                assert_eq!(warnings.len(), 1, "expected one unknown-key warning");
130                assert!(
131                    warnings[0].contains("unknown_top"),
132                    "warning: {:?}",
133                    warnings[0]
134                );
135            }
136            other => panic!("expected ParseError, got {other:?}"),
137        }
138    }
139}