llm_kernel/config/
loader.rs1use std::path::Path;
2
3use serde::de::DeserializeOwned;
4
5use crate::error::{KernelError, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FieldError {
13 pub path: String,
15 pub expected: String,
17 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
33fn parse_serde_errors(msg: &str) -> Vec<FieldError> {
41 let mut errors = Vec::new();
42
43 for line in msg.lines() {
47 let line = line.trim();
48 if line.is_empty() {
49 continue;
50 }
51
52 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 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 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 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 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 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
139pub 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
153pub 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
171pub 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); }
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 assert!(result.is_ok());
257 }
258}