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(config) => {
129            if parsed_value.get("agent_chain").is_some() {
130                if !config.agent_drains.is_empty() && config.agent_chains.is_empty() {
131                    errors.push(ConfigValidationError::InvalidValue {
132                        file: path.to_path_buf(),
133                        key: "agent_chain".to_string(),
134                        message: "found [agent_drains] with singular [agent_chain]; did you mean [agent_chains]? Move retry/backoff settings to [general] (max_retries, retry_delay_ms, backoff_multiplier, max_backoff_ms, max_cycles)".to_string(),
135                    });
136                } else {
137                    warnings.push(format!(
138                        "Deprecated section '[agent_chain]' in {} - Ralph will keep legacy role-keyed behavior by adding the default drain bindings automatically. Migrate agent lists to [agent_chains]/[agent_drains] and move retry/backoff settings to [general]",
139                        path.display()
140                    ));
141                }
142            }
143
144            let has_named_chains = !config.agent_chains.is_empty();
145            let has_named_drains = !config.agent_drains.is_empty();
146            let has_legacy_role_bindings = config
147                .agent_chain
148                .as_ref()
149                .is_some_and(crate::agents::fallback::FallbackConfig::uses_legacy_role_schema);
150            let validate_named_schema_now = (!has_named_chains && !has_named_drains)
151                || (has_named_chains && has_named_drains)
152                || has_legacy_role_bindings;
153
154            if validate_named_schema_now {
155                if let Err(message) = config.resolve_agent_drains_checked() {
156                    let key = if message.contains("references unknown chain") {
157                        message
158                            .split_whitespace()
159                            .next()
160                            .map_or_else(|| "agent_drains".to_string(), ToString::to_string)
161                    } else if message.contains("agent_chain") {
162                        "agent_chain".to_string()
163                    } else {
164                        "agent_drains".to_string()
165                    };
166
167                    errors.push(ConfigValidationError::InvalidValue {
168                        file: path.to_path_buf(),
169                        key,
170                        message,
171                    });
172                }
173            }
174        }
175        Err(e) => {
176            // TOML is syntactically valid but doesn't match our schema
177            // This could be a type error or missing required field
178            let error_str = e.to_string();
179
180            // Parse the error to extract useful information
181            if error_str.contains("missing field") || error_str.contains("invalid type") {
182                // For type mismatches, add a structured error
183                errors.push(ConfigValidationError::InvalidValue {
184                    file: path.to_path_buf(),
185                    key: error_formatting::extract_key_from_toml_error(&error_str),
186                    message: error_formatting::format_invalid_type_message(&error_str),
187                });
188            } else {
189                // Other deserialization errors
190                errors.push(ConfigValidationError::InvalidValue {
191                    file: path.to_path_buf(),
192                    key: "config".to_string(),
193                    message: error_str,
194                });
195            }
196        }
197    }
198
199    if errors.is_empty() {
200        Ok(warnings)
201    } else {
202        Err(errors)
203    }
204}
205
206/// Format validation errors for user display.
207#[must_use]
208pub fn format_validation_errors(errors: &[ConfigValidationError]) -> String {
209    let mut output = String::new();
210
211    for error in errors {
212        writeln!(output, "  {error}").unwrap();
213
214        if let ConfigValidationError::UnknownKey {
215            suggestion: Some(s),
216            ..
217        } = error
218        {
219            writeln!(output, "    Did you mean '{s}'?").unwrap();
220        }
221    }
222
223    output
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_validate_config_file_valid_toml() {
232        let content = r"
233[general]
234verbosity = 2
235developer_iters = 5
236max_retries = 4
237retry_delay_ms = 1500
238";
239        let result = validate_config_file(Path::new("test.toml"), content);
240        assert!(result.is_ok());
241    }
242
243    #[test]
244    fn test_validate_config_file_warns_for_legacy_agent_chain_with_migration_message() {
245        let content = r#"
246[agent_chain]
247developer = ["codex"]
248max_retries = 5
249retry_delay_ms = 2000
250"#;
251
252        let result = validate_config_file(Path::new("test.toml"), content);
253        assert!(
254            result.is_ok(),
255            "legacy agent_chain should remain compatible"
256        );
257
258        let warnings = result.expect("validation should succeed with warnings");
259        assert!(
260            warnings
261                .iter()
262                .any(|warning| warning.contains("Deprecated section '[agent_chain]'")),
263            "expected legacy migration warning, got: {warnings:?}"
264        );
265    }
266
267    #[test]
268    fn test_validate_config_file_invalid_toml() {
269        let content = r"
270[general
271verbosity = 2
272";
273        let result = validate_config_file(Path::new("test.toml"), content);
274        assert!(result.is_err());
275
276        if let Err(errors) = result {
277            assert_eq!(errors.len(), 1);
278            match &errors[0] {
279                ConfigValidationError::TomlSyntax { file, .. } => {
280                    assert_eq!(file, Path::new("test.toml"));
281                }
282                _ => panic!("Expected TomlSyntax error"),
283            }
284        }
285    }
286
287    #[test]
288    fn test_format_validation_errors_with_suggestion() {
289        let errors = vec![ConfigValidationError::UnknownKey {
290            file: PathBuf::from("test.toml"),
291            key: "develper_iters".to_string(),
292            suggestion: Some("developer_iters".to_string()),
293        }];
294
295        let formatted = format_validation_errors(&errors);
296        assert!(formatted.contains("develper_iters"));
297        assert!(formatted.contains("Did you mean 'developer_iters'?"));
298    }
299
300    #[test]
301    fn test_format_validation_errors_without_suggestion() {
302        let errors = vec![ConfigValidationError::UnknownKey {
303            file: PathBuf::from("test.toml"),
304            key: "completely_unknown".to_string(),
305            suggestion: None,
306        }];
307
308        let formatted = format_validation_errors(&errors);
309        assert!(formatted.contains("completely_unknown"));
310        assert!(!formatted.contains("Did you mean"));
311    }
312
313    #[test]
314    fn test_format_validation_errors_multiple() {
315        // Create a real TOML parse error
316        let toml_error = toml::from_str::<toml::Value>("[invalid\nkey = value").unwrap_err();
317
318        let errors = vec![
319            ConfigValidationError::TomlSyntax {
320                file: PathBuf::from("global.toml"),
321                error: toml_error,
322            },
323            ConfigValidationError::UnknownKey {
324                file: PathBuf::from("local.toml"),
325                key: "bad_key".to_string(),
326                suggestion: Some("good_key".to_string()),
327            },
328        ];
329
330        let formatted = format_validation_errors(&errors);
331        assert!(formatted.contains("global.toml"));
332        assert!(formatted.contains("local.toml"));
333        assert!(formatted.contains("Did you mean 'good_key'?"));
334    }
335
336    #[test]
337    fn test_validate_config_file_unknown_key() {
338        let content = r"
339[general]
340develper_iters = 5
341verbosity = 2
342";
343        let result = validate_config_file(Path::new("test.toml"), content);
344        // Unknown keys are now detected via custom validation
345        assert!(result.is_err());
346
347        if let Err(errors) = result {
348            assert_eq!(errors.len(), 1);
349            match &errors[0] {
350                ConfigValidationError::UnknownKey {
351                    key, suggestion, ..
352                } => {
353                    assert!(key.contains("develper_iters"));
354                    assert_eq!(suggestion.as_ref().unwrap(), "developer_iters");
355                }
356                _ => panic!("Expected UnknownKey error"),
357            }
358        }
359    }
360
361    #[test]
362    fn test_validate_config_file_invalid_type() {
363        // This test verifies that type errors during deserialization are caught.
364        // When a string is provided where an integer is expected, validation should fail.
365        let content = r#"
366[general]
367developer_iters = "five"
368"#;
369        let result = validate_config_file(Path::new("test.toml"), content);
370        assert!(result.is_err(), "Should fail with string instead of int");
371    }
372
373    #[test]
374    fn test_validate_config_file_valid_with_all_sections() {
375        let content = r#"
376[general]
377verbosity = 2
378developer_iters = 5
379reviewer_reviews = 2
380
381[ccs]
382output_flag = "--output=json"
383
384[agents.claude]
385cmd = "claude"
386
387[ccs_aliases]
388work = "ccs work"
389"#;
390        let result = validate_config_file(Path::new("test.toml"), content);
391        assert!(result.is_ok(), "Valid config with all sections should pass");
392    }
393
394    #[test]
395    fn test_validate_config_file_empty_file() {
396        let content = "";
397        let result = validate_config_file(Path::new("test.toml"), content);
398        assert!(result.is_ok(), "Empty file should use default values");
399    }
400
401    #[test]
402    fn test_validate_general_retry_keys() {
403        let content = r#"
404[general]
405developer_iters = 5
406max_retries = 5
407retry_delay_ms = 2000
408backoff_multiplier = 2.5
409max_backoff_ms = 120000
410max_cycles = 5
411
412[agent_chains]
413shared_dev = ["claude", "codex"]
414shared_review = ["claude"]
415
416[agent_drains]
417planning = "shared_dev"
418development = "shared_dev"
419analysis = "shared_dev"
420review = "shared_review"
421fix = "shared_review"
422commit = "shared_review"
423"#;
424        let result = validate_config_file(Path::new("test.toml"), content);
425        assert!(result.is_ok(), "general retry/backoff keys should be valid");
426    }
427
428    #[test]
429    fn test_validate_general_provider_fallback_key() {
430        let content = r#"
431[general]
432
433[general.provider_fallback]
434opencode = ["-m opencode/glm-4.7-free"]
435"#;
436        let result = validate_config_file(Path::new("test.toml"), content);
437        assert!(result.is_ok(), "general.provider_fallback should be valid");
438    }
439
440    #[test]
441    fn test_validate_agent_chain_with_all_valid_keys() {
442        // Legacy [agent_chain] remains accepted with a warning for compatibility.
443        let content = r#"
444[general]
445developer_iters = 5
446
447[agent_chain]
448developer = ["claude", "codex"]
449reviewer = ["claude"]
450commit = ["claude"]
451analysis = ["claude"]
452max_retries = 5
453retry_delay_ms = 2000
454backoff_multiplier = 2.5
455max_backoff_ms = 120000
456max_cycles = 5
457
458[agent_chain.provider_fallback]
459opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]
460"#;
461        let result = validate_config_file(Path::new("test.toml"), content);
462        assert!(result.is_ok(), "legacy agent_chain should remain valid");
463    }
464
465    #[test]
466    fn test_validate_agent_chain_commit_key() {
467        // The commit key was missing from VALID_AGENT_CHAIN_KEYS
468        let content = r#"
469[agent_chain]
470developer = ["claude"]
471commit = ["claude"]
472"#;
473        let result = validate_config_file(Path::new("test.toml"), content);
474        assert!(result.is_ok(), "commit key should be valid in agent_chain");
475    }
476
477    #[test]
478    fn test_validate_agent_chain_analysis_key() {
479        // The analysis key was missing from VALID_AGENT_CHAIN_KEYS
480        let content = r#"
481[agent_chain]
482developer = ["claude"]
483analysis = ["claude"]
484"#;
485        let result = validate_config_file(Path::new("test.toml"), content);
486        assert!(
487            result.is_ok(),
488            "analysis key should be valid in agent_chain"
489        );
490    }
491
492    #[test]
493    fn test_validate_agent_chain_retry_keys() {
494        // These retry/backoff keys were missing from VALID_AGENT_CHAIN_KEYS
495        let content = r#"
496[agent_chain]
497developer = ["claude"]
498max_retries = 3
499retry_delay_ms = 5000
500backoff_multiplier = 1.5
501max_backoff_ms = 30000
502max_cycles = 2
503"#;
504        let result = validate_config_file(Path::new("test.toml"), content);
505        assert!(
506            result.is_ok(),
507            "retry/backoff keys should be valid in agent_chain"
508        );
509    }
510
511    #[test]
512    fn test_validate_agent_chain_provider_fallback_key() {
513        // The provider_fallback nested table was missing from VALID_AGENT_CHAIN_KEYS
514        let content = r#"
515[agent_chain]
516developer = ["opencode"]
517
518[agent_chain.provider_fallback]
519opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]
520"#;
521        let result = validate_config_file(Path::new("test.toml"), content);
522        assert!(
523            result.is_ok(),
524            "provider_fallback nested table should be valid in agent_chain"
525        );
526    }
527
528    #[test]
529    fn test_validate_config_file_deprecated_key_warning() {
530        let content = r"
531[general]
532verbosity = 2
533auto_rebase = true
534max_recovery_attempts = 3
535";
536        let result = validate_config_file(Path::new("test.toml"), content);
537        assert!(result.is_ok(), "Deprecated keys should not cause errors");
538
539        if let Ok(warnings) = result {
540            assert_eq!(warnings.len(), 2, "Should have 2 deprecation warnings");
541            assert!(
542                warnings.iter().any(|w| w.contains("auto_rebase")),
543                "Should warn about auto_rebase"
544            );
545            assert!(
546                warnings.iter().any(|w| w.contains("max_recovery_attempts")),
547                "Should warn about max_recovery_attempts"
548            );
549        }
550    }
551
552    #[test]
553    fn test_validate_config_file_no_warnings_without_deprecated() {
554        let content = r"
555[general]
556verbosity = 2
557developer_iters = 5
558";
559        let result = validate_config_file(Path::new("test.toml"), content);
560        assert!(result.is_ok(), "Valid config should pass");
561
562        if let Ok(warnings) = result {
563            assert_eq!(warnings.len(), 0, "Should have no warnings");
564        }
565    }
566
567    #[test]
568    fn test_validate_config_file_rejects_unknown_agent_drain_binding_target() {
569        let content = r#"
570[agent_chains]
571shared_dev = ["codex"]
572
573[agent_drains]
574planning = "missing_chain"
575"#;
576
577        let result = validate_config_file(Path::new("test.toml"), content);
578        assert!(
579            result.is_err(),
580            "unknown drain binding target should fail validation"
581        );
582
583        let errors = result.expect_err("validation should fail");
584        assert!(
585            errors.iter().any(|error| matches!(
586                error,
587                ConfigValidationError::InvalidValue { key, message, .. }
588                    if key == "agent_drains.planning"
589                        && message.contains("missing_chain")
590            )),
591            "expected invalid drain binding error, got: {errors:?}"
592        );
593    }
594
595    #[test]
596    fn test_validate_config_file_rejects_mixed_legacy_and_named_chain_schema() {
597        let content = r#"
598[agent_chain]
599developer = ["codex"]
600
601[agent_chains]
602shared_dev = ["claude"]
603"#;
604
605        let result = validate_config_file(Path::new("test.toml"), content);
606        assert!(
607            result.is_err(),
608            "mixing legacy and named chain schema should fail validation"
609        );
610
611        let errors = result.expect_err("validation should fail");
612        assert!(
613            errors.iter().any(|error| matches!(
614                error,
615                ConfigValidationError::InvalidValue { key, message, .. }
616                    if key == "agent_chain"
617                        && message.contains("agent_chains")
618                        && message.contains("agent_drains")
619            )),
620            "expected mixed schema error, got: {errors:?}"
621        );
622    }
623
624    #[test]
625    fn test_validate_config_file_rejects_incomplete_named_drain_resolution() {
626        let content = r#"
627[agent_chains]
628shared_review = ["claude"]
629
630[agent_drains]
631review = "shared_review"
632fix = "shared_review"
633"#;
634
635        let result = validate_config_file(Path::new("test.toml"), content);
636        assert!(
637            result.is_err(),
638            "incomplete drain coverage should fail validation"
639        );
640
641        let errors = result.expect_err("validation should fail");
642        assert!(
643            errors.iter().any(|error| matches!(
644                error,
645                ConfigValidationError::InvalidValue { key, message, .. }
646                    if key == "agent_drains"
647                        && message.contains("planning")
648                        && message.contains("development")
649                        && message.contains("analysis")
650            )),
651            "expected incomplete drain coverage error, got: {errors:?}"
652        );
653    }
654
655    #[test]
656    fn test_validate_config_file_accepts_commit_and_analysis_derived_from_bound_drains() {
657        let content = r#"
658[agent_chains]
659shared_dev = ["codex"]
660shared_review = ["claude"]
661
662[agent_drains]
663planning = "shared_dev"
664development = "shared_dev"
665review = "shared_review"
666fix = "shared_review"
667"#;
668
669        let result = validate_config_file(Path::new("test.toml"), content);
670        assert!(
671            result.is_ok(),
672            "commit and analysis should derive from existing bound drains"
673        );
674    }
675}