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}