Skip to main content

llm_kernel/config/
loader.rs

1use std::path::Path;
2
3use serde::de::DeserializeOwned;
4
5use crate::error::{KernelError, Result};
6
7/// A field-level validation error with structured context.
8///
9/// Unlike a raw serde error string, this type extracts the field path,
10/// expected type, and the invalid value for programmatic handling.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FieldError {
13    /// Dot-separated path to the field (e.g., `"server.port"`).
14    pub path: String,
15    /// What the deserializer expected (e.g., `"u16"`, `"string"`).
16    pub expected: String,
17    /// The invalid value as it appeared in the TOML source.
18    pub value: String,
19}
20
21impl std::fmt::Display for FieldError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        write!(
24            f,
25            "field '{}': expected {}, got {}",
26            self.path, self.expected, self.value
27        )
28    }
29}
30
31impl std::error::Error for FieldError {}
32
33/// Parse a serde error message into structured [`FieldError`]s.
34///
35/// Serde's error messages follow predictable patterns. This function extracts
36/// the field path, expected type, and the invalid value from common formats:
37///
38/// - `"invalid type: found string \"x\", expected u16"` → `FieldError { path: ".", expected: "u16", value: "\"x\"" }`
39/// - `"missing field `port`"` → `FieldError { path: "port", expected: "unknown", value: "missing" }`
40fn parse_serde_errors(msg: &str) -> Vec<FieldError> {
41    let mut errors = Vec::new();
42
43    // TOML crate wraps serde errors with context lines (line numbers, source
44    // pointers). The actual serde error is always on the last non-empty line.
45    // Only try to match lines that look like serde patterns.
46    for line in msg.lines() {
47        let line = line.trim();
48        if line.is_empty() {
49            continue;
50        }
51
52        // Skip TOML context lines: "TOML parse error...", "|", "1 | ...", etc.
53        if line.starts_with("TOML parse error")
54            || line.starts_with('|')
55            || line.starts_with("+-")
56            || line
57                .chars()
58                .next()
59                .is_some_and(|c| c.is_ascii_digit() && line.contains('|'))
60        {
61            continue;
62        }
63
64        // Pattern: "invalid type: found string \"x\", expected u16"
65        if let Some(rest) = line.strip_prefix("invalid type: ") {
66            if let Some((found_part, expected_part)) = rest.split_once(", expected ") {
67                let value = found_part
68                    .strip_prefix("found ")
69                    .unwrap_or(found_part)
70                    .trim();
71                errors.push(FieldError {
72                    path: ".".into(),
73                    expected: expected_part.trim().to_string(),
74                    value: value.to_string(),
75                });
76            }
77            continue;
78        }
79
80        // Pattern: "invalid value: found string \"x\", expected ..."
81        if let Some(rest) = line.strip_prefix("invalid value: ") {
82            if let Some((found_part, expected_part)) = rest.split_once(", expected ") {
83                let value = found_part
84                    .strip_prefix("found ")
85                    .unwrap_or(found_part)
86                    .trim();
87                errors.push(FieldError {
88                    path: ".".into(),
89                    expected: expected_part.trim().to_string(),
90                    value: value.to_string(),
91                });
92            }
93            continue;
94        }
95
96        // Pattern: "missing field `port`"
97        if let Some(rest) = line.strip_prefix("missing field `") {
98            if let Some(field) = rest.strip_suffix('`') {
99                errors.push(FieldError {
100                    path: field.to_string(),
101                    expected: "unknown".into(),
102                    value: "missing".into(),
103                });
104            }
105            continue;
106        }
107
108        // Pattern: "unknown field `extra`, expected one of `name`, `port`"
109        if let Some(rest) = line.strip_prefix("unknown field `") {
110            if let Some((field, rest2)) = rest.split_once('`') {
111                errors.push(FieldError {
112                    path: field.to_string(),
113                    expected: rest2.trim_start_matches(", expected ").to_string(),
114                    value: "unknown field".into(),
115                });
116            }
117            continue;
118        }
119
120        // Fallback: return the raw message
121        errors.push(FieldError {
122            path: ".".into(),
123            expected: "unknown".into(),
124            value: line.to_string(),
125        });
126    }
127
128    if errors.is_empty() {
129        errors.push(FieldError {
130            path: ".".into(),
131            expected: "unknown".into(),
132            value: msg.to_string(),
133        });
134    }
135
136    errors
137}
138
139/// Validate a TOML config string and return structured field-level errors.
140///
141/// On success, returns the parsed config. On failure, returns a vec of
142/// [`FieldError`] instead of a raw string error, making it possible to
143/// display targeted error messages per field.
144pub fn validate_config<T: DeserializeOwned>(
145    toml_str: &str,
146) -> std::result::Result<T, Vec<FieldError>> {
147    match toml::from_str::<T>(toml_str) {
148        Ok(v) => Ok(v),
149        Err(e) => Err(parse_serde_errors(&e.to_string())),
150    }
151}
152
153/// Load a TOML config file and deserialize it into T.
154///
155/// If the file doesn't exist and `template` is provided, writes the template
156/// to the path first, then loads it.
157pub fn load_toml_config<T: DeserializeOwned>(path: &Path, template: Option<&str>) -> Result<T> {
158    if !path.exists()
159        && let Some(tmpl) = template
160    {
161        if let Some(parent) = path.parent() {
162            std::fs::create_dir_all(parent)?;
163        }
164        std::fs::write(path, tmpl)?;
165    }
166
167    let content = std::fs::read_to_string(path)?;
168    toml::from_str(&content).map_err(|e| KernelError::Config(format!("{}: {}", path.display(), e)))
169}
170
171/// Load a TOML config from a string (useful for testing).
172pub fn parse_toml_config<T: DeserializeOwned>(content: &str) -> Result<T> {
173    toml::from_str(content).map_err(|e| KernelError::Config(e.to_string()))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde::Deserialize;
180    use tempfile::TempDir;
181
182    #[derive(Debug, Deserialize, PartialEq)]
183    struct TestConfig {
184        name: String,
185        #[serde(default = "default_port")]
186        port: u16,
187    }
188
189    fn default_port() -> u16 {
190        8080
191    }
192
193    #[test]
194    fn test_load_existing_config() {
195        let dir = TempDir::new().unwrap();
196        let path = dir.path().join("test.toml");
197        std::fs::write(&path, "name = \"test\"\nport = 3000\n").unwrap();
198
199        let config: TestConfig = load_toml_config(&path, None).unwrap();
200        assert_eq!(config.name, "test");
201        assert_eq!(config.port, 3000);
202    }
203
204    #[test]
205    fn test_load_creates_from_template() {
206        let dir = TempDir::new().unwrap();
207        let path = dir.path().join("new.toml");
208        let template = "name = \"default\"\nport = 9090\n";
209
210        let config: TestConfig = load_toml_config(&path, Some(template)).unwrap();
211        assert_eq!(config.name, "default");
212        assert_eq!(config.port, 9090);
213        assert!(path.exists());
214    }
215
216    #[test]
217    fn test_parse_toml_config() {
218        let config: TestConfig = parse_toml_config("name = \"hello\"").unwrap();
219        assert_eq!(config.name, "hello");
220        assert_eq!(config.port, 8080); // default
221    }
222
223    #[test]
224    fn validate_config_success() {
225        let result = validate_config::<TestConfig>("name = \"test\"\nport = 3000");
226        assert!(result.is_ok());
227        let config = result.unwrap();
228        assert_eq!(config.port, 3000);
229    }
230
231    #[test]
232    fn validate_config_wrong_type() {
233        let result = validate_config::<TestConfig>("name = \"test\"\nport = \"not_a_number\"");
234        assert!(result.is_err());
235        let errors = result.unwrap_err();
236        assert_eq!(errors.len(), 1);
237        assert_eq!(errors[0].expected, "u16");
238        assert!(errors[0].value.contains("not_a_number"));
239    }
240
241    #[test]
242    fn validate_config_missing_field() {
243        let result = validate_config::<TestConfig>("port = 3000");
244        assert!(result.is_err());
245        let errors = result.unwrap_err();
246        assert_eq!(errors.len(), 1);
247        assert_eq!(errors[0].path, "name");
248        assert_eq!(errors[0].value, "missing");
249    }
250
251    #[test]
252    fn validate_config_unknown_field() {
253        let result = validate_config::<TestConfig>("name = \"test\"\nextra = true");
254        // serde by default ignores unknown fields with #[serde(deny_unknown_fields)]
255        // but without that attribute, this succeeds
256        assert!(result.is_ok());
257    }
258}