Skip to main content

ralph_workflow/config/validation/
mod.rs

1//! Configuration validation and error reporting.
2//!
3//! This module provides validation for configuration files with:
4//! - TOML syntax validation
5//! - Type checking (expected vs actual types)
6//! - Unknown key detection with typo suggestions (Levenshtein distance)
7//! - Multi-file error aggregation
8//! - User-friendly error messages
9//!
10//! ## Architecture
11//!
12//! The validation process follows these steps:
13//! 1. Parse TOML syntax → `ConfigValidationError::TomlSyntax` on failure
14//! 2. Detect unknown/deprecated keys → `ConfigValidationError::UnknownKey` + warnings
15//! 3. Validate types against schema → `ConfigValidationError::InvalidValue` on mismatch
16//!
17//! ## Modules
18//!
19//! - `levenshtein`: String distance calculation for typo suggestions
20//! - `keys`: Valid configuration key definitions
21//! - `key_detection`: TOML structure traversal for unknown key detection
22//! - `error_formatting`: User-friendly error message generation
23
24use std::fmt::Write;
25use std::path::{Path, PathBuf};
26use thiserror::Error;
27
28mod error_formatting;
29mod key_detection;
30mod keys;
31mod levenshtein;
32
33// Re-export public API
34pub use levenshtein::suggest_key;
35
36/// Configuration validation error.
37#[derive(Debug, Error)]
38pub enum ConfigValidationError {
39    #[error("TOML syntax error in {file}: {error}")]
40    TomlSyntax {
41        file: PathBuf,
42        error: toml::de::Error,
43    },
44
45    #[error("Invalid value in {file} at '{key}': {message}")]
46    InvalidValue {
47        file: PathBuf,
48        key: String,
49        message: String,
50    },
51
52    #[error("Unknown key in {file}: '{key}'")]
53    UnknownKey {
54        file: PathBuf,
55        key: String,
56        suggestion: Option<String>,
57    },
58}
59
60/// Result of config validation.
61/// On success: Ok(warnings) where warnings is a Vec<String> of deprecation warnings
62/// On failure: Err(errors) where errors is a Vec<ConfigValidationError>
63pub type ValidationResult = Result<Vec<String>, Vec<ConfigValidationError>>;
64
65/// Validate a config file and collect errors and warnings.
66///
67/// This validates:
68/// - TOML syntax
69/// - Type checking against `UnifiedConfig` schema
70/// - Unknown keys with typo suggestions
71/// - Deprecated keys (returns as warnings, not errors)
72///
73/// Returns Ok((warnings)) on success with optional deprecation warnings,
74/// or Err(errors) on validation failure.
75///
76/// # Errors
77///
78/// Returns error if the operation fails.
79pub fn validate_config_file(
80    path: &Path,
81    content: &str,
82) -> Result<Vec<String>, Vec<ConfigValidationError>> {
83    let mut errors = Vec::new();
84    let mut warnings = Vec::new();
85
86    // Step 1: Validate TOML syntax and parse to generic Value for unknown key detection
87    let parsed_value: toml::Value = match toml::from_str(content) {
88        Ok(value) => value,
89        Err(e) => {
90            errors.push(ConfigValidationError::TomlSyntax {
91                file: path.to_path_buf(),
92                error: e,
93            });
94            return Err(errors);
95        }
96    };
97
98    // Step 2: Detect unknown and deprecated keys by walking the TOML structure
99    // This is necessary because #[serde(default)] causes serde to silently ignore unknown fields
100    let (unknown_keys, deprecated_keys) =
101        key_detection::detect_unknown_and_deprecated_keys(&parsed_value);
102
103    // Unknown keys are errors
104    for (key, location) in unknown_keys {
105        let valid_keys = keys::get_valid_config_keys();
106        let suggestion = levenshtein::suggest_key(&key, &valid_keys);
107        errors.push(ConfigValidationError::UnknownKey {
108            file: path.to_path_buf(),
109            key: format!("{location}{key}"),
110            suggestion,
111        });
112    }
113
114    // Deprecated keys are warnings
115    for (key, location) in deprecated_keys {
116        let full_key = format!("{location}{key}");
117        warnings.push(format!(
118            "Deprecated key '{}' in {} - this key is no longer used and can be safely removed",
119            full_key,
120            path.display()
121        ));
122    }
123
124    // Step 3: Validate against UnifiedConfig schema for type checking
125    // Unknown keys won't cause deserialization to fail due to #[serde(default)],
126    // but we've already detected them in Step 2
127    match toml::from_str::<crate::config::unified::UnifiedConfig>(content) {
128        Ok(_) => {
129            // Successfully deserialized - types are valid
130        }
131        Err(e) => {
132            // TOML is syntactically valid but doesn't match our schema
133            // This could be a type error or missing required field
134            let error_str = e.to_string();
135
136            // Parse the error to extract useful information
137            if error_str.contains("missing field") || error_str.contains("invalid type") {
138                // For type mismatches, add a structured error
139                errors.push(ConfigValidationError::InvalidValue {
140                    file: path.to_path_buf(),
141                    key: error_formatting::extract_key_from_toml_error(&error_str),
142                    message: error_formatting::format_invalid_type_message(&error_str),
143                });
144            } else {
145                // Other deserialization errors
146                errors.push(ConfigValidationError::InvalidValue {
147                    file: path.to_path_buf(),
148                    key: "config".to_string(),
149                    message: error_str,
150                });
151            }
152        }
153    }
154
155    if errors.is_empty() {
156        Ok(warnings)
157    } else {
158        Err(errors)
159    }
160}
161
162/// Format validation errors for user display.
163#[must_use]
164pub fn format_validation_errors(errors: &[ConfigValidationError]) -> String {
165    let mut output = String::new();
166
167    for error in errors {
168        writeln!(output, "  {error}").unwrap();
169
170        if let ConfigValidationError::UnknownKey {
171            suggestion: Some(s),
172            ..
173        } = error
174        {
175            writeln!(output, "    Did you mean '{s}'?").unwrap();
176        }
177    }
178
179    output
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_validate_config_file_valid_toml() {
188        let content = r"
189[general]
190verbosity = 2
191developer_iters = 5
192";
193        let result = validate_config_file(Path::new("test.toml"), content);
194        assert!(result.is_ok());
195    }
196
197    #[test]
198    fn test_validate_config_file_invalid_toml() {
199        let content = r"
200[general
201verbosity = 2
202";
203        let result = validate_config_file(Path::new("test.toml"), content);
204        assert!(result.is_err());
205
206        if let Err(errors) = result {
207            assert_eq!(errors.len(), 1);
208            match &errors[0] {
209                ConfigValidationError::TomlSyntax { file, .. } => {
210                    assert_eq!(file, Path::new("test.toml"));
211                }
212                _ => panic!("Expected TomlSyntax error"),
213            }
214        }
215    }
216
217    #[test]
218    fn test_format_validation_errors_with_suggestion() {
219        let errors = vec![ConfigValidationError::UnknownKey {
220            file: PathBuf::from("test.toml"),
221            key: "develper_iters".to_string(),
222            suggestion: Some("developer_iters".to_string()),
223        }];
224
225        let formatted = format_validation_errors(&errors);
226        assert!(formatted.contains("develper_iters"));
227        assert!(formatted.contains("Did you mean 'developer_iters'?"));
228    }
229
230    #[test]
231    fn test_format_validation_errors_without_suggestion() {
232        let errors = vec![ConfigValidationError::UnknownKey {
233            file: PathBuf::from("test.toml"),
234            key: "completely_unknown".to_string(),
235            suggestion: None,
236        }];
237
238        let formatted = format_validation_errors(&errors);
239        assert!(formatted.contains("completely_unknown"));
240        assert!(!formatted.contains("Did you mean"));
241    }
242
243    #[test]
244    fn test_format_validation_errors_multiple() {
245        // Create a real TOML parse error
246        let toml_error = toml::from_str::<toml::Value>("[invalid\nkey = value").unwrap_err();
247
248        let errors = vec![
249            ConfigValidationError::TomlSyntax {
250                file: PathBuf::from("global.toml"),
251                error: toml_error,
252            },
253            ConfigValidationError::UnknownKey {
254                file: PathBuf::from("local.toml"),
255                key: "bad_key".to_string(),
256                suggestion: Some("good_key".to_string()),
257            },
258        ];
259
260        let formatted = format_validation_errors(&errors);
261        assert!(formatted.contains("global.toml"));
262        assert!(formatted.contains("local.toml"));
263        assert!(formatted.contains("Did you mean 'good_key'?"));
264    }
265
266    #[test]
267    fn test_validate_config_file_unknown_key() {
268        let content = r"
269[general]
270develper_iters = 5
271verbosity = 2
272";
273        let result = validate_config_file(Path::new("test.toml"), content);
274        // Unknown keys are now detected via custom validation
275        assert!(result.is_err());
276
277        if let Err(errors) = result {
278            assert_eq!(errors.len(), 1);
279            match &errors[0] {
280                ConfigValidationError::UnknownKey {
281                    key, suggestion, ..
282                } => {
283                    assert!(key.contains("develper_iters"));
284                    assert_eq!(suggestion.as_ref().unwrap(), "developer_iters");
285                }
286                _ => panic!("Expected UnknownKey error"),
287            }
288        }
289    }
290
291    #[test]
292    fn test_validate_config_file_invalid_type() {
293        // This test verifies that type errors during deserialization are caught.
294        // When a string is provided where an integer is expected, validation should fail.
295        let content = r#"
296[general]
297developer_iters = "five"
298"#;
299        let result = validate_config_file(Path::new("test.toml"), content);
300        assert!(result.is_err(), "Should fail with string instead of int");
301    }
302
303    #[test]
304    fn test_validate_config_file_valid_with_all_sections() {
305        let content = r#"
306[general]
307verbosity = 2
308developer_iters = 5
309reviewer_reviews = 2
310
311[ccs]
312output_flag = "--output=json"
313
314[agents.claude]
315cmd = "claude"
316
317[ccs_aliases]
318work = "ccs work"
319"#;
320        let result = validate_config_file(Path::new("test.toml"), content);
321        assert!(result.is_ok(), "Valid config with all sections should pass");
322    }
323
324    #[test]
325    fn test_validate_config_file_empty_file() {
326        let content = "";
327        let result = validate_config_file(Path::new("test.toml"), content);
328        assert!(result.is_ok(), "Empty file should use default values");
329    }
330
331    #[test]
332    fn test_validate_agent_chain_with_all_valid_keys() {
333        // Verify all FallbackConfig fields are accepted in agent_chain section
334        let content = r#"
335[general]
336developer_iters = 5
337
338[agent_chain]
339developer = ["claude", "codex"]
340reviewer = ["claude"]
341commit = ["claude"]
342analysis = ["claude"]
343max_retries = 5
344retry_delay_ms = 2000
345backoff_multiplier = 2.5
346max_backoff_ms = 120000
347max_cycles = 5
348
349[agent_chain.provider_fallback]
350opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]
351"#;
352        let result = validate_config_file(Path::new("test.toml"), content);
353        assert!(result.is_ok(), "All FallbackConfig fields should be valid");
354    }
355
356    #[test]
357    fn test_validate_agent_chain_commit_key() {
358        // The commit key was missing from VALID_AGENT_CHAIN_KEYS
359        let content = r#"
360[agent_chain]
361developer = ["claude"]
362commit = ["claude"]
363"#;
364        let result = validate_config_file(Path::new("test.toml"), content);
365        assert!(result.is_ok(), "commit key should be valid in agent_chain");
366    }
367
368    #[test]
369    fn test_validate_agent_chain_analysis_key() {
370        // The analysis key was missing from VALID_AGENT_CHAIN_KEYS
371        let content = r#"
372[agent_chain]
373developer = ["claude"]
374analysis = ["claude"]
375"#;
376        let result = validate_config_file(Path::new("test.toml"), content);
377        assert!(
378            result.is_ok(),
379            "analysis key should be valid in agent_chain"
380        );
381    }
382
383    #[test]
384    fn test_validate_agent_chain_retry_keys() {
385        // These retry/backoff keys were missing from VALID_AGENT_CHAIN_KEYS
386        let content = r#"
387[agent_chain]
388developer = ["claude"]
389max_retries = 3
390retry_delay_ms = 5000
391backoff_multiplier = 1.5
392max_backoff_ms = 30000
393max_cycles = 2
394"#;
395        let result = validate_config_file(Path::new("test.toml"), content);
396        assert!(
397            result.is_ok(),
398            "retry/backoff keys should be valid in agent_chain"
399        );
400    }
401
402    #[test]
403    fn test_validate_agent_chain_provider_fallback_key() {
404        // The provider_fallback nested table was missing from VALID_AGENT_CHAIN_KEYS
405        let content = r#"
406[agent_chain]
407developer = ["opencode"]
408
409[agent_chain.provider_fallback]
410opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]
411"#;
412        let result = validate_config_file(Path::new("test.toml"), content);
413        assert!(
414            result.is_ok(),
415            "provider_fallback nested table should be valid in agent_chain"
416        );
417    }
418
419    #[test]
420    fn test_validate_config_file_deprecated_key_warning() {
421        let content = r"
422[general]
423verbosity = 2
424auto_rebase = true
425max_recovery_attempts = 3
426";
427        let result = validate_config_file(Path::new("test.toml"), content);
428        assert!(result.is_ok(), "Deprecated keys should not cause errors");
429
430        if let Ok(warnings) = result {
431            assert_eq!(warnings.len(), 2, "Should have 2 deprecation warnings");
432            assert!(
433                warnings.iter().any(|w| w.contains("auto_rebase")),
434                "Should warn about auto_rebase"
435            );
436            assert!(
437                warnings.iter().any(|w| w.contains("max_recovery_attempts")),
438                "Should warn about max_recovery_attempts"
439            );
440        }
441    }
442
443    #[test]
444    fn test_validate_config_file_no_warnings_without_deprecated() {
445        let content = r"
446[general]
447verbosity = 2
448developer_iters = 5
449";
450        let result = validate_config_file(Path::new("test.toml"), content);
451        assert!(result.is_ok(), "Valid config should pass");
452
453        if let Ok(warnings) = result {
454            assert_eq!(warnings.len(), 0, "Should have no warnings");
455        }
456    }
457}