ralph_workflow/config/validation/
mod.rs1use std::path::{Path, PathBuf};
25use thiserror::Error;
26
27mod error_formatting;
28mod key_detection;
29mod keys;
30mod levenshtein;
31
32pub use levenshtein::suggest_key;
34
35#[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
59pub type ValidationResult = Result<Vec<String>, Vec<ConfigValidationError>>;
63
64pub 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 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 let (unknown_keys, deprecated_keys) =
96 key_detection::detect_unknown_and_deprecated_keys(&parsed_value);
97
98 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 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 match toml::from_str::<crate::config::unified::UnifiedConfig>(content) {
123 Ok(_) => {
124 }
126 Err(e) => {
127 let error_str = e.to_string();
130
131 if error_str.contains("missing field") || error_str.contains("invalid type") {
133 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 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
157pub 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 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 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 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 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 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 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 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 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}