1use crate::constants::runner::{MAX_PHASES, MIN_ITERATIONS, MIN_PARALLEL_WORKERS, MIN_PHASES};
19use crate::contracts::{AgentConfig, Config, QueueAgingThresholds, QueueConfig};
20use anyhow::{Result, bail};
21use std::path::{Component, Path};
22
23fn format_aging_threshold_error(
25 warning: Option<u32>,
26 stale: Option<u32>,
27 rotten: Option<u32>,
28) -> String {
29 format!(
30 "Invalid queue.aging_thresholds ordering: require warning_days < stale_days < rotten_days (got warning_days={}, stale_days={}, rotten_days={}). Update .ralph/config.jsonc.",
31 warning
32 .map(|w| w.to_string())
33 .unwrap_or_else(|| "unset".to_string()),
34 stale
35 .map(|s| s.to_string())
36 .unwrap_or_else(|| "unset".to_string()),
37 rotten
38 .map(|r| r.to_string())
39 .unwrap_or_else(|| "unset".to_string()),
40 )
41}
42
43pub(crate) const ERR_EMPTY_QUEUE_ID_PREFIX: &str = "Empty queue.id_prefix: prefix is required if specified. Set a non-empty prefix (e.g., 'RQ') in .ralph/config.jsonc or via --id-prefix.";
45pub(crate) const ERR_INVALID_QUEUE_ID_WIDTH: &str = "Invalid queue.id_width: width must be greater than 0. Set a valid width (e.g., 4) in .ralph/config.jsonc or via --id-width.";
46pub(crate) const ERR_EMPTY_QUEUE_FILE: &str = "Empty queue.file: path is required if specified. Specify a valid path (e.g., '.ralph/queue.jsonc') in .ralph/config.jsonc or via --queue-file.";
47pub(crate) const ERR_EMPTY_QUEUE_DONE_FILE: &str = "Empty queue.done_file: path is required if specified. Specify a valid path (e.g., '.ralph/done.jsonc') in .ralph/config.jsonc or via --done-file.";
48
49pub fn validate_queue_id_prefix_override(id_prefix: Option<&str>) -> Result<()> {
51 if let Some(prefix) = id_prefix
52 && prefix.trim().is_empty()
53 {
54 bail!(ERR_EMPTY_QUEUE_ID_PREFIX);
55 }
56 Ok(())
57}
58
59pub fn validate_queue_id_width_override(id_width: Option<u8>) -> Result<()> {
61 if let Some(width) = id_width
62 && width == 0
63 {
64 bail!(ERR_INVALID_QUEUE_ID_WIDTH);
65 }
66 Ok(())
67}
68
69pub fn validate_queue_file_override(file: Option<&Path>) -> Result<()> {
71 if let Some(path) = file
72 && path.as_os_str().is_empty()
73 {
74 bail!(ERR_EMPTY_QUEUE_FILE);
75 }
76 Ok(())
77}
78
79pub fn validate_queue_done_file_override(done_file: Option<&Path>) -> Result<()> {
81 if let Some(path) = done_file
82 && path.as_os_str().is_empty()
83 {
84 bail!(ERR_EMPTY_QUEUE_DONE_FILE);
85 }
86 Ok(())
87}
88
89pub fn validate_queue_overrides(queue: &QueueConfig) -> Result<()> {
91 validate_queue_id_prefix_override(queue.id_prefix.as_deref())?;
92 validate_queue_id_width_override(queue.id_width)?;
93 validate_queue_file_override(queue.file.as_deref())?;
94 validate_queue_done_file_override(queue.done_file.as_deref())?;
95 validate_queue_thresholds(queue)?;
96 Ok(())
97}
98
99pub fn validate_queue_thresholds(queue: &QueueConfig) -> Result<()> {
107 if let Some(threshold) = queue.size_warning_threshold_kb
108 && !(100..=10000).contains(&threshold)
109 {
110 bail!(
111 "Invalid queue.size_warning_threshold_kb: {}. Value must be between 100 and 10000 (inclusive). Update .ralph/config.jsonc.",
112 threshold
113 );
114 }
115
116 if let Some(threshold) = queue.task_count_warning_threshold
117 && !(50..=5000).contains(&threshold)
118 {
119 bail!(
120 "Invalid queue.task_count_warning_threshold: {}. Value must be between 50 and 5000 (inclusive). Update .ralph/config.jsonc.",
121 threshold
122 );
123 }
124
125 if let Some(depth) = queue.max_dependency_depth
126 && !(1..=100).contains(&depth)
127 {
128 bail!(
129 "Invalid queue.max_dependency_depth: {}. Value must be between 1 and 100 (inclusive). Update .ralph/config.jsonc.",
130 depth
131 );
132 }
133
134 if let Some(days) = queue.auto_archive_terminal_after_days
135 && days > 3650
136 {
137 bail!(
138 "Invalid queue.auto_archive_terminal_after_days: {}. Value must be between 0 and 3650 (inclusive). Update .ralph/config.jsonc.",
139 days
140 );
141 }
142
143 Ok(())
144}
145
146pub fn validate_queue_aging_thresholds(thresholds: &Option<QueueAgingThresholds>) -> Result<()> {
153 let Some(t) = thresholds else {
154 return Ok(());
155 };
156
157 let warning = t.warning_days;
158 let stale = t.stale_days;
159 let rotten = t.rotten_days;
160
161 if let (Some(w), Some(s)) = (warning, stale)
163 && w >= s
164 {
165 bail!(format_aging_threshold_error(Some(w), Some(s), rotten));
166 }
167
168 if let (Some(s), Some(r)) = (stale, rotten)
169 && s >= r
170 {
171 bail!(format_aging_threshold_error(warning, Some(s), Some(r)));
172 }
173
174 if let (Some(w), Some(r)) = (warning, rotten)
176 && w >= r
177 {
178 bail!(format_aging_threshold_error(Some(w), stale, Some(r)));
179 }
180
181 Ok(())
182}
183
184pub fn validate_agent_binary_paths(agent: &AgentConfig, label: &str) -> Result<()> {
196 macro_rules! check_bin {
197 ($field:ident) => {
198 if let Some(bin) = &agent.$field
199 && bin.trim().is_empty()
200 {
201 bail!(
202 "Empty {label}.{}: binary path is required if specified.",
203 stringify!($field)
204 );
205 }
206 };
207 }
208
209 check_bin!(codex_bin);
210 check_bin!(opencode_bin);
211 check_bin!(gemini_bin);
212 check_bin!(claude_bin);
213 check_bin!(cursor_bin);
214 check_bin!(kimi_bin);
215 check_bin!(pi_bin);
216
217 Ok(())
218}
219
220pub fn validate_config(cfg: &Config) -> Result<()> {
222 if cfg.version != 1 {
223 bail!(
224 "Unsupported config version: {}. Ralph requires version 1. Update the 'version' field in your config file.",
225 cfg.version
226 );
227 }
228
229 validate_queue_overrides(&cfg.queue)?;
231 validate_queue_aging_thresholds(&cfg.queue.aging_thresholds)?;
232
233 if let Some(phases) = cfg.agent.phases
234 && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
235 {
236 bail!(
237 "Invalid agent.phases: {}. Supported values are {}, {}, or {}. Update .ralph/config.jsonc or CLI flags.",
238 phases,
239 MIN_PHASES,
240 MIN_PHASES + 1,
241 MAX_PHASES
242 );
243 }
244
245 if let Some(iterations) = cfg.agent.iterations
246 && iterations < MIN_ITERATIONS
247 {
248 bail!(
249 "Invalid agent.iterations: {}. Iterations must be at least {}. Update .ralph/config.jsonc.",
250 iterations,
251 MIN_ITERATIONS
252 );
253 }
254
255 if let Some(workers) = cfg.parallel.workers
256 && workers < MIN_PARALLEL_WORKERS
257 {
258 bail!(
259 "Invalid parallel.workers: {}. Parallel workers must be >= {}. Update .ralph/config.jsonc or CLI flags.",
260 workers,
261 MIN_PARALLEL_WORKERS
262 );
263 }
264
265 if let Some(root) = &cfg.parallel.workspace_root {
267 if root.as_os_str().is_empty() {
268 bail!(
269 "Empty parallel.workspace_root: path is required if specified. Set a valid path or remove the field."
270 );
271 }
272 if root.components().any(|c| matches!(c, Component::ParentDir)) {
273 bail!(
274 "Invalid parallel.workspace_root: path must not contain '..' components (got {}). Use a normalized path.",
275 root.display()
276 );
277 }
278 }
279
280 if let Some(timeout) = cfg.agent.session_timeout_hours
281 && timeout == 0
282 {
283 bail!(
284 "Invalid agent.session_timeout_hours: {}. Session timeout must be greater than 0. Update .ralph/config.jsonc.",
285 timeout
286 );
287 }
288
289 validate_agent_binary_paths(&cfg.agent, "agent")?;
291
292 let ci_gate_enabled = cfg.agent.ci_gate_enabled.unwrap_or(true);
293 if ci_gate_enabled
294 && let Some(command) = &cfg.agent.ci_gate_command
295 && command.trim().is_empty()
296 {
297 bail!(
298 "Empty agent.ci_gate_command: CI gate command must be non-empty when enabled. Set a command (e.g., 'make ci') or disable the gate with agent.ci_gate_enabled=false."
299 );
300 }
301
302 if let Some(profiles) = cfg.profiles.as_ref() {
304 for (name, patch) in profiles {
305 validate_agent_patch(patch, &format!("profiles.{name}"))?;
306 }
307 }
308
309 Ok(())
310}
311
312pub fn validate_agent_patch(agent: &AgentConfig, label: &str) -> Result<()> {
314 if let Some(phases) = agent.phases
315 && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
316 {
317 bail!(
318 "Invalid {label}.phases: {phases}. Supported values are {MIN_PHASES}, {}, or {MAX_PHASES}.",
319 MIN_PHASES + 1
320 );
321 }
322
323 if let Some(iterations) = agent.iterations
324 && iterations < MIN_ITERATIONS
325 {
326 bail!(
327 "Invalid {label}.iterations: {iterations}. Iterations must be at least {MIN_ITERATIONS}."
328 );
329 }
330
331 if let Some(timeout) = agent.session_timeout_hours
332 && timeout == 0
333 {
334 bail!(
335 "Invalid {label}.session_timeout_hours: {timeout}. Session timeout must be greater than 0."
336 );
337 }
338
339 validate_agent_binary_paths(agent, label)?;
341
342 Ok(())
343}
344
345pub fn git_ref_invalid_reason(branch: &str) -> Option<String> {
355 if branch.is_empty() {
357 return Some("branch name cannot be empty".to_string());
358 }
359
360 if branch.chars().any(|c| c.is_ascii_control() || c == ' ') {
362 return Some("branch name cannot contain spaces or control characters".to_string());
363 }
364
365 if branch.contains("..") {
367 return Some("branch name cannot contain '..'".to_string());
368 }
369
370 if branch.contains("@{") {
372 return Some("branch name cannot contain '@{{'".to_string());
373 }
374
375 if branch.starts_with('.') {
377 return Some("branch name cannot start with '.'".to_string());
378 }
379
380 if branch.ends_with(".lock") {
381 return Some("branch name cannot end with '.lock'".to_string());
382 }
383
384 if branch.contains("//") || branch.contains("/.") || branch.ends_with('/') {
386 return Some("branch name contains invalid slash/dot pattern".to_string());
387 }
388
389 if branch == "@" || branch.starts_with("@/") || branch.contains("/@/") || branch.ends_with("/@")
391 {
392 return Some("branch name cannot be '@' or contain '@' as a path component".to_string());
393 }
394
395 if branch.contains('~') {
397 return Some("branch name cannot contain '~'".to_string());
398 }
399
400 if branch.contains('^') {
402 return Some("branch name cannot contain '^'".to_string());
403 }
404
405 if branch.contains(':') {
407 return Some("branch name cannot contain ':'".to_string());
408 }
409
410 if branch.contains('\\') {
412 return Some("branch name cannot contain '\\'".to_string());
413 }
414
415 None
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
427 fn test_validate_queue_thresholds_size_warning_ok() {
428 let queue = QueueConfig {
429 size_warning_threshold_kb: Some(500),
430 ..Default::default()
431 };
432 assert!(validate_queue_thresholds(&queue).is_ok());
433 }
434
435 #[test]
436 fn test_validate_queue_thresholds_size_warning_min_boundary() {
437 let queue = QueueConfig {
438 size_warning_threshold_kb: Some(100),
439 ..Default::default()
440 };
441 assert!(validate_queue_thresholds(&queue).is_ok());
442 }
443
444 #[test]
445 fn test_validate_queue_thresholds_size_warning_max_boundary() {
446 let queue = QueueConfig {
447 size_warning_threshold_kb: Some(10000),
448 ..Default::default()
449 };
450 assert!(validate_queue_thresholds(&queue).is_ok());
451 }
452
453 #[test]
454 fn test_validate_queue_thresholds_size_warning_too_low() {
455 let queue = QueueConfig {
456 size_warning_threshold_kb: Some(50),
457 ..Default::default()
458 };
459 let result = validate_queue_thresholds(&queue);
460 assert!(result.is_err());
461 let err = result.unwrap_err().to_string();
462 assert!(err.contains("size_warning_threshold_kb"));
463 assert!(err.contains("100"));
464 assert!(err.contains("10000"));
465 }
466
467 #[test]
468 fn test_validate_queue_thresholds_size_warning_too_high() {
469 let queue = QueueConfig {
470 size_warning_threshold_kb: Some(50000),
471 ..Default::default()
472 };
473 let result = validate_queue_thresholds(&queue);
474 assert!(result.is_err());
475 let err = result.unwrap_err().to_string();
476 assert!(err.contains("size_warning_threshold_kb"));
477 }
478
479 #[test]
480 fn test_validate_queue_thresholds_task_count_ok() {
481 let queue = QueueConfig {
482 task_count_warning_threshold: Some(500),
483 ..Default::default()
484 };
485 assert!(validate_queue_thresholds(&queue).is_ok());
486 }
487
488 #[test]
489 fn test_validate_queue_thresholds_task_count_min_boundary() {
490 let queue = QueueConfig {
491 task_count_warning_threshold: Some(50),
492 ..Default::default()
493 };
494 assert!(validate_queue_thresholds(&queue).is_ok());
495 }
496
497 #[test]
498 fn test_validate_queue_thresholds_task_count_max_boundary() {
499 let queue = QueueConfig {
500 task_count_warning_threshold: Some(5000),
501 ..Default::default()
502 };
503 assert!(validate_queue_thresholds(&queue).is_ok());
504 }
505
506 #[test]
507 fn test_validate_queue_thresholds_task_count_too_low() {
508 let queue = QueueConfig {
509 task_count_warning_threshold: Some(10),
510 ..Default::default()
511 };
512 let result = validate_queue_thresholds(&queue);
513 assert!(result.is_err());
514 let err = result.unwrap_err().to_string();
515 assert!(err.contains("task_count_warning_threshold"));
516 assert!(err.contains("50"));
517 assert!(err.contains("5000"));
518 }
519
520 #[test]
521 fn test_validate_queue_thresholds_task_count_too_high() {
522 let queue = QueueConfig {
523 task_count_warning_threshold: Some(10000),
524 ..Default::default()
525 };
526 let result = validate_queue_thresholds(&queue);
527 assert!(result.is_err());
528 let err = result.unwrap_err().to_string();
529 assert!(err.contains("task_count_warning_threshold"));
530 }
531
532 #[test]
533 fn test_validate_queue_thresholds_max_dependency_depth_ok() {
534 let queue = QueueConfig {
535 max_dependency_depth: Some(10),
536 ..Default::default()
537 };
538 assert!(validate_queue_thresholds(&queue).is_ok());
539 }
540
541 #[test]
542 fn test_validate_queue_thresholds_max_dependency_depth_min_boundary() {
543 let queue = QueueConfig {
544 max_dependency_depth: Some(1),
545 ..Default::default()
546 };
547 assert!(validate_queue_thresholds(&queue).is_ok());
548 }
549
550 #[test]
551 fn test_validate_queue_thresholds_max_dependency_depth_max_boundary() {
552 let queue = QueueConfig {
553 max_dependency_depth: Some(100),
554 ..Default::default()
555 };
556 assert!(validate_queue_thresholds(&queue).is_ok());
557 }
558
559 #[test]
560 fn test_validate_queue_thresholds_max_dependency_depth_too_low() {
561 let queue = QueueConfig {
562 max_dependency_depth: Some(0),
563 ..Default::default()
564 };
565 let result = validate_queue_thresholds(&queue);
566 assert!(result.is_err());
567 let err = result.unwrap_err().to_string();
568 assert!(err.contains("max_dependency_depth"));
569 assert!(err.contains("1"));
570 assert!(err.contains("100"));
571 }
572
573 #[test]
574 fn test_validate_queue_thresholds_max_dependency_depth_too_high() {
575 let queue = QueueConfig {
576 max_dependency_depth: Some(200),
577 ..Default::default()
578 };
579 let result = validate_queue_thresholds(&queue);
580 assert!(result.is_err());
581 let err = result.unwrap_err().to_string();
582 assert!(err.contains("max_dependency_depth"));
583 }
584
585 #[test]
586 fn test_validate_queue_thresholds_auto_archive_ok() {
587 let queue = QueueConfig {
588 auto_archive_terminal_after_days: Some(30),
589 ..Default::default()
590 };
591 assert!(validate_queue_thresholds(&queue).is_ok());
592 }
593
594 #[test]
595 fn test_validate_queue_thresholds_auto_archive_min_boundary() {
596 let queue = QueueConfig {
597 auto_archive_terminal_after_days: Some(0),
598 ..Default::default()
599 };
600 assert!(validate_queue_thresholds(&queue).is_ok());
601 }
602
603 #[test]
604 fn test_validate_queue_thresholds_auto_archive_max_boundary() {
605 let queue = QueueConfig {
606 auto_archive_terminal_after_days: Some(3650),
607 ..Default::default()
608 };
609 assert!(validate_queue_thresholds(&queue).is_ok());
610 }
611
612 #[test]
613 fn test_validate_queue_thresholds_auto_archive_too_high() {
614 let queue = QueueConfig {
615 auto_archive_terminal_after_days: Some(4000),
616 ..Default::default()
617 };
618 let result = validate_queue_thresholds(&queue);
619 assert!(result.is_err());
620 let err = result.unwrap_err().to_string();
621 assert!(err.contains("auto_archive_terminal_after_days"));
622 assert!(err.contains("3650"));
623 }
624
625 #[test]
626 fn test_validate_queue_thresholds_none_values_ok() {
627 let queue = QueueConfig {
628 size_warning_threshold_kb: None,
629 task_count_warning_threshold: None,
630 max_dependency_depth: None,
631 auto_archive_terminal_after_days: None,
632 ..Default::default()
633 };
634 assert!(validate_queue_thresholds(&queue).is_ok());
635 }
636
637 #[test]
638 fn test_validate_queue_thresholds_all_boundary_values() {
639 let queue = QueueConfig {
641 size_warning_threshold_kb: Some(100), task_count_warning_threshold: Some(5000), max_dependency_depth: Some(1), auto_archive_terminal_after_days: Some(3650), ..Default::default()
646 };
647 assert!(validate_queue_thresholds(&queue).is_ok());
648 }
649
650 #[test]
651 fn test_validate_queue_thresholds_all_max_boundary_values() {
652 let queue = QueueConfig {
653 size_warning_threshold_kb: Some(10000), task_count_warning_threshold: Some(50), max_dependency_depth: Some(100), auto_archive_terminal_after_days: Some(0), ..Default::default()
658 };
659 assert!(validate_queue_thresholds(&queue).is_ok());
660 }
661
662 #[test]
667 fn test_validate_queue_overrides_calls_threshold_validation() {
668 let queue = QueueConfig {
670 size_warning_threshold_kb: Some(50), ..Default::default()
672 };
673 let result = validate_queue_overrides(&queue);
674 assert!(result.is_err());
675 let err = result.unwrap_err().to_string();
676 assert!(err.contains("size_warning_threshold_kb"));
677 }
678
679 #[test]
684 fn test_validate_config_includes_threshold_validation() {
685 let cfg = Config {
686 version: 1,
687 queue: QueueConfig {
688 size_warning_threshold_kb: Some(50000), ..Default::default()
690 },
691 ..Default::default()
692 };
693 let result = validate_config(&cfg);
694 assert!(result.is_err());
695 let err = result.unwrap_err().to_string();
696 assert!(err.contains("size_warning_threshold_kb"));
697 }
698}