1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
20 #[serde(default)]
22 pub event_loop: EventLoopConfig,
23
24 #[serde(default)]
26 pub cli: CliConfig,
27
28 #[serde(default)]
30 pub core: CoreConfig,
31
32 #[serde(default)]
35 pub hats: HashMap<String, HatConfig>,
36
37 #[serde(default)]
41 pub events: HashMap<String, EventMetadata>,
42
43 #[serde(default)]
50 pub agent: Option<String>,
51
52 #[serde(default)]
54 pub agent_priority: Vec<String>,
55
56 #[serde(default)]
58 pub prompt_file: Option<String>,
59
60 #[serde(default)]
62 pub completion_promise: Option<String>,
63
64 #[serde(default)]
66 pub max_iterations: Option<u32>,
67
68 #[serde(default)]
70 pub max_runtime: Option<u64>,
71
72 #[serde(default)]
74 pub max_cost: Option<f64>,
75
76 #[serde(default)]
81 pub verbose: bool,
82
83 #[serde(default)]
85 pub archive_prompts: bool,
86
87 #[serde(default)]
89 pub enable_metrics: bool,
90
91 #[serde(default)]
96 pub max_tokens: Option<u32>,
97
98 #[serde(default)]
100 pub retry_delay: Option<u32>,
101
102 #[serde(default)]
104 pub adapters: AdaptersConfig,
105
106 #[serde(default, rename = "_suppress_warnings")]
111 pub suppress_warnings: bool,
112
113 #[serde(default)]
115 pub tui: TuiConfig,
116
117 #[serde(default)]
119 pub memories: MemoriesConfig,
120
121 #[serde(default)]
123 pub tasks: TasksConfig,
124
125 #[serde(default)]
127 pub hooks: HooksConfig,
128
129 #[serde(default)]
131 pub skills: SkillsConfig,
132
133 #[serde(default)]
135 pub features: FeaturesConfig,
136
137 #[serde(default, rename = "RObot")]
139 pub robot: RobotConfig,
140}
141
142fn default_true() -> bool {
143 true
144}
145
146#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
148 fn default() -> Self {
149 Self {
150 event_loop: EventLoopConfig::default(),
151 cli: CliConfig::default(),
152 core: CoreConfig::default(),
153 hats: HashMap::new(),
154 events: HashMap::new(),
155 agent: None,
157 agent_priority: vec![],
158 prompt_file: None,
159 completion_promise: None,
160 max_iterations: None,
161 max_runtime: None,
162 max_cost: None,
163 verbose: false,
165 archive_prompts: false,
166 enable_metrics: false,
167 max_tokens: None,
169 retry_delay: None,
170 adapters: AdaptersConfig::default(),
171 suppress_warnings: false,
173 tui: TuiConfig::default(),
175 memories: MemoriesConfig::default(),
177 tasks: TasksConfig::default(),
179 hooks: HooksConfig::default(),
181 skills: SkillsConfig::default(),
183 features: FeaturesConfig::default(),
185 robot: RobotConfig::default(),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct AdaptersConfig {
194 #[serde(default)]
196 pub claude: AdapterSettings,
197
198 #[serde(default)]
200 pub gemini: AdapterSettings,
201
202 #[serde(default)]
204 pub kiro: AdapterSettings,
205
206 #[serde(default)]
208 pub codex: AdapterSettings,
209
210 #[serde(default)]
212 pub amp: AdapterSettings,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AdapterSettings {
218 #[serde(default = "default_timeout")]
220 pub timeout: u64,
221
222 #[serde(default = "default_true")]
224 pub enabled: bool,
225
226 #[serde(default)]
228 pub tool_permissions: Option<Vec<String>>,
229}
230
231fn default_timeout() -> u64 {
232 300 }
234
235impl Default for AdapterSettings {
236 fn default() -> Self {
237 Self {
238 timeout: default_timeout(),
239 enabled: true,
240 tool_permissions: None,
241 }
242 }
243}
244
245impl RalphConfig {
246 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
248 let path_ref = path.as_ref();
249 debug!(path = %path_ref.display(), "Loading configuration from file");
250 let content = std::fs::read_to_string(path_ref)?;
251 Self::parse_yaml(&content)
252 }
253
254 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
256 let value: serde_yaml::Value = serde_yaml::from_str(content)?;
258 if let Some(map) = value.as_mapping()
259 && map.contains_key(serde_yaml::Value::String("project".to_string()))
260 {
261 return Err(ConfigError::DeprecatedProjectKey);
262 }
263
264 validate_hooks_phase_event_keys(&value)?;
265
266 let config: Self = serde_yaml::from_value(value)?;
267 debug!(
268 backend = %config.cli.backend,
269 has_v1_fields = config.agent.is_some(),
270 custom_hats = config.hats.len(),
271 "Configuration loaded"
272 );
273 Ok(config)
274 }
275
276 pub fn normalize(&mut self) {
281 let mut normalized_count = 0;
282
283 if let Some(ref agent) = self.agent {
285 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
286 self.cli.backend = agent.clone();
287 normalized_count += 1;
288 }
289
290 if let Some(ref pf) = self.prompt_file {
292 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
293 self.event_loop.prompt_file = pf.clone();
294 normalized_count += 1;
295 }
296
297 if let Some(ref cp) = self.completion_promise {
299 debug!(
300 from = "completion_promise",
301 to = "event_loop.completion_promise",
302 "Normalizing v1 field"
303 );
304 self.event_loop.completion_promise = cp.clone();
305 normalized_count += 1;
306 }
307
308 if let Some(mi) = self.max_iterations {
310 debug!(
311 from = "max_iterations",
312 to = "event_loop.max_iterations",
313 value = mi,
314 "Normalizing v1 field"
315 );
316 self.event_loop.max_iterations = mi;
317 normalized_count += 1;
318 }
319
320 if let Some(mr) = self.max_runtime {
322 debug!(
323 from = "max_runtime",
324 to = "event_loop.max_runtime_seconds",
325 value = mr,
326 "Normalizing v1 field"
327 );
328 self.event_loop.max_runtime_seconds = mr;
329 normalized_count += 1;
330 }
331
332 if self.max_cost.is_some() {
334 debug!(
335 from = "max_cost",
336 to = "event_loop.max_cost_usd",
337 "Normalizing v1 field"
338 );
339 self.event_loop.max_cost_usd = self.max_cost;
340 normalized_count += 1;
341 }
342
343 for (hat_id, hat) in &mut self.hats {
345 if !hat.extra_instructions.is_empty() {
346 for fragment in hat.extra_instructions.drain(..) {
347 if !hat.instructions.ends_with('\n') {
348 hat.instructions.push('\n');
349 }
350 hat.instructions.push_str(&fragment);
351 }
352 debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
353 normalized_count += 1;
354 }
355 }
356
357 if normalized_count > 0 {
358 debug!(
359 fields_normalized = normalized_count,
360 "V1 to V2 config normalization complete"
361 );
362 }
363 }
364
365 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
375 let mut warnings = Vec::new();
376
377 if self.suppress_warnings {
379 return Ok(warnings);
380 }
381
382 if self.event_loop.prompt.is_some()
385 && !self.event_loop.prompt_file.is_empty()
386 && self.event_loop.prompt_file != default_prompt_file()
387 {
388 return Err(ConfigError::MutuallyExclusive {
389 field1: "event_loop.prompt".to_string(),
390 field2: "event_loop.prompt_file".to_string(),
391 });
392 }
393 if self.event_loop.completion_promise.trim().is_empty() {
394 return Err(ConfigError::InvalidCompletionPromise);
395 }
396
397 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
399 return Err(ConfigError::CustomBackendRequiresCommand);
400 }
401
402 if self.archive_prompts {
404 warnings.push(ConfigWarning::DeferredFeature {
405 field: "archive_prompts".to_string(),
406 message: "Feature not yet available in v2".to_string(),
407 });
408 }
409
410 if self.enable_metrics {
411 warnings.push(ConfigWarning::DeferredFeature {
412 field: "enable_metrics".to_string(),
413 message: "Feature not yet available in v2".to_string(),
414 });
415 }
416
417 if self.max_tokens.is_some() {
419 warnings.push(ConfigWarning::DroppedField {
420 field: "max_tokens".to_string(),
421 reason: "Token limits are controlled by the CLI tool".to_string(),
422 });
423 }
424
425 if self.retry_delay.is_some() {
426 warnings.push(ConfigWarning::DroppedField {
427 field: "retry_delay".to_string(),
428 reason: "Retry logic handled differently in v2".to_string(),
429 });
430 }
431
432 if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
433 && !(0.0..=100.0).contains(&threshold)
434 {
435 warnings.push(ConfigWarning::InvalidValue {
436 field: "event_loop.mutation_score_warn_threshold".to_string(),
437 message: "Value must be between 0 and 100".to_string(),
438 });
439 }
440
441 if self.adapters.claude.tool_permissions.is_some()
443 || self.adapters.gemini.tool_permissions.is_some()
444 || self.adapters.codex.tool_permissions.is_some()
445 || self.adapters.amp.tool_permissions.is_some()
446 {
447 warnings.push(ConfigWarning::DroppedField {
448 field: "adapters.*.tool_permissions".to_string(),
449 reason: "CLI tool manages its own permissions".to_string(),
450 });
451 }
452
453 self.robot.validate()?;
455
456 self.validate_hooks()?;
458
459 for (hat_id, hat_config) in &self.hats {
461 if hat_config
462 .description
463 .as_ref()
464 .is_none_or(|d| d.trim().is_empty())
465 {
466 return Err(ConfigError::MissingDescription {
467 hat: hat_id.clone(),
468 });
469 }
470 }
471
472 for (hat_id, hat_config) in &self.hats {
474 if hat_config.concurrency == 0 {
475 return Err(ConfigError::InvalidConcurrency {
476 hat: hat_id.clone(),
477 value: 0,
478 });
479 }
480 if hat_config.aggregate.is_some() && hat_config.concurrency > 1 {
481 return Err(ConfigError::AggregateOnConcurrentHat {
482 hat: hat_id.clone(),
483 });
484 }
485 }
486
487 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
490 for (hat_id, hat_config) in &self.hats {
491 for trigger in &hat_config.triggers {
492 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
493 return Err(ConfigError::ReservedTrigger {
494 trigger: trigger.clone(),
495 hat: hat_id.clone(),
496 });
497 }
498 }
499 }
500
501 if !self.hats.is_empty() {
504 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
505 for (hat_id, hat_config) in &self.hats {
506 for trigger in &hat_config.triggers {
507 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
508 return Err(ConfigError::AmbiguousRouting {
509 trigger: trigger.clone(),
510 hat1: (*existing_hat).to_string(),
511 hat2: hat_id.clone(),
512 });
513 }
514 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
515 }
516 }
517 }
518
519 Ok(warnings)
520 }
521
522 fn validate_hooks(&self) -> Result<(), ConfigError> {
523 Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
524
525 if self.hooks.defaults.timeout_seconds == 0 {
526 return Err(ConfigError::HookValidation {
527 field: "hooks.defaults.timeout_seconds".to_string(),
528 message: "must be greater than 0".to_string(),
529 });
530 }
531
532 if self.hooks.defaults.max_output_bytes == 0 {
533 return Err(ConfigError::HookValidation {
534 field: "hooks.defaults.max_output_bytes".to_string(),
535 message: "must be greater than 0".to_string(),
536 });
537 }
538
539 for (phase_event, hook_specs) in &self.hooks.events {
540 for (index, hook) in hook_specs.iter().enumerate() {
541 let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
542
543 if hook.name.trim().is_empty() {
544 return Err(ConfigError::HookValidation {
545 field: format!("{hook_field_base}.name"),
546 message: "is required and must be non-empty".to_string(),
547 });
548 }
549
550 if hook
551 .command
552 .first()
553 .is_none_or(|command| command.trim().is_empty())
554 {
555 return Err(ConfigError::HookValidation {
556 field: format!("{hook_field_base}.command"),
557 message: "is required and must include an executable at command[0]"
558 .to_string(),
559 });
560 }
561
562 if hook.on_error.is_none() {
563 return Err(ConfigError::HookValidation {
564 field: format!("{hook_field_base}.on_error"),
565 message: "is required in v1 (warn | block | suspend)".to_string(),
566 });
567 }
568
569 if let Some(timeout_seconds) = hook.timeout_seconds
570 && timeout_seconds == 0
571 {
572 return Err(ConfigError::HookValidation {
573 field: format!("{hook_field_base}.timeout_seconds"),
574 message: "must be greater than 0 when specified".to_string(),
575 });
576 }
577
578 if let Some(max_output_bytes) = hook.max_output_bytes
579 && max_output_bytes == 0
580 {
581 return Err(ConfigError::HookValidation {
582 field: format!("{hook_field_base}.max_output_bytes"),
583 message: "must be greater than 0 when specified".to_string(),
584 });
585 }
586
587 if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
588 return Err(ConfigError::HookValidation {
589 field: format!("{hook_field_base}.suspend_mode"),
590 message: "requires on_error: suspend".to_string(),
591 });
592 }
593
594 Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
595 Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
596 }
597 }
598
599 Ok(())
600 }
601
602 fn validate_non_v1_hook_fields(
603 path_prefix: &str,
604 fields: &HashMap<String, serde_yaml::Value>,
605 ) -> Result<(), ConfigError> {
606 for key in fields.keys() {
607 let field = format!("{path_prefix}.{key}");
608 match key.as_str() {
609 "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
610 return Err(ConfigError::UnsupportedHookField {
611 field,
612 reason: "Global hooks are out of scope for v1; use per-project hooks only"
613 .to_string(),
614 });
615 }
616 "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
617 return Err(ConfigError::UnsupportedHookField {
618 field,
619 reason:
620 "Parallel hook execution is out of scope for v1; hooks must run sequentially"
621 .to_string(),
622 });
623 }
624 _ => {}
625 }
626 }
627
628 Ok(())
629 }
630
631 fn validate_mutation_contract(
632 hook_field_base: &str,
633 mutate: &HookMutationConfig,
634 ) -> Result<(), ConfigError> {
635 let mutate_field_base = format!("{hook_field_base}.mutate");
636
637 if !mutate.enabled {
638 if mutate.format.is_some() || !mutate.extra.is_empty() {
639 return Err(ConfigError::HookValidation {
640 field: mutate_field_base,
641 message: "mutation settings require mutate.enabled: true".to_string(),
642 });
643 }
644 return Ok(());
645 }
646
647 if let Some(format) = mutate.format.as_deref()
648 && !format.eq_ignore_ascii_case("json")
649 {
650 return Err(ConfigError::HookValidation {
651 field: format!("{mutate_field_base}.format"),
652 message: "only 'json' is supported for v1 mutation payloads".to_string(),
653 });
654 }
655
656 if let Some(key) = mutate.extra.keys().next() {
657 let field = format!("{mutate_field_base}.{key}");
658 let reason = match key.as_str() {
659 "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
660 "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
661 .to_string()
662 }
663 "xml" => "v1 mutation payloads are JSON-only".to_string(),
664 _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
665 };
666
667 return Err(ConfigError::UnsupportedHookField { field, reason });
668 }
669
670 Ok(())
671 }
672
673 pub fn effective_backend(&self) -> &str {
675 &self.cli.backend
676 }
677
678 pub fn get_agent_priority(&self) -> Vec<&str> {
681 if self.agent_priority.is_empty() {
682 vec!["claude", "kiro", "gemini", "codex", "amp"]
683 } else {
684 self.agent_priority.iter().map(String::as_str).collect()
685 }
686 }
687
688 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
691 match backend {
692 "claude" => &self.adapters.claude,
693 "gemini" => &self.adapters.gemini,
694 "kiro" => &self.adapters.kiro,
695 "codex" => &self.adapters.codex,
696 "amp" => &self.adapters.amp,
697 _ => &self.adapters.claude, }
699 }
700}
701
702#[derive(Debug, Clone)]
704pub enum ConfigWarning {
705 DeferredFeature { field: String, message: String },
707 DroppedField { field: String, reason: String },
709 InvalidValue { field: String, message: String },
711}
712
713impl std::fmt::Display for ConfigWarning {
714 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716 match self {
717 ConfigWarning::DeferredFeature { field, message }
718 | ConfigWarning::InvalidValue { field, message } => {
719 write!(f, "Warning [{field}]: {message}")
720 }
721 ConfigWarning::DroppedField { field, reason } => {
722 write!(f, "Warning [{field}]: Field ignored - {reason}")
723 }
724 }
725 }
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct EventLoopConfig {
731 pub prompt: Option<String>,
733
734 #[serde(default = "default_prompt_file")]
736 pub prompt_file: String,
737
738 #[serde(default = "default_completion_promise")]
740 pub completion_promise: String,
741
742 #[serde(default = "default_max_iterations")]
744 pub max_iterations: u32,
745
746 #[serde(default = "default_max_runtime")]
748 pub max_runtime_seconds: u64,
749
750 pub max_cost_usd: Option<f64>,
752
753 #[serde(default = "default_max_failures")]
755 pub max_consecutive_failures: u32,
756
757 #[serde(default)]
760 pub cooldown_delay_seconds: u64,
761
762 pub starting_hat: Option<String>,
764
765 pub starting_event: Option<String>,
775
776 #[serde(default)]
780 pub mutation_score_warn_threshold: Option<f64>,
781
782 #[serde(default)]
789 pub persistent: bool,
790
791 #[serde(default)]
795 pub required_events: Vec<String>,
796
797 #[serde(default)]
801 pub cancellation_promise: String,
802
803 #[serde(default)]
807 pub enforce_hat_scope: bool,
808}
809
810fn default_prompt_file() -> String {
811 "PROMPT.md".to_string()
812}
813
814fn default_completion_promise() -> String {
815 "LOOP_COMPLETE".to_string()
816}
817
818fn default_max_iterations() -> u32 {
819 100
820}
821
822fn default_max_runtime() -> u64 {
823 14400 }
825
826fn default_max_failures() -> u32 {
827 5
828}
829
830impl Default for EventLoopConfig {
831 fn default() -> Self {
832 Self {
833 prompt: None,
834 prompt_file: default_prompt_file(),
835 completion_promise: default_completion_promise(),
836 max_iterations: default_max_iterations(),
837 max_runtime_seconds: default_max_runtime(),
838 max_cost_usd: None,
839 max_consecutive_failures: default_max_failures(),
840 cooldown_delay_seconds: 0,
841 starting_hat: None,
842 starting_event: None,
843 mutation_score_warn_threshold: None,
844 persistent: false,
845 required_events: Vec::new(),
846 cancellation_promise: String::new(),
847 enforce_hat_scope: false,
848 }
849 }
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct CoreConfig {
857 #[serde(default = "default_scratchpad")]
859 pub scratchpad: String,
860
861 #[serde(default = "default_specs_dir")]
863 pub specs_dir: String,
864
865 #[serde(default = "default_guardrails")]
869 pub guardrails: Vec<String>,
870
871 #[serde(skip)]
878 pub workspace_root: std::path::PathBuf,
879}
880
881fn default_scratchpad() -> String {
882 ".ralph/agent/scratchpad.md".to_string()
883}
884
885fn default_specs_dir() -> String {
886 ".ralph/specs/".to_string()
887}
888
889fn default_guardrails() -> Vec<String> {
890 vec![
891 "Fresh context each iteration - scratchpad is memory".to_string(),
892 "Don't assume 'not implemented' - search first".to_string(),
893 "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
894 "When behavior is runnable or user-facing, exercise the real app with the strongest available harness (Playwright, tmux, real CLI/API) and try at least one adversarial path before reporting done".to_string(),
895 "Confidence protocol: score decisions 0-100. >80 proceed autonomously; 50-80 proceed + document in .ralph/agent/decisions.md; <50 choose safe default + document".to_string(),
896 "Commit atomically - one logical change per commit, capture the why".to_string(),
897 ]
898}
899
900impl Default for CoreConfig {
901 fn default() -> Self {
902 Self {
903 scratchpad: default_scratchpad(),
904 specs_dir: default_specs_dir(),
905 guardrails: default_guardrails(),
906 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
907 .map(std::path::PathBuf::from)
908 .unwrap_or_else(|_| {
909 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
910 }),
911 }
912 }
913}
914
915impl CoreConfig {
916 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
920 self.workspace_root = root.into();
921 self
922 }
923
924 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
929 let path = std::path::Path::new(relative);
930 if path.is_absolute() {
931 path.to_path_buf()
932 } else {
933 self.workspace_root.join(path)
934 }
935 }
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct CliConfig {
941 #[serde(default = "default_backend")]
943 pub backend: String,
944
945 pub command: Option<String>,
948
949 #[serde(default = "default_prompt_mode")]
951 pub prompt_mode: String,
952
953 #[serde(default = "default_mode")]
956 pub default_mode: String,
957
958 #[serde(default = "default_idle_timeout")]
962 pub idle_timeout_secs: u32,
963
964 #[serde(default)]
967 pub args: Vec<String>,
968
969 #[serde(default)]
972 pub prompt_flag: Option<String>,
973}
974
975fn default_backend() -> String {
976 "claude".to_string()
977}
978
979fn default_prompt_mode() -> String {
980 "arg".to_string()
981}
982
983fn default_mode() -> String {
984 "autonomous".to_string()
985}
986
987fn default_idle_timeout() -> u32 {
988 30 }
990
991impl Default for CliConfig {
992 fn default() -> Self {
993 Self {
994 backend: default_backend(),
995 command: None,
996 prompt_mode: default_prompt_mode(),
997 default_mode: default_mode(),
998 idle_timeout_secs: default_idle_timeout(),
999 args: Vec::new(),
1000 prompt_flag: None,
1001 }
1002 }
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize)]
1007pub struct TuiConfig {
1008 #[serde(default = "default_prefix_key")]
1010 pub prefix_key: String,
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1017#[serde(rename_all = "lowercase")]
1018pub enum InjectMode {
1019 #[default]
1021 Auto,
1022 Manual,
1024 None,
1026}
1027
1028impl std::fmt::Display for InjectMode {
1029 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030 match self {
1031 Self::Auto => write!(f, "auto"),
1032 Self::Manual => write!(f, "manual"),
1033 Self::None => write!(f, "none"),
1034 }
1035 }
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1054pub struct MemoriesConfig {
1055 #[serde(default)]
1059 pub enabled: bool,
1060
1061 #[serde(default)]
1063 pub inject: InjectMode,
1064
1065 #[serde(default)]
1069 pub budget: usize,
1070
1071 #[serde(default)]
1073 pub filter: MemoriesFilter,
1074}
1075
1076impl Default for MemoriesConfig {
1077 fn default() -> Self {
1078 Self {
1079 enabled: true, inject: InjectMode::Auto,
1081 budget: 0,
1082 filter: MemoriesFilter::default(),
1083 }
1084 }
1085}
1086
1087#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1091pub struct MemoriesFilter {
1092 #[serde(default)]
1094 pub types: Vec<String>,
1095
1096 #[serde(default)]
1098 pub tags: Vec<String>,
1099
1100 #[serde(default)]
1102 pub recent: u32,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1118pub struct TasksConfig {
1119 #[serde(default = "default_true")]
1123 pub enabled: bool,
1124}
1125
1126impl Default for TasksConfig {
1127 fn default() -> Self {
1128 Self {
1129 enabled: true, }
1131 }
1132}
1133
1134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1154pub struct HooksConfig {
1155 #[serde(default)]
1157 pub enabled: bool,
1158
1159 #[serde(default)]
1161 pub defaults: HookDefaults,
1162
1163 #[serde(default)]
1165 pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1166
1167 #[serde(default, flatten)]
1169 pub extra: HashMap<String, serde_yaml::Value>,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct HookDefaults {
1175 #[serde(default = "default_hook_timeout_seconds")]
1177 pub timeout_seconds: u64,
1178
1179 #[serde(default = "default_hook_max_output_bytes")]
1181 pub max_output_bytes: u64,
1182
1183 #[serde(default)]
1185 pub suspend_mode: HookSuspendMode,
1186}
1187
1188fn default_hook_timeout_seconds() -> u64 {
1189 30
1190}
1191
1192fn default_hook_max_output_bytes() -> u64 {
1193 8192
1194}
1195
1196impl Default for HookDefaults {
1197 fn default() -> Self {
1198 Self {
1199 timeout_seconds: default_hook_timeout_seconds(),
1200 max_output_bytes: default_hook_max_output_bytes(),
1201 suspend_mode: HookSuspendMode::default(),
1202 }
1203 }
1204}
1205
1206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1208pub enum HookPhaseEvent {
1209 #[serde(rename = "pre.loop.start")]
1210 PreLoopStart,
1211 #[serde(rename = "post.loop.start")]
1212 PostLoopStart,
1213 #[serde(rename = "pre.iteration.start")]
1214 PreIterationStart,
1215 #[serde(rename = "post.iteration.start")]
1216 PostIterationStart,
1217 #[serde(rename = "pre.plan.created")]
1218 PrePlanCreated,
1219 #[serde(rename = "post.plan.created")]
1220 PostPlanCreated,
1221 #[serde(rename = "pre.human.interact")]
1222 PreHumanInteract,
1223 #[serde(rename = "post.human.interact")]
1224 PostHumanInteract,
1225 #[serde(rename = "pre.loop.complete")]
1226 PreLoopComplete,
1227 #[serde(rename = "post.loop.complete")]
1228 PostLoopComplete,
1229 #[serde(rename = "pre.loop.error")]
1230 PreLoopError,
1231 #[serde(rename = "post.loop.error")]
1232 PostLoopError,
1233}
1234
1235impl HookPhaseEvent {
1236 pub fn as_str(self) -> &'static str {
1238 match self {
1239 Self::PreLoopStart => "pre.loop.start",
1240 Self::PostLoopStart => "post.loop.start",
1241 Self::PreIterationStart => "pre.iteration.start",
1242 Self::PostIterationStart => "post.iteration.start",
1243 Self::PrePlanCreated => "pre.plan.created",
1244 Self::PostPlanCreated => "post.plan.created",
1245 Self::PreHumanInteract => "pre.human.interact",
1246 Self::PostHumanInteract => "post.human.interact",
1247 Self::PreLoopComplete => "pre.loop.complete",
1248 Self::PostLoopComplete => "post.loop.complete",
1249 Self::PreLoopError => "pre.loop.error",
1250 Self::PostLoopError => "post.loop.error",
1251 }
1252 }
1253
1254 pub fn parse(value: &str) -> Option<Self> {
1256 match value {
1257 "pre.loop.start" => Some(Self::PreLoopStart),
1258 "post.loop.start" => Some(Self::PostLoopStart),
1259 "pre.iteration.start" => Some(Self::PreIterationStart),
1260 "post.iteration.start" => Some(Self::PostIterationStart),
1261 "pre.plan.created" => Some(Self::PrePlanCreated),
1262 "post.plan.created" => Some(Self::PostPlanCreated),
1263 "pre.human.interact" => Some(Self::PreHumanInteract),
1264 "post.human.interact" => Some(Self::PostHumanInteract),
1265 "pre.loop.complete" => Some(Self::PreLoopComplete),
1266 "post.loop.complete" => Some(Self::PostLoopComplete),
1267 "pre.loop.error" => Some(Self::PreLoopError),
1268 "post.loop.error" => Some(Self::PostLoopError),
1269 _ => None,
1270 }
1271 }
1272}
1273
1274impl std::fmt::Display for HookPhaseEvent {
1275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1276 f.write_str((*self).as_str())
1277 }
1278}
1279
1280fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1281 let Some(root) = value.as_mapping() else {
1282 return Ok(());
1283 };
1284
1285 let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1286 return Ok(());
1287 };
1288
1289 let Some(hooks_map) = hooks.as_mapping() else {
1290 return Ok(());
1291 };
1292
1293 let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1294 return Ok(());
1295 };
1296
1297 let Some(events_map) = events.as_mapping() else {
1298 return Ok(());
1299 };
1300
1301 for key in events_map.keys() {
1302 if let Some(phase_event) = key.as_str()
1303 && HookPhaseEvent::parse(phase_event).is_none()
1304 {
1305 return Err(ConfigError::InvalidHookPhaseEvent {
1306 phase_event: phase_event.to_string(),
1307 });
1308 }
1309 }
1310
1311 Ok(())
1312}
1313
1314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1316#[serde(rename_all = "snake_case")]
1317pub enum HookOnError {
1318 Warn,
1320 Block,
1322 Suspend,
1324}
1325
1326#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1328#[serde(rename_all = "snake_case")]
1329pub enum HookSuspendMode {
1330 #[default]
1332 WaitForResume,
1333 RetryBackoff,
1335 WaitThenRetry,
1337}
1338
1339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1341pub struct HookMutationConfig {
1342 #[serde(default)]
1344 pub enabled: bool,
1345
1346 #[serde(default)]
1348 pub format: Option<String>,
1349
1350 #[serde(default, flatten)]
1352 pub extra: HashMap<String, serde_yaml::Value>,
1353}
1354
1355#[derive(Debug, Clone, Serialize, Deserialize)]
1357pub struct HookSpec {
1358 #[serde(default)]
1360 pub name: String,
1361
1362 #[serde(default)]
1364 pub command: Vec<String>,
1365
1366 #[serde(default)]
1368 pub cwd: Option<PathBuf>,
1369
1370 #[serde(default)]
1372 pub env: HashMap<String, String>,
1373
1374 #[serde(default)]
1376 pub timeout_seconds: Option<u64>,
1377
1378 #[serde(default)]
1380 pub max_output_bytes: Option<u64>,
1381
1382 #[serde(default)]
1384 pub on_error: Option<HookOnError>,
1385
1386 #[serde(default)]
1388 pub suspend_mode: Option<HookSuspendMode>,
1389
1390 #[serde(default)]
1392 pub mutate: HookMutationConfig,
1393
1394 #[serde(default, flatten)]
1396 pub extra: HashMap<String, serde_yaml::Value>,
1397}
1398
1399#[derive(Debug, Clone, Serialize, Deserialize)]
1422pub struct SkillsConfig {
1423 #[serde(default = "default_true")]
1425 pub enabled: bool,
1426
1427 #[serde(default)]
1430 pub dirs: Vec<PathBuf>,
1431
1432 #[serde(default)]
1434 pub overrides: HashMap<String, SkillOverride>,
1435}
1436
1437impl Default for SkillsConfig {
1438 fn default() -> Self {
1439 Self {
1440 enabled: true, dirs: vec![],
1442 overrides: HashMap::new(),
1443 }
1444 }
1445}
1446
1447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1452pub struct SkillOverride {
1453 #[serde(default)]
1455 pub enabled: Option<bool>,
1456
1457 #[serde(default)]
1459 pub hats: Vec<String>,
1460
1461 #[serde(default)]
1463 pub backends: Vec<String>,
1464
1465 #[serde(default)]
1467 pub tags: Vec<String>,
1468
1469 #[serde(default)]
1471 pub auto_inject: Option<bool>,
1472}
1473
1474#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1476pub struct PreflightConfig {
1477 #[serde(default)]
1479 pub enabled: bool,
1480
1481 #[serde(default)]
1483 pub strict: bool,
1484
1485 #[serde(default)]
1487 pub skip: Vec<String>,
1488}
1489
1490#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct FeaturesConfig {
1507 #[serde(default = "default_true")]
1512 pub parallel: bool,
1513
1514 #[serde(default)]
1520 pub auto_merge: bool,
1521
1522 #[serde(default)]
1528 pub loop_naming: crate::loop_name::LoopNamingConfig,
1529
1530 #[serde(default)]
1532 pub preflight: PreflightConfig,
1533}
1534
1535impl Default for FeaturesConfig {
1536 fn default() -> Self {
1537 Self {
1538 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1541 preflight: PreflightConfig::default(),
1542 }
1543 }
1544}
1545
1546fn default_prefix_key() -> String {
1547 "ctrl-a".to_string()
1548}
1549
1550impl Default for TuiConfig {
1551 fn default() -> Self {
1552 Self {
1553 prefix_key: default_prefix_key(),
1554 }
1555 }
1556}
1557
1558impl TuiConfig {
1559 pub fn parse_prefix(
1562 &self,
1563 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1564 use crossterm::event::{KeyCode, KeyModifiers};
1565
1566 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1567 if parts.len() != 2 {
1568 return Err(format!(
1569 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1570 self.prefix_key
1571 ));
1572 }
1573
1574 let modifier = match parts[0].to_lowercase().as_str() {
1575 "ctrl" => KeyModifiers::CONTROL,
1576 _ => {
1577 return Err(format!(
1578 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1579 parts[0]
1580 ));
1581 }
1582 };
1583
1584 let key_str = parts[1];
1585 if key_str.len() != 1 {
1586 return Err(format!(
1587 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1588 key_str
1589 ));
1590 }
1591
1592 let key_char = key_str.chars().next().unwrap();
1593 let key_code = KeyCode::Char(key_char);
1594
1595 Ok((key_code, modifier))
1596 }
1597}
1598
1599#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1614pub struct EventMetadata {
1615 #[serde(default)]
1617 pub description: String,
1618
1619 #[serde(default)]
1622 pub on_trigger: String,
1623
1624 #[serde(default)]
1627 pub on_publish: String,
1628}
1629
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1632#[serde(untagged)]
1633pub enum HatBackend {
1634 KiroAgent {
1637 #[serde(rename = "type")]
1638 backend_type: String,
1639 agent: String,
1640 #[serde(default)]
1641 args: Vec<String>,
1642 },
1643 NamedWithArgs {
1645 #[serde(rename = "type")]
1646 backend_type: String,
1647 #[serde(default)]
1648 args: Vec<String>,
1649 },
1650 Named(String),
1652 Custom {
1654 command: String,
1655 #[serde(default)]
1656 args: Vec<String>,
1657 },
1658}
1659
1660impl HatBackend {
1661 pub fn to_cli_backend(&self) -> String {
1663 match self {
1664 HatBackend::Named(name) => name.clone(),
1665 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1666 HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1667 HatBackend::Custom { .. } => "custom".to_string(),
1668 }
1669 }
1670}
1671
1672#[derive(Debug, Clone, Serialize, Deserialize)]
1674pub struct HatConfig {
1675 pub name: String,
1677
1678 pub description: Option<String>,
1681
1682 #[serde(default)]
1685 pub triggers: Vec<String>,
1686
1687 #[serde(default)]
1689 pub publishes: Vec<String>,
1690
1691 #[serde(default)]
1693 pub instructions: String,
1694
1695 #[serde(default)]
1712 pub extra_instructions: Vec<String>,
1713
1714 #[serde(default)]
1716 pub backend: Option<HatBackend>,
1717
1718 #[serde(default, alias = "args")]
1722 pub backend_args: Option<Vec<String>>,
1723
1724 #[serde(default)]
1726 pub default_publishes: Option<String>,
1727
1728 pub max_activations: Option<u32>,
1733
1734 #[serde(default)]
1740 pub disallowed_tools: Vec<String>,
1741
1742 #[serde(default)]
1747 pub timeout: Option<u32>,
1748
1749 #[serde(default = "default_concurrency")]
1754 pub concurrency: u32,
1755
1756 #[serde(default)]
1762 pub aggregate: Option<AggregateConfig>,
1763}
1764
1765fn default_concurrency() -> u32 {
1766 1
1767}
1768
1769#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct AggregateConfig {
1772 pub mode: AggregateMode,
1774
1775 pub timeout: u32,
1778}
1779
1780#[derive(Debug, Clone, Serialize, Deserialize)]
1782#[serde(rename_all = "snake_case")]
1783pub enum AggregateMode {
1784 WaitForAll,
1786}
1787
1788impl HatConfig {
1789 pub fn trigger_topics(&self) -> Vec<Topic> {
1791 self.triggers.iter().map(|s| Topic::new(s)).collect()
1792 }
1793
1794 pub fn publish_topics(&self) -> Vec<Topic> {
1796 self.publishes.iter().map(|s| Topic::new(s)).collect()
1797 }
1798}
1799
1800#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1817pub struct RobotConfig {
1818 #[serde(default)]
1820 pub enabled: bool,
1821
1822 pub timeout_seconds: Option<u64>,
1825
1826 pub checkin_interval_seconds: Option<u64>,
1830
1831 #[serde(default)]
1833 pub telegram: Option<TelegramBotConfig>,
1834}
1835
1836impl RobotConfig {
1837 pub fn validate(&self) -> Result<(), ConfigError> {
1839 if !self.enabled {
1840 return Ok(());
1841 }
1842
1843 if self.timeout_seconds.is_none() {
1844 return Err(ConfigError::RobotMissingField {
1845 field: "RObot.timeout_seconds".to_string(),
1846 hint: "timeout_seconds is required when RObot is enabled".to_string(),
1847 });
1848 }
1849
1850 if self.resolve_bot_token().is_none() {
1852 return Err(ConfigError::RobotMissingField {
1853 field: "RObot.telegram.bot_token".to_string(),
1854 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1855 .to_string(),
1856 });
1857 }
1858
1859 Ok(())
1860 }
1861
1862 pub fn resolve_bot_token(&self) -> Option<String> {
1869 let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
1871 let config_token = self
1872 .telegram
1873 .as_ref()
1874 .and_then(|telegram| telegram.bot_token.clone());
1875
1876 if cfg!(test) {
1877 return env_token.or(config_token);
1878 }
1879
1880 env_token
1881 .or(config_token)
1883 .or_else(|| {
1885 std::panic::catch_unwind(|| {
1886 keyring::Entry::new("ralph", "telegram-bot-token")
1887 .ok()
1888 .and_then(|e| e.get_password().ok())
1889 })
1890 .ok()
1891 .flatten()
1892 })
1893 }
1894
1895 pub fn resolve_api_url(&self) -> Option<String> {
1901 std::env::var("RALPH_TELEGRAM_API_URL").ok().or_else(|| {
1902 self.telegram
1903 .as_ref()
1904 .and_then(|telegram| telegram.api_url.clone())
1905 })
1906 }
1907}
1908
1909#[derive(Debug, Clone, Serialize, Deserialize)]
1911pub struct TelegramBotConfig {
1912 pub bot_token: Option<String>,
1914
1915 pub api_url: Option<String>,
1920}
1921
1922#[derive(Debug, thiserror::Error)]
1924pub enum ConfigError {
1925 #[error("IO error: {0}")]
1926 Io(#[from] std::io::Error),
1927
1928 #[error("YAML parse error: {0}")]
1929 Yaml(#[from] serde_yaml::Error),
1930
1931 #[error(
1932 "Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'.\nFix: ensure only one hat claims this trigger or delegate with a new event.\nSee: docs/reference/troubleshooting.md#ambiguous-routing"
1933 )]
1934 AmbiguousRouting {
1935 trigger: String,
1936 hat1: String,
1937 hat2: String,
1938 },
1939
1940 #[error(
1941 "Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified.\nFix: remove one field or split into separate configs.\nSee: docs/reference/troubleshooting.md#mutually-exclusive-fields"
1942 )]
1943 MutuallyExclusive { field1: String, field2: String },
1944
1945 #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
1946 InvalidCompletionPromise,
1947
1948 #[error(
1949 "Custom backend requires a command.\nFix: set 'cli.command' in your config (or run `ralph init --backend custom`).\nSee: docs/reference/troubleshooting.md#custom-backend-command"
1950 )]
1951 CustomBackendRequiresCommand,
1952
1953 #[error(
1954 "Reserved trigger '{trigger}' used by hat '{hat}' - task.start and task.resume are reserved for Ralph (the coordinator). Use a delegated event like 'work.start' instead.\nSee: docs/reference/troubleshooting.md#reserved-trigger"
1955 )]
1956 ReservedTrigger { trigger: String, hat: String },
1957
1958 #[error(
1959 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
1960 )]
1961 MissingDescription { hat: String },
1962
1963 #[error(
1964 "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
1965 )]
1966 RobotMissingField { field: String, hint: String },
1967
1968 #[error(
1969 "Invalid hooks phase-event '{phase_event}'. Supported v1 phase-events: pre.loop.start, post.loop.start, pre.iteration.start, post.iteration.start, pre.plan.created, post.plan.created, pre.human.interact, post.human.interact, pre.loop.complete, post.loop.complete, pre.loop.error, post.loop.error.\nFix: use one of the supported keys under hooks.events."
1970 )]
1971 InvalidHookPhaseEvent { phase_event: String },
1972
1973 #[error(
1974 "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
1975 )]
1976 HookValidation { field: String, message: String },
1977
1978 #[error(
1979 "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
1980 )]
1981 UnsupportedHookField { field: String, reason: String },
1982
1983 #[error(
1984 "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
1985 )]
1986 DeprecatedProjectKey,
1987
1988 #[error(
1989 "Hat '{hat}' has invalid concurrency: {value}. Must be >= 1.\nFix: set 'concurrency' to 1 or higher."
1990 )]
1991 InvalidConcurrency { hat: String, value: u32 },
1992
1993 #[error(
1994 "Hat '{hat}' has both 'aggregate' and 'concurrency > 1'. An aggregator hat cannot also be a concurrent worker.\nFix: remove 'aggregate' or set 'concurrency' to 1."
1995 )]
1996 AggregateOnConcurrentHat { hat: String },
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001 use super::*;
2002
2003 #[test]
2004 fn test_default_config() {
2005 let config = RalphConfig::default();
2006 assert!(config.hats.is_empty());
2008 assert_eq!(config.event_loop.max_iterations, 100);
2009 assert!(!config.verbose);
2010 assert!(!config.features.preflight.enabled);
2011 assert!(!config.features.preflight.strict);
2012 assert!(config.features.preflight.skip.is_empty());
2013 }
2014
2015 #[test]
2016 fn test_parse_yaml_with_custom_hats() {
2017 let yaml = r#"
2018event_loop:
2019 prompt_file: "TASK.md"
2020 completion_promise: "DONE"
2021 max_iterations: 50
2022cli:
2023 backend: "claude"
2024hats:
2025 implementer:
2026 name: "Implementer"
2027 triggers: ["task.*", "review.done"]
2028 publishes: ["impl.done"]
2029 instructions: "You are the implementation agent."
2030"#;
2031 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2032 assert_eq!(config.hats.len(), 1);
2034 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2035
2036 let hat = config.hats.get("implementer").unwrap();
2037 assert_eq!(hat.triggers.len(), 2);
2038 }
2039
2040 #[test]
2041 fn test_preflight_config_deserialize() {
2042 let yaml = r#"
2043features:
2044 preflight:
2045 enabled: true
2046 strict: true
2047 skip: ["telegram", "git"]
2048"#;
2049 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2050 assert!(config.features.preflight.enabled);
2051 assert!(config.features.preflight.strict);
2052 assert_eq!(
2053 config.features.preflight.skip,
2054 vec!["telegram".to_string(), "git".to_string()]
2055 );
2056 }
2057
2058 #[test]
2059 fn test_parse_yaml_v1_format() {
2060 let yaml = r#"
2062agent: gemini
2063prompt_file: "TASK.md"
2064completion_promise: "RALPH_DONE"
2065max_iterations: 75
2066max_runtime: 7200
2067max_cost: 10.0
2068verbose: true
2069"#;
2070 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2071
2072 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
2078
2079 assert_eq!(config.cli.backend, "gemini");
2081 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2082 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
2083 assert_eq!(config.event_loop.max_iterations, 75);
2084 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
2085 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
2086 assert!(config.verbose);
2087 }
2088
2089 #[test]
2090 fn test_agent_priority() {
2091 let yaml = r"
2092agent: auto
2093agent_priority: [gemini, claude, codex]
2094";
2095 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2096 let priority = config.get_agent_priority();
2097 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2098 }
2099
2100 #[test]
2101 fn test_default_agent_priority() {
2102 let config = RalphConfig::default();
2103 let priority = config.get_agent_priority();
2104 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
2105 }
2106
2107 #[test]
2108 fn test_validate_deferred_features() {
2109 let yaml = r"
2110archive_prompts: true
2111enable_metrics: true
2112";
2113 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2114 let warnings = config.validate().unwrap();
2115
2116 assert_eq!(warnings.len(), 2);
2117 assert!(warnings
2118 .iter()
2119 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2120 assert!(warnings
2121 .iter()
2122 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2123 }
2124
2125 #[test]
2126 fn test_validate_dropped_fields() {
2127 let yaml = r#"
2128max_tokens: 4096
2129retry_delay: 5
2130adapters:
2131 claude:
2132 tool_permissions: ["read", "write"]
2133"#;
2134 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2135 let warnings = config.validate().unwrap();
2136
2137 assert_eq!(warnings.len(), 3);
2138 assert!(warnings.iter().any(
2139 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2140 ));
2141 assert!(warnings.iter().any(
2142 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2143 ));
2144 assert!(warnings
2145 .iter()
2146 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2147 }
2148
2149 #[test]
2150 fn test_suppress_warnings() {
2151 let yaml = r"
2152_suppress_warnings: true
2153archive_prompts: true
2154max_tokens: 4096
2155";
2156 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2157 let warnings = config.validate().unwrap();
2158
2159 assert!(warnings.is_empty());
2161 }
2162
2163 #[test]
2164 fn test_adapter_settings() {
2165 let yaml = r"
2166adapters:
2167 claude:
2168 timeout: 600
2169 enabled: true
2170 gemini:
2171 timeout: 300
2172 enabled: false
2173";
2174 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2175
2176 let claude = config.adapter_settings("claude");
2177 assert_eq!(claude.timeout, 600);
2178 assert!(claude.enabled);
2179
2180 let gemini = config.adapter_settings("gemini");
2181 assert_eq!(gemini.timeout, 300);
2182 assert!(!gemini.enabled);
2183 }
2184
2185 #[test]
2186 fn test_unknown_fields_ignored() {
2187 let yaml = r#"
2189agent: claude
2190unknown_field: "some value"
2191future_feature: true
2192"#;
2193 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2194 assert!(result.is_ok());
2196 }
2197
2198 #[test]
2199 fn test_custom_backend_args_shorthand() {
2200 let yaml = r#"
2201hats:
2202 opencode_builder:
2203 name: "Opencode"
2204 description: "Opencode hat"
2205 backend: "opencode"
2206 args: ["-m", "model"]
2207"#;
2208 let config = RalphConfig::parse_yaml(yaml).unwrap();
2209 let hat = config.hats.get("opencode_builder").unwrap();
2210 assert!(hat.backend_args.is_some());
2211 assert_eq!(
2212 hat.backend_args.as_ref().unwrap(),
2213 &vec!["-m".to_string(), "model".to_string()]
2214 );
2215 }
2216
2217 #[test]
2218 fn test_custom_backend_args_explicit_key() {
2219 let yaml = r#"
2220hats:
2221 opencode_builder:
2222 name: "Opencode"
2223 description: "Opencode hat"
2224 backend: "opencode"
2225 backend_args: ["-m", "model"]
2226"#;
2227 let config = RalphConfig::parse_yaml(yaml).unwrap();
2228 let hat = config.hats.get("opencode_builder").unwrap();
2229 assert!(hat.backend_args.is_some());
2230 assert_eq!(
2231 hat.backend_args.as_ref().unwrap(),
2232 &vec!["-m".to_string(), "model".to_string()]
2233 );
2234 }
2235
2236 #[test]
2237 fn test_project_key_rejected() {
2238 let yaml = r#"
2239project:
2240 specs_dir: "my_specs"
2241"#;
2242 let result = RalphConfig::parse_yaml(yaml);
2243 assert!(result.is_err());
2244 assert!(matches!(
2245 result.unwrap_err(),
2246 ConfigError::DeprecatedProjectKey
2247 ));
2248 }
2249
2250 #[test]
2251 fn test_ambiguous_routing_rejected() {
2252 let yaml = r#"
2255hats:
2256 planner:
2257 name: "Planner"
2258 description: "Plans tasks"
2259 triggers: ["planning.start", "build.done"]
2260 builder:
2261 name: "Builder"
2262 description: "Builds code"
2263 triggers: ["build.task", "build.done"]
2264"#;
2265 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2266 let result = config.validate();
2267
2268 assert!(result.is_err());
2269 let err = result.unwrap_err();
2270 assert!(
2271 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2272 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2273 err
2274 );
2275 }
2276
2277 #[test]
2278 fn test_unique_triggers_accepted() {
2279 let yaml = r#"
2282hats:
2283 planner:
2284 name: "Planner"
2285 description: "Plans tasks"
2286 triggers: ["planning.start", "build.done", "build.blocked"]
2287 builder:
2288 name: "Builder"
2289 description: "Builds code"
2290 triggers: ["build.task"]
2291"#;
2292 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2293 let result = config.validate();
2294
2295 assert!(
2296 result.is_ok(),
2297 "Expected valid config, got: {:?}",
2298 result.unwrap_err()
2299 );
2300 }
2301
2302 #[test]
2303 fn test_reserved_trigger_task_start_rejected() {
2304 let yaml = r#"
2306hats:
2307 my_hat:
2308 name: "My Hat"
2309 description: "Test hat"
2310 triggers: ["task.start"]
2311"#;
2312 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2313 let result = config.validate();
2314
2315 assert!(result.is_err());
2316 let err = result.unwrap_err();
2317 assert!(
2318 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2319 if trigger == "task.start" && hat == "my_hat"),
2320 "Expected ReservedTrigger error for 'task.start', got: {:?}",
2321 err
2322 );
2323 }
2324
2325 #[test]
2326 fn test_reserved_trigger_task_resume_rejected() {
2327 let yaml = r#"
2329hats:
2330 my_hat:
2331 name: "My Hat"
2332 description: "Test hat"
2333 triggers: ["task.resume", "other.event"]
2334"#;
2335 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2336 let result = config.validate();
2337
2338 assert!(result.is_err());
2339 let err = result.unwrap_err();
2340 assert!(
2341 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2342 if trigger == "task.resume" && hat == "my_hat"),
2343 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2344 err
2345 );
2346 }
2347
2348 #[test]
2349 fn test_missing_description_rejected() {
2350 let yaml = r#"
2352hats:
2353 my_hat:
2354 name: "My Hat"
2355 triggers: ["build.task"]
2356"#;
2357 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2358 let result = config.validate();
2359
2360 assert!(result.is_err());
2361 let err = result.unwrap_err();
2362 assert!(
2363 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2364 "Expected MissingDescription error, got: {:?}",
2365 err
2366 );
2367 }
2368
2369 #[test]
2370 fn test_empty_description_rejected() {
2371 let yaml = r#"
2373hats:
2374 my_hat:
2375 name: "My Hat"
2376 description: " "
2377 triggers: ["build.task"]
2378"#;
2379 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2380 let result = config.validate();
2381
2382 assert!(result.is_err());
2383 let err = result.unwrap_err();
2384 assert!(
2385 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2386 "Expected MissingDescription error for empty description, got: {:?}",
2387 err
2388 );
2389 }
2390
2391 #[test]
2392 fn test_core_config_defaults() {
2393 let config = RalphConfig::default();
2394 assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
2395 assert_eq!(config.core.specs_dir, ".ralph/specs/");
2396 assert_eq!(config.core.guardrails.len(), 6);
2398 assert!(config.core.guardrails[0].contains("Fresh context"));
2399 assert!(config.core.guardrails[1].contains("search first"));
2400 assert!(config.core.guardrails[2].contains("Backpressure"));
2401 assert!(config.core.guardrails[3].contains("strongest available harness"));
2402 assert!(config.core.guardrails[4].contains("Confidence protocol"));
2403 assert!(config.core.guardrails[5].contains("Commit atomically"));
2404 }
2405
2406 #[test]
2407 fn test_core_config_customizable() {
2408 let yaml = r#"
2409core:
2410 scratchpad: ".workspace/plan.md"
2411 specs_dir: "./specifications/"
2412"#;
2413 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2414 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
2415 assert_eq!(config.core.specs_dir, "./specifications/");
2416 assert_eq!(config.core.guardrails.len(), 6);
2418 }
2419
2420 #[test]
2421 fn test_core_config_custom_guardrails() {
2422 let yaml = r#"
2423core:
2424 scratchpad: ".ralph/agent/scratchpad.md"
2425 specs_dir: "./specs/"
2426 guardrails:
2427 - "Custom rule one"
2428 - "Custom rule two"
2429"#;
2430 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2431 assert_eq!(config.core.guardrails.len(), 2);
2432 assert_eq!(config.core.guardrails[0], "Custom rule one");
2433 assert_eq!(config.core.guardrails[1], "Custom rule two");
2434 }
2435
2436 #[test]
2437 fn test_prompt_and_prompt_file_mutually_exclusive() {
2438 let yaml = r#"
2440event_loop:
2441 prompt: "inline text"
2442 prompt_file: "custom.md"
2443"#;
2444 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2445 let result = config.validate();
2446
2447 assert!(result.is_err());
2448 let err = result.unwrap_err();
2449 assert!(
2450 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2451 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2452 "Expected MutuallyExclusive error, got: {:?}",
2453 err
2454 );
2455 }
2456
2457 #[test]
2458 fn test_prompt_with_default_prompt_file_allowed() {
2459 let yaml = r#"
2461event_loop:
2462 prompt: "inline text"
2463"#;
2464 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2465 let result = config.validate();
2466
2467 assert!(
2468 result.is_ok(),
2469 "Should allow inline prompt with default prompt_file"
2470 );
2471 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2472 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2473 }
2474
2475 #[test]
2476 fn test_custom_backend_requires_command() {
2477 let yaml = r#"
2479cli:
2480 backend: "custom"
2481"#;
2482 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2483 let result = config.validate();
2484
2485 assert!(result.is_err());
2486 let err = result.unwrap_err();
2487 assert!(
2488 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2489 "Expected CustomBackendRequiresCommand error, got: {:?}",
2490 err
2491 );
2492 }
2493
2494 #[test]
2495 fn test_empty_completion_promise_rejected() {
2496 let yaml = r#"
2497event_loop:
2498 completion_promise: " "
2499"#;
2500 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2501 let result = config.validate();
2502
2503 assert!(result.is_err());
2504 let err = result.unwrap_err();
2505 assert!(
2506 matches!(&err, ConfigError::InvalidCompletionPromise),
2507 "Expected InvalidCompletionPromise error, got: {:?}",
2508 err
2509 );
2510 }
2511
2512 #[test]
2513 fn test_custom_backend_with_empty_command_errors() {
2514 let yaml = r#"
2516cli:
2517 backend: "custom"
2518 command: ""
2519"#;
2520 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2521 let result = config.validate();
2522
2523 assert!(result.is_err());
2524 let err = result.unwrap_err();
2525 assert!(
2526 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2527 "Expected CustomBackendRequiresCommand error, got: {:?}",
2528 err
2529 );
2530 }
2531
2532 #[test]
2533 fn test_custom_backend_with_command_succeeds() {
2534 let yaml = r#"
2536cli:
2537 backend: "custom"
2538 command: "my-agent"
2539"#;
2540 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2541 let result = config.validate();
2542
2543 assert!(
2544 result.is_ok(),
2545 "Should allow custom backend with command: {:?}",
2546 result.unwrap_err()
2547 );
2548 }
2549
2550 #[test]
2551 fn test_custom_backend_requires_command_message_actionable() {
2552 let err = ConfigError::CustomBackendRequiresCommand;
2553 let msg = err.to_string();
2554 assert!(msg.contains("cli.command"));
2555 assert!(msg.contains("ralph init --backend custom"));
2556 assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2557 }
2558
2559 #[test]
2560 fn test_reserved_trigger_message_actionable() {
2561 let err = ConfigError::ReservedTrigger {
2562 trigger: "task.start".to_string(),
2563 hat: "builder".to_string(),
2564 };
2565 let msg = err.to_string();
2566 assert!(msg.contains("Reserved trigger"));
2567 assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2568 }
2569
2570 #[test]
2571 fn test_prompt_file_with_no_inline_allowed() {
2572 let yaml = r#"
2574event_loop:
2575 prompt_file: "custom.md"
2576"#;
2577 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2578 let result = config.validate();
2579
2580 assert!(
2581 result.is_ok(),
2582 "Should allow prompt_file without inline prompt"
2583 );
2584 assert_eq!(config.event_loop.prompt, None);
2585 assert_eq!(config.event_loop.prompt_file, "custom.md");
2586 }
2587
2588 #[test]
2589 fn test_default_prompt_file_value() {
2590 let config = RalphConfig::default();
2591 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2592 assert_eq!(config.event_loop.prompt, None);
2593 }
2594
2595 #[test]
2596 fn test_tui_config_default() {
2597 let config = RalphConfig::default();
2598 assert_eq!(config.tui.prefix_key, "ctrl-a");
2599 }
2600
2601 #[test]
2602 fn test_tui_config_parse_ctrl_b() {
2603 let yaml = r#"
2604tui:
2605 prefix_key: "ctrl-b"
2606"#;
2607 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2608 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2609
2610 use crossterm::event::{KeyCode, KeyModifiers};
2611 assert_eq!(key_code, KeyCode::Char('b'));
2612 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2613 }
2614
2615 #[test]
2616 fn test_tui_config_parse_invalid_format() {
2617 let tui_config = TuiConfig {
2618 prefix_key: "invalid".to_string(),
2619 };
2620 let result = tui_config.parse_prefix();
2621 assert!(result.is_err());
2622 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2623 }
2624
2625 #[test]
2626 fn test_tui_config_parse_invalid_modifier() {
2627 let tui_config = TuiConfig {
2628 prefix_key: "alt-a".to_string(),
2629 };
2630 let result = tui_config.parse_prefix();
2631 assert!(result.is_err());
2632 assert!(result.unwrap_err().contains("Invalid modifier"));
2633 }
2634
2635 #[test]
2636 fn test_tui_config_parse_invalid_key() {
2637 let tui_config = TuiConfig {
2638 prefix_key: "ctrl-abc".to_string(),
2639 };
2640 let result = tui_config.parse_prefix();
2641 assert!(result.is_err());
2642 assert!(result.unwrap_err().contains("Invalid key"));
2643 }
2644
2645 #[test]
2646 fn test_hat_backend_named() {
2647 let yaml = r#""claude""#;
2648 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2649 assert_eq!(backend.to_cli_backend(), "claude");
2650 match backend {
2651 HatBackend::Named(name) => assert_eq!(name, "claude"),
2652 _ => panic!("Expected Named variant"),
2653 }
2654 }
2655
2656 #[test]
2657 fn test_hat_backend_kiro_agent() {
2658 let yaml = r#"
2659type: "kiro"
2660agent: "builder"
2661"#;
2662 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2663 assert_eq!(backend.to_cli_backend(), "kiro");
2664 match backend {
2665 HatBackend::KiroAgent {
2666 backend_type,
2667 agent,
2668 args,
2669 } => {
2670 assert_eq!(backend_type, "kiro");
2671 assert_eq!(agent, "builder");
2672 assert!(args.is_empty());
2673 }
2674 _ => panic!("Expected KiroAgent variant"),
2675 }
2676 }
2677
2678 #[test]
2679 fn test_hat_backend_kiro_agent_with_args() {
2680 let yaml = r#"
2681type: "kiro"
2682agent: "builder"
2683args: ["--verbose", "--debug"]
2684"#;
2685 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2686 assert_eq!(backend.to_cli_backend(), "kiro");
2687 match backend {
2688 HatBackend::KiroAgent {
2689 backend_type,
2690 agent,
2691 args,
2692 } => {
2693 assert_eq!(backend_type, "kiro");
2694 assert_eq!(agent, "builder");
2695 assert_eq!(args, vec!["--verbose", "--debug"]);
2696 }
2697 _ => panic!("Expected KiroAgent variant"),
2698 }
2699 }
2700
2701 #[test]
2702 fn test_hat_backend_named_with_args() {
2703 let yaml = r#"
2704type: "claude"
2705args: ["--model", "claude-sonnet-4"]
2706"#;
2707 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2708 assert_eq!(backend.to_cli_backend(), "claude");
2709 match backend {
2710 HatBackend::NamedWithArgs { backend_type, args } => {
2711 assert_eq!(backend_type, "claude");
2712 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2713 }
2714 _ => panic!("Expected NamedWithArgs variant"),
2715 }
2716 }
2717
2718 #[test]
2719 fn test_hat_backend_named_with_args_empty() {
2720 let yaml = r#"
2722type: "gemini"
2723"#;
2724 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2725 assert_eq!(backend.to_cli_backend(), "gemini");
2726 match backend {
2727 HatBackend::NamedWithArgs { backend_type, args } => {
2728 assert_eq!(backend_type, "gemini");
2729 assert!(args.is_empty());
2730 }
2731 _ => panic!("Expected NamedWithArgs variant"),
2732 }
2733 }
2734
2735 #[test]
2736 fn test_hat_backend_custom() {
2737 let yaml = r#"
2738command: "/usr/bin/my-agent"
2739args: ["--flag", "value"]
2740"#;
2741 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2742 assert_eq!(backend.to_cli_backend(), "custom");
2743 match backend {
2744 HatBackend::Custom { command, args } => {
2745 assert_eq!(command, "/usr/bin/my-agent");
2746 assert_eq!(args, vec!["--flag", "value"]);
2747 }
2748 _ => panic!("Expected Custom variant"),
2749 }
2750 }
2751
2752 #[test]
2753 fn test_hat_config_with_backend() {
2754 let yaml = r#"
2755name: "Custom Builder"
2756triggers: ["build.task"]
2757publishes: ["build.done"]
2758instructions: "Build stuff"
2759backend: "gemini"
2760default_publishes: "task.done"
2761"#;
2762 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2763 assert_eq!(hat.name, "Custom Builder");
2764 assert!(hat.backend.is_some());
2765 match hat.backend.unwrap() {
2766 HatBackend::Named(name) => assert_eq!(name, "gemini"),
2767 _ => panic!("Expected Named backend"),
2768 }
2769 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2770 }
2771
2772 #[test]
2773 fn test_hat_config_without_backend() {
2774 let yaml = r#"
2775name: "Default Hat"
2776triggers: ["task.start"]
2777publishes: ["task.done"]
2778instructions: "Do work"
2779"#;
2780 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2781 assert_eq!(hat.name, "Default Hat");
2782 assert!(hat.backend.is_none());
2783 assert!(hat.default_publishes.is_none());
2784 }
2785
2786 #[test]
2787 fn test_mixed_backends_config() {
2788 let yaml = r#"
2789event_loop:
2790 prompt_file: "TASK.md"
2791 max_iterations: 50
2792
2793cli:
2794 backend: "claude"
2795
2796hats:
2797 planner:
2798 name: "Planner"
2799 triggers: ["task.start"]
2800 publishes: ["build.task"]
2801 instructions: "Plan the work"
2802 backend: "claude"
2803
2804 builder:
2805 name: "Builder"
2806 triggers: ["build.task"]
2807 publishes: ["build.done"]
2808 instructions: "Build the thing"
2809 backend:
2810 type: "kiro"
2811 agent: "builder"
2812
2813 reviewer:
2814 name: "Reviewer"
2815 triggers: ["build.done"]
2816 publishes: ["review.complete"]
2817 instructions: "Review the work"
2818 backend:
2819 command: "/usr/local/bin/custom-agent"
2820 args: ["--mode", "review"]
2821 default_publishes: "review.complete"
2822"#;
2823 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2824 assert_eq!(config.hats.len(), 3);
2825
2826 let planner = config.hats.get("planner").unwrap();
2828 assert!(planner.backend.is_some());
2829 match planner.backend.as_ref().unwrap() {
2830 HatBackend::Named(name) => assert_eq!(name, "claude"),
2831 _ => panic!("Expected Named backend for planner"),
2832 }
2833
2834 let builder = config.hats.get("builder").unwrap();
2836 assert!(builder.backend.is_some());
2837 match builder.backend.as_ref().unwrap() {
2838 HatBackend::KiroAgent {
2839 backend_type,
2840 agent,
2841 args,
2842 } => {
2843 assert_eq!(backend_type, "kiro");
2844 assert_eq!(agent, "builder");
2845 assert!(args.is_empty());
2846 }
2847 _ => panic!("Expected KiroAgent backend for builder"),
2848 }
2849
2850 let reviewer = config.hats.get("reviewer").unwrap();
2852 assert!(reviewer.backend.is_some());
2853 match reviewer.backend.as_ref().unwrap() {
2854 HatBackend::Custom { command, args } => {
2855 assert_eq!(command, "/usr/local/bin/custom-agent");
2856 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2857 }
2858 _ => panic!("Expected Custom backend for reviewer"),
2859 }
2860 assert_eq!(
2861 reviewer.default_publishes,
2862 Some("review.complete".to_string())
2863 );
2864 }
2865
2866 #[test]
2867 fn test_features_config_auto_merge_defaults_to_false() {
2868 let config = RalphConfig::default();
2871 assert!(
2872 !config.features.auto_merge,
2873 "auto_merge should default to false"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_features_config_auto_merge_from_yaml() {
2879 let yaml = r"
2881features:
2882 auto_merge: true
2883";
2884 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2885 assert!(
2886 config.features.auto_merge,
2887 "auto_merge should be true when configured"
2888 );
2889 }
2890
2891 #[test]
2892 fn test_features_config_auto_merge_false_from_yaml() {
2893 let yaml = r"
2895features:
2896 auto_merge: false
2897";
2898 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2899 assert!(
2900 !config.features.auto_merge,
2901 "auto_merge should be false when explicitly configured"
2902 );
2903 }
2904
2905 #[test]
2906 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2907 let yaml = r"
2909features:
2910 parallel: false
2911 auto_merge: true
2912";
2913 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2914 assert!(!config.features.parallel, "parallel should be false");
2915 assert!(config.features.auto_merge, "auto_merge should be true");
2916 }
2917
2918 #[test]
2919 fn test_skills_config_defaults_when_absent() {
2920 let yaml = r"
2922agent: claude
2923";
2924 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2925 assert!(config.skills.enabled);
2926 assert!(config.skills.dirs.is_empty());
2927 assert!(config.skills.overrides.is_empty());
2928 }
2929
2930 #[test]
2931 fn test_skills_config_deserializes_all_fields() {
2932 let yaml = r#"
2933skills:
2934 enabled: true
2935 dirs:
2936 - ".claude/skills"
2937 - "/shared/skills"
2938 overrides:
2939 pdd:
2940 enabled: false
2941 memories:
2942 auto_inject: true
2943 hats: ["ralph"]
2944 backends: ["claude"]
2945 tags: ["core"]
2946"#;
2947 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2948 assert!(config.skills.enabled);
2949 assert_eq!(config.skills.dirs.len(), 2);
2950 assert_eq!(
2951 config.skills.dirs[0],
2952 std::path::PathBuf::from(".claude/skills")
2953 );
2954 assert_eq!(config.skills.overrides.len(), 2);
2955
2956 let pdd = config.skills.overrides.get("pdd").unwrap();
2957 assert_eq!(pdd.enabled, Some(false));
2958
2959 let memories = config.skills.overrides.get("memories").unwrap();
2960 assert_eq!(memories.auto_inject, Some(true));
2961 assert_eq!(memories.hats, vec!["ralph"]);
2962 assert_eq!(memories.backends, vec!["claude"]);
2963 assert_eq!(memories.tags, vec!["core"]);
2964 }
2965
2966 #[test]
2967 fn test_skills_config_disabled() {
2968 let yaml = r"
2969skills:
2970 enabled: false
2971";
2972 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2973 assert!(!config.skills.enabled);
2974 assert!(config.skills.dirs.is_empty());
2975 }
2976
2977 #[test]
2978 fn test_skill_override_partial_fields() {
2979 let yaml = r#"
2980skills:
2981 overrides:
2982 my-skill:
2983 hats: ["builder", "reviewer"]
2984"#;
2985 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2986 let override_ = config.skills.overrides.get("my-skill").unwrap();
2987 assert_eq!(override_.enabled, None);
2988 assert_eq!(override_.auto_inject, None);
2989 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2990 assert!(override_.backends.is_empty());
2991 assert!(override_.tags.is_empty());
2992 }
2993
2994 #[test]
2995 fn test_hooks_config_valid_yaml_parses_and_validates() {
2996 let yaml = r#"
2997hooks:
2998 enabled: true
2999 defaults:
3000 timeout_seconds: 45
3001 max_output_bytes: 16384
3002 suspend_mode: wait_for_resume
3003 events:
3004 pre.loop.start:
3005 - name: env-guard
3006 command: ["./scripts/hooks/env-guard.sh", "--check"]
3007 on_error: block
3008 post.loop.complete:
3009 - name: notify
3010 command: ["./scripts/hooks/notify.sh"]
3011 on_error: warn
3012 mutate:
3013 enabled: true
3014 format: json
3015"#;
3016 let config = RalphConfig::parse_yaml(yaml).unwrap();
3017
3018 assert!(config.hooks.enabled);
3019 assert_eq!(config.hooks.defaults.timeout_seconds, 45);
3020 assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
3021 assert_eq!(config.hooks.events.len(), 2);
3022
3023 let warnings = config.validate().unwrap();
3024 assert!(warnings.is_empty());
3025 }
3026
3027 #[test]
3028 fn test_hooks_parse_rejects_invalid_phase_event_key() {
3029 let yaml = r#"
3030hooks:
3031 enabled: true
3032 events:
3033 pre.loop.launch:
3034 - name: bad-phase
3035 command: ["./scripts/hooks/bad-phase.sh"]
3036 on_error: warn
3037"#;
3038
3039 let result = RalphConfig::parse_yaml(yaml);
3040 assert!(result.is_err());
3041
3042 let err = result.unwrap_err();
3043 assert!(matches!(
3044 &err,
3045 ConfigError::InvalidHookPhaseEvent { phase_event }
3046 if phase_event == "pre.loop.launch"
3047 ));
3048 }
3049
3050 #[test]
3051 fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
3052 let yaml = r#"
3053hooks:
3054 enabled: true
3055 events:
3056 pre.backpressure.triggered:
3057 - name: unsupported-backpressure
3058 command: ["./scripts/hooks/backpressure.sh"]
3059 on_error: warn
3060"#;
3061
3062 let result = RalphConfig::parse_yaml(yaml);
3063 assert!(result.is_err());
3064
3065 let err = result.unwrap_err();
3066 assert!(matches!(
3067 &err,
3068 ConfigError::InvalidHookPhaseEvent { phase_event }
3069 if phase_event == "pre.backpressure.triggered"
3070 ));
3071
3072 let message = err.to_string();
3073 assert!(message.contains("Supported v1 phase-events"));
3074 assert!(message.contains("pre.plan.created"));
3075 assert!(message.contains("post.loop.error"));
3076 }
3077
3078 #[test]
3079 fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
3080 let yaml = r#"
3081hooks:
3082 enabled: true
3083 events:
3084 pre.loop.start:
3085 - name: bad-on-error
3086 command: ["./scripts/hooks/bad-on-error.sh"]
3087 on_error: explode
3088"#;
3089
3090 let result = RalphConfig::parse_yaml(yaml);
3091 assert!(result.is_err());
3092
3093 let err = result.unwrap_err();
3094 assert!(matches!(&err, ConfigError::Yaml(_)));
3095
3096 let message = err.to_string();
3097 assert!(message.contains("unknown variant `explode`"));
3098 assert!(message.contains("warn"));
3099 assert!(message.contains("block"));
3100 assert!(message.contains("suspend"));
3101 }
3102
3103 #[test]
3104 fn test_hooks_validate_rejects_missing_name() {
3105 let yaml = r#"
3106hooks:
3107 enabled: true
3108 events:
3109 pre.loop.start:
3110 - command: ["./scripts/hooks/no-name.sh"]
3111 on_error: block
3112"#;
3113 let config = RalphConfig::parse_yaml(yaml).unwrap();
3114
3115 let result = config.validate();
3116 assert!(result.is_err());
3117
3118 let err = result.unwrap_err();
3119 assert!(matches!(
3120 &err,
3121 ConfigError::HookValidation { field, .. }
3122 if field == "hooks.events.pre.loop.start[0].name"
3123 ));
3124 }
3125
3126 #[test]
3127 fn test_hooks_validate_rejects_missing_command() {
3128 let yaml = r"
3129hooks:
3130 enabled: true
3131 events:
3132 pre.loop.start:
3133 - name: missing-command
3134 on_error: block
3135";
3136 let config = RalphConfig::parse_yaml(yaml).unwrap();
3137
3138 let result = config.validate();
3139 assert!(result.is_err());
3140
3141 let err = result.unwrap_err();
3142 assert!(matches!(
3143 &err,
3144 ConfigError::HookValidation { field, .. }
3145 if field == "hooks.events.pre.loop.start[0].command"
3146 ));
3147 }
3148
3149 #[test]
3150 fn test_hooks_validate_rejects_missing_on_error() {
3151 let yaml = r#"
3152hooks:
3153 enabled: true
3154 events:
3155 pre.loop.start:
3156 - name: missing-on-error
3157 command: ["./scripts/hooks/no-on-error.sh"]
3158"#;
3159 let config = RalphConfig::parse_yaml(yaml).unwrap();
3160
3161 let result = config.validate();
3162 assert!(result.is_err());
3163
3164 let err = result.unwrap_err();
3165 assert!(matches!(
3166 &err,
3167 ConfigError::HookValidation { field, .. }
3168 if field == "hooks.events.pre.loop.start[0].on_error"
3169 ));
3170 }
3171
3172 #[test]
3173 fn test_hooks_validate_rejects_zero_timeout_seconds() {
3174 let yaml = r"
3175hooks:
3176 enabled: true
3177 defaults:
3178 timeout_seconds: 0
3179";
3180 let config = RalphConfig::parse_yaml(yaml).unwrap();
3181
3182 let result = config.validate();
3183 assert!(result.is_err());
3184
3185 let err = result.unwrap_err();
3186 assert!(matches!(
3187 &err,
3188 ConfigError::HookValidation { field, .. }
3189 if field == "hooks.defaults.timeout_seconds"
3190 ));
3191 }
3192
3193 #[test]
3194 fn test_hooks_validate_rejects_zero_max_output_bytes() {
3195 let yaml = r"
3196hooks:
3197 enabled: true
3198 defaults:
3199 max_output_bytes: 0
3200";
3201 let config = RalphConfig::parse_yaml(yaml).unwrap();
3202
3203 let result = config.validate();
3204 assert!(result.is_err());
3205
3206 let err = result.unwrap_err();
3207 assert!(matches!(
3208 &err,
3209 ConfigError::HookValidation { field, .. }
3210 if field == "hooks.defaults.max_output_bytes"
3211 ));
3212 }
3213
3214 #[test]
3215 fn test_hooks_validate_rejects_parallel_non_v1_field() {
3216 let yaml = r"
3217hooks:
3218 enabled: true
3219 parallel: true
3220";
3221 let config = RalphConfig::parse_yaml(yaml).unwrap();
3222
3223 let result = config.validate();
3224 assert!(result.is_err());
3225
3226 let err = result.unwrap_err();
3227 assert!(matches!(
3228 &err,
3229 ConfigError::UnsupportedHookField { field, .. }
3230 if field == "hooks.parallel"
3231 ));
3232 }
3233
3234 #[test]
3235 fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3236 let yaml = r#"
3237hooks:
3238 enabled: true
3239 events:
3240 pre.loop.start:
3241 - name: global-scope
3242 command: ["./scripts/hooks/global.sh"]
3243 on_error: warn
3244 scope: global
3245"#;
3246 let config = RalphConfig::parse_yaml(yaml).unwrap();
3247
3248 let result = config.validate();
3249 assert!(result.is_err());
3250
3251 let err = result.unwrap_err();
3252 assert!(matches!(
3253 &err,
3254 ConfigError::UnsupportedHookField { field, .. }
3255 if field == "hooks.events.pre.loop.start[0].scope"
3256 ));
3257 }
3258
3259 #[test]
3264 fn test_robot_config_defaults_disabled() {
3265 let config = RalphConfig::default();
3266 assert!(!config.robot.enabled);
3267 assert!(config.robot.timeout_seconds.is_none());
3268 assert!(config.robot.telegram.is_none());
3269 }
3270
3271 #[test]
3272 fn test_robot_config_absent_parses_as_default() {
3273 let yaml = r"
3275agent: claude
3276";
3277 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3278 assert!(!config.robot.enabled);
3279 assert!(config.robot.timeout_seconds.is_none());
3280 }
3281
3282 #[test]
3283 fn test_robot_config_valid_full() {
3284 let yaml = r#"
3285RObot:
3286 enabled: true
3287 timeout_seconds: 300
3288 telegram:
3289 bot_token: "123456:ABC-DEF"
3290"#;
3291 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3292 assert!(config.robot.enabled);
3293 assert_eq!(config.robot.timeout_seconds, Some(300));
3294 let telegram = config.robot.telegram.as_ref().unwrap();
3295 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3296
3297 assert!(config.validate().is_ok());
3299 }
3300
3301 #[test]
3302 fn test_robot_config_disabled_skips_validation() {
3303 let yaml = r"
3305RObot:
3306 enabled: false
3307";
3308 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3309 assert!(!config.robot.enabled);
3310 assert!(config.validate().is_ok());
3311 }
3312
3313 #[test]
3314 fn test_robot_config_enabled_missing_timeout_fails() {
3315 let yaml = r#"
3316RObot:
3317 enabled: true
3318 telegram:
3319 bot_token: "123456:ABC-DEF"
3320"#;
3321 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3322 let result = config.validate();
3323 assert!(result.is_err());
3324 let err = result.unwrap_err();
3325 assert!(
3326 matches!(&err, ConfigError::RobotMissingField { field, .. }
3327 if field == "RObot.timeout_seconds"),
3328 "Expected RobotMissingField for timeout_seconds, got: {:?}",
3329 err
3330 );
3331 }
3332
3333 #[test]
3334 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3335 let robot = RobotConfig {
3337 enabled: true,
3338 timeout_seconds: None,
3339 checkin_interval_seconds: None,
3340 telegram: None,
3341 };
3342 let result = robot.validate();
3343 assert!(result.is_err());
3344 let err = result.unwrap_err();
3345 assert!(
3346 matches!(&err, ConfigError::RobotMissingField { field, .. }
3347 if field == "RObot.timeout_seconds"),
3348 "Expected timeout validation failure first, got: {:?}",
3349 err
3350 );
3351 }
3352
3353 #[test]
3354 fn test_robot_config_resolve_bot_token_from_config() {
3355 let config = RobotConfig {
3359 enabled: true,
3360 timeout_seconds: Some(300),
3361 checkin_interval_seconds: None,
3362 telegram: Some(TelegramBotConfig {
3363 bot_token: Some("config-token".to_string()),
3364 api_url: None,
3365 }),
3366 };
3367
3368 let resolved = config.resolve_bot_token();
3371 assert!(resolved.is_some());
3374 }
3375
3376 #[test]
3377 fn test_robot_config_resolve_bot_token_none_without_config() {
3378 let config = RobotConfig {
3380 enabled: true,
3381 timeout_seconds: Some(300),
3382 checkin_interval_seconds: None,
3383 telegram: None,
3384 };
3385
3386 let resolved = config.resolve_bot_token();
3389 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3390 assert!(resolved.is_none());
3391 }
3392 }
3393
3394 #[test]
3395 fn test_robot_config_validate_with_config_token() {
3396 let robot = RobotConfig {
3398 enabled: true,
3399 timeout_seconds: Some(300),
3400 checkin_interval_seconds: None,
3401 telegram: Some(TelegramBotConfig {
3402 bot_token: Some("test-token".to_string()),
3403 api_url: None,
3404 }),
3405 };
3406 assert!(robot.validate().is_ok());
3407 }
3408
3409 #[test]
3410 fn test_robot_config_validate_missing_telegram_section() {
3411 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3414 return;
3415 }
3416
3417 let robot = RobotConfig {
3418 enabled: true,
3419 timeout_seconds: Some(300),
3420 checkin_interval_seconds: None,
3421 telegram: None,
3422 };
3423 let result = robot.validate();
3424 assert!(result.is_err());
3425 let err = result.unwrap_err();
3426 assert!(
3427 matches!(&err, ConfigError::RobotMissingField { field, .. }
3428 if field == "RObot.telegram.bot_token"),
3429 "Expected bot_token validation failure, got: {:?}",
3430 err
3431 );
3432 }
3433
3434 #[test]
3435 fn test_robot_config_validate_empty_bot_token() {
3436 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3439 return;
3440 }
3441
3442 let robot = RobotConfig {
3443 enabled: true,
3444 timeout_seconds: Some(300),
3445 checkin_interval_seconds: None,
3446 telegram: Some(TelegramBotConfig {
3447 bot_token: None,
3448 api_url: None,
3449 }),
3450 };
3451 let result = robot.validate();
3452 assert!(result.is_err());
3453 let err = result.unwrap_err();
3454 assert!(
3455 matches!(&err, ConfigError::RobotMissingField { field, .. }
3456 if field == "RObot.telegram.bot_token"),
3457 "Expected bot_token validation failure, got: {:?}",
3458 err
3459 );
3460 }
3461
3462 #[test]
3463 fn test_extra_instructions_merged_during_normalize() {
3464 let yaml = r#"
3465_fragments:
3466 shared_protocol: &shared_protocol |
3467 ### Shared Protocol
3468 Follow this protocol.
3469
3470hats:
3471 builder:
3472 name: "Builder"
3473 triggers: ["build.start"]
3474 instructions: |
3475 ## BUILDER MODE
3476 Build things.
3477 extra_instructions:
3478 - *shared_protocol
3479"#;
3480 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3481 let hat = config.hats.get("builder").unwrap();
3482
3483 assert_eq!(hat.extra_instructions.len(), 1);
3485 assert!(!hat.instructions.contains("Shared Protocol"));
3486
3487 config.normalize();
3488
3489 let hat = config.hats.get("builder").unwrap();
3490 assert!(hat.extra_instructions.is_empty());
3492 assert!(hat.instructions.contains("## BUILDER MODE"));
3493 assert!(hat.instructions.contains("### Shared Protocol"));
3494 assert!(hat.instructions.contains("Follow this protocol."));
3495 }
3496
3497 #[test]
3498 fn test_extra_instructions_empty_by_default() {
3499 let yaml = r#"
3500hats:
3501 simple:
3502 name: "Simple"
3503 triggers: ["start"]
3504 instructions: "Do the thing."
3505"#;
3506 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3507 let hat = config.hats.get("simple").unwrap();
3508 assert!(hat.extra_instructions.is_empty());
3509 }
3510
3511 #[test]
3514 fn test_wave_config_concurrency_and_aggregate_parse() {
3515 let yaml = r#"
3516hats:
3517 reviewer:
3518 name: "Reviewer"
3519 description: "Reviews files in parallel"
3520 triggers: ["review.file"]
3521 publishes: ["review.done"]
3522 instructions: "Review the file."
3523 concurrency: 3
3524 aggregator:
3525 name: "Aggregator"
3526 description: "Aggregates review results"
3527 triggers: ["review.done"]
3528 publishes: ["review.complete"]
3529 instructions: "Aggregate results."
3530 aggregate:
3531 mode: wait_for_all
3532 timeout: 600
3533"#;
3534 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3535
3536 let reviewer = config.hats.get("reviewer").unwrap();
3537 assert_eq!(reviewer.concurrency, 3);
3538 assert!(reviewer.aggregate.is_none());
3539
3540 let aggregator = config.hats.get("aggregator").unwrap();
3541 assert_eq!(aggregator.concurrency, 1); let agg = aggregator.aggregate.as_ref().unwrap();
3543 assert!(matches!(agg.mode, AggregateMode::WaitForAll));
3544 assert_eq!(agg.timeout, 600);
3545 }
3546
3547 #[test]
3548 fn test_wave_config_defaults_without_new_fields() {
3549 let yaml = r#"
3551hats:
3552 builder:
3553 name: "Builder"
3554 description: "Builds code"
3555 triggers: ["build.task"]
3556 publishes: ["build.done"]
3557 instructions: "Build stuff."
3558"#;
3559 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3560 let hat = config.hats.get("builder").unwrap();
3561 assert_eq!(hat.concurrency, 1);
3562 assert!(hat.aggregate.is_none());
3563 }
3564
3565 #[test]
3566 fn test_wave_config_concurrency_zero_rejected() {
3567 let yaml = r#"
3568hats:
3569 worker:
3570 name: "Worker"
3571 description: "Parallel worker"
3572 triggers: ["work.item"]
3573 publishes: ["work.done"]
3574 instructions: "Do work."
3575 concurrency: 0
3576"#;
3577 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3578 let result = config.validate();
3579
3580 assert!(result.is_err());
3581 let err = result.unwrap_err();
3582 assert!(
3583 matches!(&err, ConfigError::InvalidConcurrency { hat, .. } if hat == "worker"),
3584 "Expected InvalidConcurrency error, got: {:?}",
3585 err
3586 );
3587 }
3588
3589 #[test]
3590 fn test_wave_config_aggregate_on_concurrent_hat_rejected() {
3591 let yaml = r#"
3593hats:
3594 hybrid:
3595 name: "Hybrid"
3596 description: "Invalid: both concurrent and aggregator"
3597 triggers: ["work.item"]
3598 publishes: ["work.done"]
3599 instructions: "Invalid config."
3600 concurrency: 3
3601 aggregate:
3602 mode: wait_for_all
3603 timeout: 300
3604"#;
3605 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3606 let result = config.validate();
3607
3608 assert!(result.is_err());
3609 let err = result.unwrap_err();
3610 assert!(
3611 matches!(&err, ConfigError::AggregateOnConcurrentHat { hat, .. } if hat == "hybrid"),
3612 "Expected AggregateOnConcurrentHat error, got: {:?}",
3613 err
3614 );
3615 }
3616
3617 #[test]
3618 fn test_wave_config_aggregate_on_non_concurrent_hat_valid() {
3619 let yaml = r#"
3621hats:
3622 aggregator:
3623 name: "Aggregator"
3624 description: "Collects results"
3625 triggers: ["work.done"]
3626 publishes: ["work.complete"]
3627 instructions: "Aggregate."
3628 aggregate:
3629 mode: wait_for_all
3630 timeout: 300
3631"#;
3632 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3633 let result = config.validate();
3634
3635 assert!(
3636 result.is_ok(),
3637 "Aggregate on non-concurrent hat should be valid: {:?}",
3638 result.unwrap_err()
3639 );
3640 }
3641}