ralph_workflow/config/validation/
mod.rs1use std::fmt::Write;
25use std::path::{Path, PathBuf};
26use thiserror::Error;
27
28mod error_formatting;
29mod key_detection;
30mod keys;
31mod levenshtein;
32
33pub use levenshtein::suggest_key;
35
36#[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
60pub type ValidationResult = Result<Vec<String>, Vec<ConfigValidationError>>;
64
65pub 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 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 let (unknown_keys, deprecated_keys) =
101 key_detection::detect_unknown_and_deprecated_keys(&parsed_value);
102
103 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 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 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 let error_str = e.to_string();
179
180 if error_str.contains("missing field") || error_str.contains("invalid type") {
182 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 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#[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 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 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 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 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 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 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 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 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}