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