1use crate::config::{ConfigLayer, RepoTrust};
19use crate::constants::runner::{MAX_PHASES, MIN_ITERATIONS, MIN_PARALLEL_WORKERS, MIN_PHASES};
20use crate::contracts::{AgentConfig, CiGateConfig, Config, QueueAgingThresholds, QueueConfig};
21use anyhow::{Result, bail};
22use std::path::{Component, Path};
23
24fn format_aging_threshold_error(
26 warning: Option<u32>,
27 stale: Option<u32>,
28 rotten: Option<u32>,
29) -> String {
30 format!(
31 "Invalid queue.aging_thresholds ordering: require warning_days < stale_days < rotten_days (got warning_days={}, stale_days={}, rotten_days={}). Update .ralph/config.jsonc.",
32 warning
33 .map(|w| w.to_string())
34 .unwrap_or_else(|| "unset".to_string()),
35 stale
36 .map(|s| s.to_string())
37 .unwrap_or_else(|| "unset".to_string()),
38 rotten
39 .map(|r| r.to_string())
40 .unwrap_or_else(|| "unset".to_string()),
41 )
42}
43
44pub(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.";
46pub(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.";
47pub(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.";
48pub(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.";
49pub(crate) const ERR_PROJECT_EXECUTION_TRUST: &str = "Project config defines execution-sensitive settings (runner binaries, plugin runners, agent.ci_gate, and/or plugins), but this repo is not trusted. Create .ralph/trust.jsonc with {\"allow_project_commands\": true, \"trusted_at\": \"<RFC3339>\"} or move those settings to trusted global config.";
50
51fn validate_ci_gate_config(ci_gate: Option<&CiGateConfig>, label: &str) -> Result<()> {
52 let Some(ci_gate) = ci_gate else {
53 return Ok(());
54 };
55
56 if !ci_gate.is_enabled() {
57 return Ok(());
58 }
59
60 match ci_gate.argv.as_ref() {
61 Some(argv) => validate_ci_gate_argv(argv, label)?,
62 None => {
63 bail!("Invalid {label}.ci_gate: enabled CI gate requires argv settings.");
64 }
65 }
66
67 Ok(())
68}
69
70fn validate_ci_gate_argv(argv: &[String], label: &str) -> Result<()> {
71 if argv.is_empty() {
72 bail!("Invalid {label}.ci_gate.argv: at least one argv element is required.");
73 }
74 if argv.iter().any(|arg| arg.is_empty()) {
75 bail!("Invalid {label}.ci_gate.argv: argv entries must not be empty strings.");
76 }
77 if argv_launches_shell(argv) {
78 bail!(
79 "Invalid {label}.ci_gate.argv: shell launcher argv is not supported. Use direct executable argv instead."
80 );
81 }
82 Ok(())
83}
84
85pub fn validate_project_execution_trust(
86 project_cfg: Option<&ConfigLayer>,
87 repo_trust: &RepoTrust,
88) -> Result<()> {
89 let project_needs_trust = project_cfg.is_some_and(layer_has_execution_settings);
90 if project_needs_trust && !repo_trust.is_trusted() {
91 bail!(ERR_PROJECT_EXECUTION_TRUST);
92 }
93 Ok(())
94}
95
96fn layer_has_execution_settings(layer: &ConfigLayer) -> bool {
97 if agent_has_execution_settings(&layer.agent) {
98 return true;
99 }
100 if !layer.plugins.plugins.is_empty() {
101 return true;
102 }
103 layer
104 .profiles
105 .as_ref()
106 .is_some_and(|profiles| profiles.values().any(agent_has_execution_settings))
107}
108
109fn agent_has_execution_settings(agent: &AgentConfig) -> bool {
110 agent.ci_gate.is_some()
111 || agent.codex_bin.is_some()
112 || agent.opencode_bin.is_some()
113 || agent.gemini_bin.is_some()
114 || agent.claude_bin.is_some()
115 || agent.cursor_bin.is_some()
116 || agent.kimi_bin.is_some()
117 || agent.pi_bin.is_some()
118 || agent
119 .runner
120 .as_ref()
121 .is_some_and(crate::contracts::Runner::is_plugin)
122 || agent
123 .phase_overrides
124 .as_ref()
125 .is_some_and(phase_overrides_have_plugin_runner)
126}
127
128fn phase_overrides_have_plugin_runner(overrides: &crate::contracts::PhaseOverrides) -> bool {
129 [&overrides.phase1, &overrides.phase2, &overrides.phase3]
130 .into_iter()
131 .flatten()
132 .filter_map(|phase| phase.runner.as_ref())
133 .any(crate::contracts::Runner::is_plugin)
134}
135
136fn argv_launches_shell(argv: &[String]) -> bool {
137 let Some(program) = argv.first() else {
138 return false;
139 };
140 let Some(program_name) = std::path::Path::new(program)
141 .file_name()
142 .and_then(|name| name.to_str())
143 else {
144 return false;
145 };
146
147 let shell_program = matches!(
148 program_name,
149 "sh" | "bash" | "zsh" | "dash" | "fish" | "cmd" | "pwsh" | "powershell"
150 );
151 if !shell_program {
152 return false;
153 }
154
155 argv.iter().skip(1).any(|arg| {
156 arg == "-c"
157 || arg.eq_ignore_ascii_case("/c")
158 || arg.eq_ignore_ascii_case("-command")
159 || arg.eq_ignore_ascii_case("-c")
160 })
161}
162
163pub fn validate_queue_id_prefix_override(id_prefix: Option<&str>) -> Result<()> {
165 if let Some(prefix) = id_prefix
166 && prefix.trim().is_empty()
167 {
168 bail!(ERR_EMPTY_QUEUE_ID_PREFIX);
169 }
170 Ok(())
171}
172
173pub fn validate_queue_id_width_override(id_width: Option<u8>) -> Result<()> {
175 if let Some(width) = id_width
176 && width == 0
177 {
178 bail!(ERR_INVALID_QUEUE_ID_WIDTH);
179 }
180 Ok(())
181}
182
183pub fn validate_queue_file_override(file: Option<&Path>) -> Result<()> {
185 if let Some(path) = file
186 && path.as_os_str().is_empty()
187 {
188 bail!(ERR_EMPTY_QUEUE_FILE);
189 }
190 Ok(())
191}
192
193pub fn validate_queue_done_file_override(done_file: Option<&Path>) -> Result<()> {
195 if let Some(path) = done_file
196 && path.as_os_str().is_empty()
197 {
198 bail!(ERR_EMPTY_QUEUE_DONE_FILE);
199 }
200 Ok(())
201}
202
203pub fn validate_queue_overrides(queue: &QueueConfig) -> Result<()> {
205 validate_queue_id_prefix_override(queue.id_prefix.as_deref())?;
206 validate_queue_id_width_override(queue.id_width)?;
207 validate_queue_file_override(queue.file.as_deref())?;
208 validate_queue_done_file_override(queue.done_file.as_deref())?;
209 validate_queue_thresholds(queue)?;
210 Ok(())
211}
212
213pub fn validate_queue_thresholds(queue: &QueueConfig) -> Result<()> {
221 if let Some(threshold) = queue.size_warning_threshold_kb
222 && !(100..=10000).contains(&threshold)
223 {
224 bail!(
225 "Invalid queue.size_warning_threshold_kb: {}. Value must be between 100 and 10000 (inclusive). Update .ralph/config.jsonc.",
226 threshold
227 );
228 }
229
230 if let Some(threshold) = queue.task_count_warning_threshold
231 && !(50..=5000).contains(&threshold)
232 {
233 bail!(
234 "Invalid queue.task_count_warning_threshold: {}. Value must be between 50 and 5000 (inclusive). Update .ralph/config.jsonc.",
235 threshold
236 );
237 }
238
239 if let Some(depth) = queue.max_dependency_depth
240 && !(1..=100).contains(&depth)
241 {
242 bail!(
243 "Invalid queue.max_dependency_depth: {}. Value must be between 1 and 100 (inclusive). Update .ralph/config.jsonc.",
244 depth
245 );
246 }
247
248 if let Some(days) = queue.auto_archive_terminal_after_days
249 && days > 3650
250 {
251 bail!(
252 "Invalid queue.auto_archive_terminal_after_days: {}. Value must be between 0 and 3650 (inclusive). Update .ralph/config.jsonc.",
253 days
254 );
255 }
256
257 Ok(())
258}
259
260pub fn validate_queue_aging_thresholds(thresholds: &Option<QueueAgingThresholds>) -> Result<()> {
267 let Some(t) = thresholds else {
268 return Ok(());
269 };
270
271 let warning = t.warning_days;
272 let stale = t.stale_days;
273 let rotten = t.rotten_days;
274
275 if let (Some(w), Some(s)) = (warning, stale)
277 && w >= s
278 {
279 bail!(format_aging_threshold_error(Some(w), Some(s), rotten));
280 }
281
282 if let (Some(s), Some(r)) = (stale, rotten)
283 && s >= r
284 {
285 bail!(format_aging_threshold_error(warning, Some(s), Some(r)));
286 }
287
288 if let (Some(w), Some(r)) = (warning, rotten)
290 && w >= r
291 {
292 bail!(format_aging_threshold_error(Some(w), stale, Some(r)));
293 }
294
295 Ok(())
296}
297
298pub fn validate_agent_binary_paths(agent: &AgentConfig, label: &str) -> Result<()> {
310 macro_rules! check_bin {
311 ($field:ident) => {
312 if let Some(bin) = &agent.$field
313 && bin.trim().is_empty()
314 {
315 bail!(
316 "Empty {label}.{}: binary path is required if specified.",
317 stringify!($field)
318 );
319 }
320 };
321 }
322
323 check_bin!(codex_bin);
324 check_bin!(opencode_bin);
325 check_bin!(gemini_bin);
326 check_bin!(claude_bin);
327 check_bin!(cursor_bin);
328 check_bin!(kimi_bin);
329 check_bin!(pi_bin);
330
331 Ok(())
332}
333
334pub fn validate_config(cfg: &Config) -> Result<()> {
336 if cfg.version != 1 {
337 bail!(
338 "Unsupported config version: {}. Ralph requires version 1. Update the 'version' field in your config file.",
339 cfg.version
340 );
341 }
342
343 validate_queue_overrides(&cfg.queue)?;
345 validate_queue_aging_thresholds(&cfg.queue.aging_thresholds)?;
346
347 if let Some(phases) = cfg.agent.phases
348 && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
349 {
350 bail!(
351 "Invalid agent.phases: {}. Supported values are {}, {}, or {}. Update .ralph/config.jsonc or CLI flags.",
352 phases,
353 MIN_PHASES,
354 MIN_PHASES + 1,
355 MAX_PHASES
356 );
357 }
358
359 if let Some(iterations) = cfg.agent.iterations
360 && iterations < MIN_ITERATIONS
361 {
362 bail!(
363 "Invalid agent.iterations: {}. Iterations must be at least {}. Update .ralph/config.jsonc.",
364 iterations,
365 MIN_ITERATIONS
366 );
367 }
368
369 if let Some(workers) = cfg.parallel.workers
370 && workers < MIN_PARALLEL_WORKERS
371 {
372 bail!(
373 "Invalid parallel.workers: {}. Parallel workers must be >= {}. Update .ralph/config.jsonc or CLI flags.",
374 workers,
375 MIN_PARALLEL_WORKERS
376 );
377 }
378
379 if let Some(root) = &cfg.parallel.workspace_root {
381 if root.as_os_str().is_empty() {
382 bail!(
383 "Empty parallel.workspace_root: path is required if specified. Set a valid path or remove the field."
384 );
385 }
386 if root.components().any(|c| matches!(c, Component::ParentDir)) {
387 bail!(
388 "Invalid parallel.workspace_root: path must not contain '..' components (got {}). Use a normalized path.",
389 root.display()
390 );
391 }
392 }
393
394 if let Some(timeout) = cfg.agent.session_timeout_hours
395 && timeout == 0
396 {
397 bail!(
398 "Invalid agent.session_timeout_hours: {}. Session timeout must be greater than 0. Update .ralph/config.jsonc.",
399 timeout
400 );
401 }
402
403 validate_agent_binary_paths(&cfg.agent, "agent")?;
405
406 validate_ci_gate_config(cfg.agent.ci_gate.as_ref(), "agent")?;
407
408 if let Some(profiles) = cfg.profiles.as_ref() {
410 for (name, patch) in profiles {
411 validate_agent_patch(patch, &format!("profiles.{name}"))?;
412 }
413 }
414
415 Ok(())
416}
417
418pub fn validate_agent_patch(agent: &AgentConfig, label: &str) -> Result<()> {
420 if let Some(phases) = agent.phases
421 && !(MIN_PHASES..=MAX_PHASES).contains(&phases)
422 {
423 bail!(
424 "Invalid {label}.phases: {phases}. Supported values are {MIN_PHASES}, {}, or {MAX_PHASES}.",
425 MIN_PHASES + 1
426 );
427 }
428
429 if let Some(iterations) = agent.iterations
430 && iterations < MIN_ITERATIONS
431 {
432 bail!(
433 "Invalid {label}.iterations: {iterations}. Iterations must be at least {MIN_ITERATIONS}."
434 );
435 }
436
437 if let Some(timeout) = agent.session_timeout_hours
438 && timeout == 0
439 {
440 bail!(
441 "Invalid {label}.session_timeout_hours: {timeout}. Session timeout must be greater than 0."
442 );
443 }
444
445 validate_agent_binary_paths(agent, label)?;
447 validate_ci_gate_config(agent.ci_gate.as_ref(), label)?;
448
449 Ok(())
450}
451
452pub fn git_ref_invalid_reason(branch: &str) -> Option<String> {
462 if branch.is_empty() {
464 return Some("branch name cannot be empty".to_string());
465 }
466
467 if branch.chars().any(|c| c.is_ascii_control() || c == ' ') {
469 return Some("branch name cannot contain spaces or control characters".to_string());
470 }
471
472 if branch.contains("..") {
474 return Some("branch name cannot contain '..'".to_string());
475 }
476
477 if branch.contains("@{") {
479 return Some("branch name cannot contain '@{{'".to_string());
480 }
481
482 if branch.starts_with('.') {
484 return Some("branch name cannot start with '.'".to_string());
485 }
486
487 if branch.ends_with(".lock") {
488 return Some("branch name cannot end with '.lock'".to_string());
489 }
490
491 if branch.contains("//") || branch.contains("/.") || branch.ends_with('/') {
493 return Some("branch name contains invalid slash/dot pattern".to_string());
494 }
495
496 if branch == "@" || branch.starts_with("@/") || branch.contains("/@/") || branch.ends_with("/@")
498 {
499 return Some("branch name cannot be '@' or contain '@' as a path component".to_string());
500 }
501
502 if branch.contains('~') {
504 return Some("branch name cannot contain '~'".to_string());
505 }
506
507 if branch.contains('^') {
509 return Some("branch name cannot contain '^'".to_string());
510 }
511
512 if branch.contains(':') {
514 return Some("branch name cannot contain ':'".to_string());
515 }
516
517 if branch.contains('\\') {
519 return Some("branch name cannot contain '\\'".to_string());
520 }
521
522 None
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
534 fn test_validate_queue_thresholds_size_warning_ok() {
535 let queue = QueueConfig {
536 size_warning_threshold_kb: Some(500),
537 ..Default::default()
538 };
539 assert!(validate_queue_thresholds(&queue).is_ok());
540 }
541
542 #[test]
543 fn test_validate_queue_thresholds_size_warning_min_boundary() {
544 let queue = QueueConfig {
545 size_warning_threshold_kb: Some(100),
546 ..Default::default()
547 };
548 assert!(validate_queue_thresholds(&queue).is_ok());
549 }
550
551 #[test]
552 fn test_validate_queue_thresholds_size_warning_max_boundary() {
553 let queue = QueueConfig {
554 size_warning_threshold_kb: Some(10000),
555 ..Default::default()
556 };
557 assert!(validate_queue_thresholds(&queue).is_ok());
558 }
559
560 #[test]
561 fn test_validate_queue_thresholds_size_warning_too_low() {
562 let queue = QueueConfig {
563 size_warning_threshold_kb: Some(50),
564 ..Default::default()
565 };
566 let result = validate_queue_thresholds(&queue);
567 assert!(result.is_err());
568 let err = result.unwrap_err().to_string();
569 assert!(err.contains("size_warning_threshold_kb"));
570 assert!(err.contains("100"));
571 assert!(err.contains("10000"));
572 }
573
574 #[test]
575 fn test_validate_queue_thresholds_size_warning_too_high() {
576 let queue = QueueConfig {
577 size_warning_threshold_kb: Some(50000),
578 ..Default::default()
579 };
580 let result = validate_queue_thresholds(&queue);
581 assert!(result.is_err());
582 let err = result.unwrap_err().to_string();
583 assert!(err.contains("size_warning_threshold_kb"));
584 }
585
586 #[test]
587 fn test_validate_queue_thresholds_task_count_ok() {
588 let queue = QueueConfig {
589 task_count_warning_threshold: Some(500),
590 ..Default::default()
591 };
592 assert!(validate_queue_thresholds(&queue).is_ok());
593 }
594
595 #[test]
596 fn test_validate_queue_thresholds_task_count_min_boundary() {
597 let queue = QueueConfig {
598 task_count_warning_threshold: Some(50),
599 ..Default::default()
600 };
601 assert!(validate_queue_thresholds(&queue).is_ok());
602 }
603
604 #[test]
605 fn test_validate_queue_thresholds_task_count_max_boundary() {
606 let queue = QueueConfig {
607 task_count_warning_threshold: Some(5000),
608 ..Default::default()
609 };
610 assert!(validate_queue_thresholds(&queue).is_ok());
611 }
612
613 #[test]
614 fn test_validate_queue_thresholds_task_count_too_low() {
615 let queue = QueueConfig {
616 task_count_warning_threshold: Some(10),
617 ..Default::default()
618 };
619 let result = validate_queue_thresholds(&queue);
620 assert!(result.is_err());
621 let err = result.unwrap_err().to_string();
622 assert!(err.contains("task_count_warning_threshold"));
623 assert!(err.contains("50"));
624 assert!(err.contains("5000"));
625 }
626
627 #[test]
628 fn test_validate_queue_thresholds_task_count_too_high() {
629 let queue = QueueConfig {
630 task_count_warning_threshold: Some(10000),
631 ..Default::default()
632 };
633 let result = validate_queue_thresholds(&queue);
634 assert!(result.is_err());
635 let err = result.unwrap_err().to_string();
636 assert!(err.contains("task_count_warning_threshold"));
637 }
638
639 #[test]
640 fn test_validate_queue_thresholds_max_dependency_depth_ok() {
641 let queue = QueueConfig {
642 max_dependency_depth: Some(10),
643 ..Default::default()
644 };
645 assert!(validate_queue_thresholds(&queue).is_ok());
646 }
647
648 #[test]
649 fn test_validate_queue_thresholds_max_dependency_depth_min_boundary() {
650 let queue = QueueConfig {
651 max_dependency_depth: Some(1),
652 ..Default::default()
653 };
654 assert!(validate_queue_thresholds(&queue).is_ok());
655 }
656
657 #[test]
658 fn test_validate_queue_thresholds_max_dependency_depth_max_boundary() {
659 let queue = QueueConfig {
660 max_dependency_depth: Some(100),
661 ..Default::default()
662 };
663 assert!(validate_queue_thresholds(&queue).is_ok());
664 }
665
666 #[test]
667 fn test_validate_queue_thresholds_max_dependency_depth_too_low() {
668 let queue = QueueConfig {
669 max_dependency_depth: Some(0),
670 ..Default::default()
671 };
672 let result = validate_queue_thresholds(&queue);
673 assert!(result.is_err());
674 let err = result.unwrap_err().to_string();
675 assert!(err.contains("max_dependency_depth"));
676 assert!(err.contains("1"));
677 assert!(err.contains("100"));
678 }
679
680 #[test]
681 fn test_validate_queue_thresholds_max_dependency_depth_too_high() {
682 let queue = QueueConfig {
683 max_dependency_depth: Some(200),
684 ..Default::default()
685 };
686 let result = validate_queue_thresholds(&queue);
687 assert!(result.is_err());
688 let err = result.unwrap_err().to_string();
689 assert!(err.contains("max_dependency_depth"));
690 }
691
692 #[test]
693 fn test_validate_queue_thresholds_auto_archive_ok() {
694 let queue = QueueConfig {
695 auto_archive_terminal_after_days: Some(30),
696 ..Default::default()
697 };
698 assert!(validate_queue_thresholds(&queue).is_ok());
699 }
700
701 #[test]
702 fn test_validate_queue_thresholds_auto_archive_min_boundary() {
703 let queue = QueueConfig {
704 auto_archive_terminal_after_days: Some(0),
705 ..Default::default()
706 };
707 assert!(validate_queue_thresholds(&queue).is_ok());
708 }
709
710 #[test]
711 fn test_validate_queue_thresholds_auto_archive_max_boundary() {
712 let queue = QueueConfig {
713 auto_archive_terminal_after_days: Some(3650),
714 ..Default::default()
715 };
716 assert!(validate_queue_thresholds(&queue).is_ok());
717 }
718
719 #[test]
720 fn test_validate_queue_thresholds_auto_archive_too_high() {
721 let queue = QueueConfig {
722 auto_archive_terminal_after_days: Some(4000),
723 ..Default::default()
724 };
725 let result = validate_queue_thresholds(&queue);
726 assert!(result.is_err());
727 let err = result.unwrap_err().to_string();
728 assert!(err.contains("auto_archive_terminal_after_days"));
729 assert!(err.contains("3650"));
730 }
731
732 #[test]
733 fn test_validate_queue_thresholds_none_values_ok() {
734 let queue = QueueConfig {
735 size_warning_threshold_kb: None,
736 task_count_warning_threshold: None,
737 max_dependency_depth: None,
738 auto_archive_terminal_after_days: None,
739 ..Default::default()
740 };
741 assert!(validate_queue_thresholds(&queue).is_ok());
742 }
743
744 #[test]
745 fn test_validate_queue_thresholds_all_boundary_values() {
746 let queue = QueueConfig {
748 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()
753 };
754 assert!(validate_queue_thresholds(&queue).is_ok());
755 }
756
757 #[test]
758 fn test_validate_queue_thresholds_all_max_boundary_values() {
759 let queue = QueueConfig {
760 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()
765 };
766 assert!(validate_queue_thresholds(&queue).is_ok());
767 }
768
769 #[test]
774 fn test_validate_queue_overrides_calls_threshold_validation() {
775 let queue = QueueConfig {
777 size_warning_threshold_kb: Some(50), ..Default::default()
779 };
780 let result = validate_queue_overrides(&queue);
781 assert!(result.is_err());
782 let err = result.unwrap_err().to_string();
783 assert!(err.contains("size_warning_threshold_kb"));
784 }
785
786 #[test]
791 fn test_validate_config_includes_threshold_validation() {
792 let cfg = Config {
793 version: 1,
794 queue: QueueConfig {
795 size_warning_threshold_kb: Some(50000), ..Default::default()
797 },
798 ..Default::default()
799 };
800 let result = validate_config(&cfg);
801 assert!(result.is_err());
802 let err = result.unwrap_err().to_string();
803 assert!(err.contains("size_warning_threshold_kb"));
804 }
805}