1use crate::config_env::validate_api_key;
6use crate::types::AppConfig;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
11pub struct ConfigValidationResult {
12 pub is_valid: bool,
14 pub errors: Vec<ConfigError>,
16 pub warnings: Vec<ConfigWarning>,
18}
19
20#[derive(Debug, Clone)]
22pub struct ConfigError {
23 pub field: String,
25 pub message: String,
27 pub suggestion: Option<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct ConfigWarning {
34 pub field: String,
36 pub message: String,
38}
39
40impl ConfigValidationResult {
41 pub fn new() -> Self {
43 Self {
44 is_valid: true,
45 errors: Vec::new(),
46 warnings: Vec::new(),
47 }
48 }
49
50 pub fn add_error(&mut self, field: impl Into<String>, message: impl Into<String>) {
52 self.is_valid = false;
53 self.errors.push(ConfigError {
54 field: field.into(),
55 message: message.into(),
56 suggestion: None,
57 });
58 }
59
60 pub fn add_error_with_suggestion(
62 &mut self,
63 field: impl Into<String>,
64 message: impl Into<String>,
65 suggestion: impl Into<String>,
66 ) {
67 self.is_valid = false;
68 self.errors.push(ConfigError {
69 field: field.into(),
70 message: message.into(),
71 suggestion: Some(suggestion.into()),
72 });
73 }
74
75 pub fn add_warning(&mut self, field: impl Into<String>, message: impl Into<String>) {
77 self.warnings.push(ConfigWarning {
78 field: field.into(),
79 message: message.into(),
80 });
81 }
82
83 pub fn format_report(&self) -> String {
85 let mut report = String::new();
86 if self.is_valid && self.warnings.is_empty() {
87 report.push_str("✅ Configuration is valid\n");
88 return report;
89 }
90 if !self.errors.is_empty() {
91 report.push_str(&format!("❌ {} error(s) found:\n", self.errors.len()));
92 for (i, err) in self.errors.iter().enumerate() {
93 report.push_str(&format!(" {}. [{}] {}\n", i + 1, err.field, err.message));
94 if let Some(ref suggestion) = err.suggestion {
95 report.push_str(&format!(" 💡 Suggestion: {}\n", suggestion));
96 }
97 }
98 }
99 if !self.warnings.is_empty() {
100 report.push_str(&format!("⚠️ {} warning(s):\n", self.warnings.len()));
101 for (i, warn) in self.warnings.iter().enumerate() {
102 report.push_str(&format!(" {}. [{}] {}\n", i + 1, warn.field, warn.message));
103 }
104 }
105 report
106 }
107}
108
109impl Default for ConfigValidationResult {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115pub fn validate_config(config: &AppConfig) -> ConfigValidationResult {
117 let mut result = ConfigValidationResult::new();
118 validate_llm_config(config, &mut result);
119 validate_voice_config(config, &mut result);
120 validate_ui_config(config, &mut result);
121 validate_pipeline_config(config, &mut result);
122 validate_hotkey_config(config, &mut result);
123 result
124}
125
126fn validate_llm_config(config: &AppConfig, result: &mut ConfigValidationResult) {
127 let primary = &config.llm.primary;
129 match primary.as_str() {
130 "openai" => {
131 if let Some(ref openai) = config.llm.openai {
132 let key = &openai.api_key;
133 if key.is_empty() {
134 result.add_error_with_suggestion(
135 "llm.openai.api_key",
136 "OpenAI API key is required when primary provider is 'openai'",
137 "Set GESTURA_OPENAI_API_KEY environment variable",
138 );
139 } else if let Some(msg) = validate_api_key("openai", key).error_message() {
140 result.add_error_with_suggestion(
141 "llm.openai.api_key",
142 msg,
143 "Set GESTURA_OPENAI_API_KEY environment variable",
144 );
145 }
146 }
147 }
148 "anthropic" => {
149 if let Some(ref anthropic) = config.llm.anthropic
150 && !anthropic.api_key.is_empty()
151 && let Some(msg) = validate_api_key("anthropic", &anthropic.api_key).error_message()
152 {
153 result.add_error_with_suggestion(
154 "llm.anthropic.api_key",
155 msg,
156 "Set GESTURA_ANTHROPIC_API_KEY environment variable",
157 );
158 }
159 }
160 "grok" => {
161 if let Some(ref grok) = config.llm.grok
162 && !grok.api_key.is_empty()
163 && let Some(msg) = validate_api_key("grok", &grok.api_key).error_message()
164 {
165 result.add_error_with_suggestion(
166 "llm.grok.api_key",
167 msg,
168 "Set GESTURA_GROK_API_KEY environment variable",
169 );
170 }
171 }
172 "ollama" => {
173 if let Some(ref ollama) = config.llm.ollama
175 && ollama.base_url.is_empty()
176 {
177 result.add_warning(
178 "llm.ollama.base_url",
179 "Ollama base URL is empty, will use default http://localhost:11434",
180 );
181 }
182 }
183 _ => {
184 result.add_warning(
185 "llm.primary",
186 format!("Unknown LLM provider '{}', may not work correctly", primary),
187 );
188 }
189 }
190}
191
192fn validate_voice_config(config: &AppConfig, result: &mut ConfigValidationResult) {
193 let provider = &config.voice.provider;
194 match provider.as_str() {
195 "local" | "whisper" => {
196 }
198 "openai" => {
199 let has_key = config
201 .llm
202 .openai
203 .as_ref()
204 .is_some_and(|o| !o.api_key.is_empty());
205 if !has_key {
206 result.add_warning(
207 "voice.provider",
208 "Voice provider 'openai' requires OpenAI API key",
209 );
210 }
211 }
212 _ => {
213 result.add_warning(
214 "voice.provider",
215 format!("Unknown voice provider '{}'", provider),
216 );
217 }
218 }
219}
220
221fn validate_ui_config(config: &AppConfig, result: &mut ConfigValidationResult) {
222 let theme = &config.ui.theme_mode;
223 if !["system", "light", "dark"].contains(&theme.as_str()) {
224 result.add_error_with_suggestion(
225 "ui.theme_mode",
226 format!("Invalid theme mode '{}'", theme),
227 "Use 'system', 'light', or 'dark'",
228 );
229 }
230
231 if config.notifications.sound_volume > 100 {
232 result.add_error(
233 "notifications.sound_volume",
234 "Sound volume must be between 0 and 100",
235 );
236 }
237
238 if config.notifications.haptic_intensity > 100 {
239 result.add_error(
240 "notifications.haptic_intensity",
241 "Haptic intensity must be between 0 and 100",
242 );
243 }
244}
245
246fn validate_pipeline_config(config: &AppConfig, result: &mut ConfigValidationResult) {
247 let reflection = &config.pipeline.reflection;
248
249 if reflection.max_injected == 0 {
250 result.add_warning(
251 "pipeline.reflection.max_injected",
252 "Reflection retrieval is disabled because max_injected is 0",
253 );
254 }
255
256 if reflection.max_retry_attempts > 1 {
257 result.add_warning(
258 "pipeline.reflection.max_retry_attempts",
259 "Only one reflection-guided corrective retry is currently applied per turn; values above 1 are clamped",
260 );
261 }
262
263 if reflection.enabled && reflection.promotion_confidence_percent == 0 {
264 result.add_warning(
265 "pipeline.reflection.promotion_confidence_percent",
266 "Reflection promotion confidence is 0, so nearly every reflection may be promoted",
267 );
268 }
269}
270
271fn validate_hotkey_config(config: &AppConfig, result: &mut ConfigValidationResult) {
272 if config.hotkey_listen.is_empty() {
273 result.add_warning("hotkey_listen", "No listen hotkey configured");
274 }
275
276 if config.hotkey_new_session.is_empty() {
277 result.add_warning("hotkey_new_session", "No new-session hotkey configured");
278 }
279}
280
281#[derive(Debug, Clone)]
283pub struct ConfigHealthCheck {
284 pub validation: ConfigValidationResult,
286 pub config_path: PathBuf,
288 pub file_exists: bool,
290}
291
292impl ConfigHealthCheck {
293 pub fn run() -> Self {
295 let config_path = AppConfig::default_path();
296 let file_exists = config_path.exists();
297
298 let validation = if file_exists {
299 let config = AppConfig::load_from_path(AppConfig::default_path()).apply_env_overrides();
300 validate_config(&config)
301 } else {
302 let mut result = ConfigValidationResult::new();
303 result.add_warning("config.yaml", "Config file does not exist, using defaults");
304 result
305 };
306
307 Self {
308 validation,
309 config_path,
310 file_exists,
311 }
312 }
313
314 pub fn format_report(&self) -> String {
316 let mut report = String::new();
317 report.push_str("=== Configuration Health Check ===\n\n");
318 report.push_str(&format!("Config path: {:?}\n", self.config_path));
319 report.push_str(&format!(
320 "File exists: {}\n",
321 if self.file_exists { "Yes" } else { "No" }
322 ));
323 report.push('\n');
324 report.push_str(&self.validation.format_report());
325 report
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_validation_result_new() {
335 let result = ConfigValidationResult::new();
336 assert!(result.is_valid);
337 assert!(result.errors.is_empty());
338 assert!(result.warnings.is_empty());
339 }
340
341 #[test]
342 fn test_validation_result_add_error() {
343 let mut result = ConfigValidationResult::new();
344 result.add_error("test.field", "Test error");
345 assert!(!result.is_valid);
346 assert_eq!(result.errors.len(), 1);
347 assert_eq!(result.errors[0].field, "test.field");
348 }
349
350 #[test]
351 fn test_validation_result_add_warning() {
352 let mut result = ConfigValidationResult::new();
353 result.add_warning("test.field", "Test warning");
354 assert!(result.is_valid); assert_eq!(result.warnings.len(), 1);
356 }
357
358 #[test]
359 fn test_validate_default_config() {
360 let config = AppConfig::default();
361 let result = validate_config(&config);
362 assert!(result.errors.is_empty() || !result.errors.is_empty());
365 }
366
367 #[test]
368 fn test_format_report_valid() {
369 let result = ConfigValidationResult::new();
370 let report = result.format_report();
371 assert!(report.contains("valid"));
372 }
373
374 #[test]
375 fn test_format_report_with_errors() {
376 let mut result = ConfigValidationResult::new();
377 result.add_error_with_suggestion("test.field", "Test error", "Fix it");
378 let report = result.format_report();
379 assert!(report.contains("error"));
380 assert!(report.contains("Suggestion"));
381 }
382}