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(_) => {
129 }
131 Err(e) => {
132 let error_str = e.to_string();
135
136 if error_str.contains("missing field") || error_str.contains("invalid type") {
138 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 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#[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 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 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 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 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 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 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 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 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}