gestura_core_config/
validation.rs

1//! Configuration validation and health checking
2//!
3//! Provides schema validation, migration support, and helpful error messages.
4
5use crate::config_env::validate_api_key;
6use crate::types::AppConfig;
7use std::path::PathBuf;
8
9/// Configuration validation result
10#[derive(Debug, Clone)]
11pub struct ConfigValidationResult {
12    /// Whether the configuration is valid
13    pub is_valid: bool,
14    /// List of errors found
15    pub errors: Vec<ConfigError>,
16    /// List of warnings (non-fatal issues)
17    pub warnings: Vec<ConfigWarning>,
18}
19
20/// Configuration error
21#[derive(Debug, Clone)]
22pub struct ConfigError {
23    /// Field path (e.g., "llm.openai.api_key")
24    pub field: String,
25    /// Error message
26    pub message: String,
27    /// Suggested fix
28    pub suggestion: Option<String>,
29}
30
31/// Configuration warning
32#[derive(Debug, Clone)]
33pub struct ConfigWarning {
34    /// Field path
35    pub field: String,
36    /// Warning message
37    pub message: String,
38}
39
40impl ConfigValidationResult {
41    /// Create a new empty result
42    pub fn new() -> Self {
43        Self {
44            is_valid: true,
45            errors: Vec::new(),
46            warnings: Vec::new(),
47        }
48    }
49
50    /// Add an error
51    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    /// Add an error with suggestion
61    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    /// Add a warning
76    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    /// Format as human-readable report
84    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
115/// Validate an AppConfig
116pub 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    // Check primary provider has API key
128    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            // Ollama doesn't require API key, just check base_url
174            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            // Local whisper doesn't need API key
197        }
198        "openai" => {
199            // OpenAI voice uses the same API key as LLM
200            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/// Configuration health check result
282#[derive(Debug, Clone)]
283pub struct ConfigHealthCheck {
284    /// Validation result
285    pub validation: ConfigValidationResult,
286    /// Config file path
287    pub config_path: PathBuf,
288    /// Whether config file exists
289    pub file_exists: bool,
290}
291
292impl ConfigHealthCheck {
293    /// Run a health check on the configuration
294    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    /// Format as human-readable report
315    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); // Warnings don't invalidate
355        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        // Default config should have warnings but no errors
363        // (missing API keys are only errors if provider is selected)
364        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}