1use ralph_proto::Topic;
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ScratchpadConfig {
28 #[serde(default = "scratchpad_enabled_default")]
29 pub enabled: bool,
30
31 #[serde(default = "default_scratchpad_path")]
32 pub path: String,
33}
34
35fn scratchpad_enabled_default() -> bool {
36 true
37}
38
39fn default_scratchpad_path() -> String {
40 ".ralph/agent/scratchpad.md".to_string()
41}
42
43impl Default for ScratchpadConfig {
44 fn default() -> Self {
45 Self {
46 enabled: scratchpad_enabled_default(),
47 path: default_scratchpad_path(),
48 }
49 }
50}
51
52impl ScratchpadConfig {
53 pub fn resolve(
57 hat_config: Option<&ScratchpadConfig>,
58 global: &ScratchpadConfig,
59 ) -> ScratchpadConfig {
60 match hat_config {
61 Some(override_config) => override_config.clone(),
62 None => global.clone(),
63 }
64 }
65}
66
67fn deserialize_scratchpad_config<'de, D>(deserializer: D) -> Result<ScratchpadConfig, D::Error>
72where
73 D: Deserializer<'de>,
74{
75 use serde::de;
76
77 struct ScratchpadConfigVisitor;
78
79 impl<'de> de::Visitor<'de> for ScratchpadConfigVisitor {
80 type Value = ScratchpadConfig;
81
82 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
83 formatter.write_str("a string or a scratchpad config object")
84 }
85
86 fn visit_str<E: de::Error>(self, value: &str) -> Result<ScratchpadConfig, E> {
87 Ok(ScratchpadConfig {
88 enabled: true,
89 path: value.to_string(),
90 })
91 }
92
93 fn visit_map<M: de::MapAccess<'de>>(self, map: M) -> Result<ScratchpadConfig, M::Error> {
94 Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))
95 }
96 }
97
98 deserializer.deserialize_any(ScratchpadConfigVisitor)
99}
100
101fn deserialize_optional_scratchpad_config<'de, D>(
105 deserializer: D,
106) -> Result<Option<ScratchpadConfig>, D::Error>
107where
108 D: Deserializer<'de>,
109{
110 use serde::de;
111
112 struct OptionalScratchpadConfigVisitor;
113
114 impl<'de> de::Visitor<'de> for OptionalScratchpadConfigVisitor {
115 type Value = Option<ScratchpadConfig>;
116
117 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
118 formatter.write_str("null, a string, or a scratchpad config object")
119 }
120
121 fn visit_none<E: de::Error>(self) -> Result<Option<ScratchpadConfig>, E> {
122 Ok(None)
123 }
124
125 fn visit_unit<E: de::Error>(self) -> Result<Option<ScratchpadConfig>, E> {
126 Ok(None)
127 }
128
129 fn visit_str<E: de::Error>(self, value: &str) -> Result<Option<ScratchpadConfig>, E> {
130 Ok(Some(ScratchpadConfig {
131 enabled: true,
132 path: value.to_string(),
133 }))
134 }
135
136 fn visit_map<M: de::MapAccess<'de>>(
137 self,
138 map: M,
139 ) -> Result<Option<ScratchpadConfig>, M::Error> {
140 let config: ScratchpadConfig =
141 Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
142 Ok(Some(config))
143 }
144 }
145
146 deserializer.deserialize_any(OptionalScratchpadConfigVisitor)
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
155#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
157 #[serde(default)]
159 pub event_loop: EventLoopConfig,
160
161 #[serde(default)]
163 pub cli: CliConfig,
164
165 #[serde(default)]
167 pub core: CoreConfig,
168
169 #[serde(default)]
172 pub hats: HashMap<String, HatConfig>,
173
174 #[serde(default)]
178 pub events: HashMap<String, EventMetadata>,
179
180 #[serde(default)]
188 pub agent: Option<String>,
189
190 #[serde(default)]
192 pub agent_priority: Vec<String>,
193
194 #[serde(default)]
196 pub prompt_file: Option<String>,
197
198 #[serde(default)]
200 pub completion_promise: Option<String>,
201
202 #[serde(default)]
204 pub max_iterations: Option<u32>,
205
206 #[serde(default)]
208 pub max_runtime: Option<u64>,
209
210 #[serde(default)]
212 pub max_cost: Option<f64>,
213
214 #[serde(default)]
219 pub verbose: bool,
220
221 #[serde(default)]
223 pub archive_prompts: bool,
224
225 #[serde(default)]
227 pub enable_metrics: bool,
228
229 #[serde(default)]
234 pub max_tokens: Option<u32>,
235
236 #[serde(default)]
238 pub retry_delay: Option<u32>,
239
240 #[serde(default)]
242 pub adapters: AdaptersConfig,
243
244 #[serde(default, rename = "_suppress_warnings")]
249 pub suppress_warnings: bool,
250
251 #[serde(default)]
253 pub tui: TuiConfig,
254
255 #[serde(default)]
257 pub memories: MemoriesConfig,
258
259 #[serde(default)]
261 pub tasks: TasksConfig,
262
263 #[serde(default)]
265 pub hooks: HooksConfig,
266
267 #[serde(default)]
269 pub skills: SkillsConfig,
270
271 #[serde(default)]
273 pub features: FeaturesConfig,
274
275 #[serde(default, rename = "RObot")]
277 pub robot: RobotConfig,
278}
279
280fn default_true() -> bool {
281 true
282}
283
284#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
286 fn default() -> Self {
287 Self {
288 event_loop: EventLoopConfig::default(),
289 cli: CliConfig::default(),
290 core: CoreConfig::default(),
291 hats: HashMap::new(),
292 events: HashMap::new(),
293 agent: None,
295 agent_priority: vec![],
296 prompt_file: None,
297 completion_promise: None,
298 max_iterations: None,
299 max_runtime: None,
300 max_cost: None,
301 verbose: false,
303 archive_prompts: false,
304 enable_metrics: false,
305 max_tokens: None,
307 retry_delay: None,
308 adapters: AdaptersConfig::default(),
309 suppress_warnings: false,
311 tui: TuiConfig::default(),
313 memories: MemoriesConfig::default(),
315 tasks: TasksConfig::default(),
317 hooks: HooksConfig::default(),
319 skills: SkillsConfig::default(),
321 features: FeaturesConfig::default(),
323 robot: RobotConfig::default(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331pub struct AdaptersConfig {
332 #[serde(default)]
334 pub claude: AdapterSettings,
335
336 #[serde(default)]
338 pub gemini: AdapterSettings,
339
340 #[serde(default)]
342 pub kiro: AdapterSettings,
343
344 #[serde(default)]
346 pub codex: AdapterSettings,
347
348 #[serde(default)]
350 pub amp: AdapterSettings,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct AdapterSettings {
356 #[serde(default = "default_timeout")]
358 pub timeout: u64,
359
360 #[serde(default = "default_true")]
362 pub enabled: bool,
363
364 #[serde(default)]
366 pub tool_permissions: Option<Vec<String>>,
367}
368
369fn default_timeout() -> u64 {
370 300 }
372
373impl Default for AdapterSettings {
374 fn default() -> Self {
375 Self {
376 timeout: default_timeout(),
377 enabled: true,
378 tool_permissions: None,
379 }
380 }
381}
382
383impl RalphConfig {
384 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
386 let path_ref = path.as_ref();
387 debug!(path = %path_ref.display(), "Loading configuration from file");
388 let content = std::fs::read_to_string(path_ref)?;
389 Self::parse_yaml(&content)
390 }
391
392 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
394 let value: serde_yaml::Value = serde_yaml::from_str(content)?;
396 if let Some(map) = value.as_mapping()
397 && map.contains_key(serde_yaml::Value::String("project".to_string()))
398 {
399 return Err(ConfigError::DeprecatedProjectKey);
400 }
401
402 validate_hooks_phase_event_keys(&value)?;
403
404 let config: Self = serde_yaml::from_value(value)?;
405 debug!(
406 backend = %config.cli.backend,
407 has_v1_fields = config.agent.is_some(),
408 custom_hats = config.hats.len(),
409 "Configuration loaded"
410 );
411 Ok(config)
412 }
413
414 pub fn normalize(&mut self) {
419 let mut normalized_count = 0;
420
421 if let Some(ref agent) = self.agent {
423 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
424 self.cli.backend = agent.clone();
425 normalized_count += 1;
426 }
427
428 if let Some(ref pf) = self.prompt_file {
430 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
431 self.event_loop.prompt_file = pf.clone();
432 normalized_count += 1;
433 }
434
435 if let Some(ref cp) = self.completion_promise {
437 debug!(
438 from = "completion_promise",
439 to = "event_loop.completion_promise",
440 "Normalizing v1 field"
441 );
442 self.event_loop.completion_promise = cp.clone();
443 normalized_count += 1;
444 }
445
446 if let Some(mi) = self.max_iterations {
448 debug!(
449 from = "max_iterations",
450 to = "event_loop.max_iterations",
451 value = mi,
452 "Normalizing v1 field"
453 );
454 self.event_loop.max_iterations = mi;
455 normalized_count += 1;
456 }
457
458 if let Some(mr) = self.max_runtime {
460 debug!(
461 from = "max_runtime",
462 to = "event_loop.max_runtime_seconds",
463 value = mr,
464 "Normalizing v1 field"
465 );
466 self.event_loop.max_runtime_seconds = mr;
467 normalized_count += 1;
468 }
469
470 if self.max_cost.is_some() {
472 debug!(
473 from = "max_cost",
474 to = "event_loop.max_cost_usd",
475 "Normalizing v1 field"
476 );
477 self.event_loop.max_cost_usd = self.max_cost;
478 normalized_count += 1;
479 }
480
481 for (hat_id, hat) in &mut self.hats {
483 if !hat.extra_instructions.is_empty() {
484 for fragment in hat.extra_instructions.drain(..) {
485 if !hat.instructions.ends_with('\n') {
486 hat.instructions.push('\n');
487 }
488 hat.instructions.push_str(&fragment);
489 }
490 debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
491 normalized_count += 1;
492 }
493 }
494
495 if normalized_count > 0 {
496 debug!(
497 fields_normalized = normalized_count,
498 "V1 to V2 config normalization complete"
499 );
500 }
501 }
502
503 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
513 let mut warnings = Vec::new();
514
515 if self.suppress_warnings {
517 return Ok(warnings);
518 }
519
520 if self.event_loop.prompt.is_some()
523 && !self.event_loop.prompt_file.is_empty()
524 && self.event_loop.prompt_file != default_prompt_file()
525 {
526 return Err(ConfigError::MutuallyExclusive {
527 field1: "event_loop.prompt".to_string(),
528 field2: "event_loop.prompt_file".to_string(),
529 });
530 }
531 if self.event_loop.completion_promise.trim().is_empty() {
532 return Err(ConfigError::InvalidCompletionPromise);
533 }
534
535 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
537 return Err(ConfigError::CustomBackendRequiresCommand);
538 }
539
540 if self.archive_prompts {
542 warnings.push(ConfigWarning::DeferredFeature {
543 field: "archive_prompts".to_string(),
544 message: "Feature not yet available in v2".to_string(),
545 });
546 }
547
548 if self.enable_metrics {
549 warnings.push(ConfigWarning::DeferredFeature {
550 field: "enable_metrics".to_string(),
551 message: "Feature not yet available in v2".to_string(),
552 });
553 }
554
555 if self.max_tokens.is_some() {
557 warnings.push(ConfigWarning::DroppedField {
558 field: "max_tokens".to_string(),
559 reason: "Token limits are controlled by the CLI tool".to_string(),
560 });
561 }
562
563 if self.retry_delay.is_some() {
564 warnings.push(ConfigWarning::DroppedField {
565 field: "retry_delay".to_string(),
566 reason: "Retry logic handled differently in v2".to_string(),
567 });
568 }
569
570 if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
571 && !(0.0..=100.0).contains(&threshold)
572 {
573 warnings.push(ConfigWarning::InvalidValue {
574 field: "event_loop.mutation_score_warn_threshold".to_string(),
575 message: "Value must be between 0 and 100".to_string(),
576 });
577 }
578
579 if self.adapters.claude.tool_permissions.is_some()
581 || self.adapters.gemini.tool_permissions.is_some()
582 || self.adapters.codex.tool_permissions.is_some()
583 || self.adapters.amp.tool_permissions.is_some()
584 {
585 warnings.push(ConfigWarning::DroppedField {
586 field: "adapters.*.tool_permissions".to_string(),
587 reason: "CLI tool manages its own permissions".to_string(),
588 });
589 }
590
591 self.robot.validate()?;
593
594 self.validate_hooks()?;
596
597 for (hat_id, hat_config) in &self.hats {
599 if hat_config
600 .description
601 .as_ref()
602 .is_none_or(|d| d.trim().is_empty())
603 {
604 return Err(ConfigError::MissingDescription {
605 hat: hat_id.clone(),
606 });
607 }
608 }
609
610 for (hat_id, hat_config) in &self.hats {
612 if hat_config.concurrency == 0 {
613 return Err(ConfigError::InvalidConcurrency {
614 hat: hat_id.clone(),
615 value: 0,
616 });
617 }
618 if hat_config.aggregate.is_some() && hat_config.concurrency > 1 {
619 return Err(ConfigError::AggregateOnConcurrentHat {
620 hat: hat_id.clone(),
621 });
622 }
623 }
624
625 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
628 for (hat_id, hat_config) in &self.hats {
629 for trigger in &hat_config.triggers {
630 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
631 return Err(ConfigError::ReservedTrigger {
632 trigger: trigger.clone(),
633 hat: hat_id.clone(),
634 });
635 }
636 }
637 }
638
639 if !self.hats.is_empty() {
642 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
643 for (hat_id, hat_config) in &self.hats {
644 for trigger in &hat_config.triggers {
645 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
646 return Err(ConfigError::AmbiguousRouting {
647 trigger: trigger.clone(),
648 hat1: (*existing_hat).to_string(),
649 hat2: hat_id.clone(),
650 });
651 }
652 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
653 }
654 }
655 }
656
657 Ok(warnings)
658 }
659
660 fn validate_hooks(&self) -> Result<(), ConfigError> {
661 Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
662
663 if self.hooks.defaults.timeout_seconds == 0 {
664 return Err(ConfigError::HookValidation {
665 field: "hooks.defaults.timeout_seconds".to_string(),
666 message: "must be greater than 0".to_string(),
667 });
668 }
669
670 if self.hooks.defaults.max_output_bytes == 0 {
671 return Err(ConfigError::HookValidation {
672 field: "hooks.defaults.max_output_bytes".to_string(),
673 message: "must be greater than 0".to_string(),
674 });
675 }
676
677 for (phase_event, hook_specs) in &self.hooks.events {
678 for (index, hook) in hook_specs.iter().enumerate() {
679 let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
680
681 if hook.name.trim().is_empty() {
682 return Err(ConfigError::HookValidation {
683 field: format!("{hook_field_base}.name"),
684 message: "is required and must be non-empty".to_string(),
685 });
686 }
687
688 if hook
689 .command
690 .first()
691 .is_none_or(|command| command.trim().is_empty())
692 {
693 return Err(ConfigError::HookValidation {
694 field: format!("{hook_field_base}.command"),
695 message: "is required and must include an executable at command[0]"
696 .to_string(),
697 });
698 }
699
700 if hook.on_error.is_none() {
701 return Err(ConfigError::HookValidation {
702 field: format!("{hook_field_base}.on_error"),
703 message: "is required in v1 (warn | block | suspend)".to_string(),
704 });
705 }
706
707 if let Some(timeout_seconds) = hook.timeout_seconds
708 && timeout_seconds == 0
709 {
710 return Err(ConfigError::HookValidation {
711 field: format!("{hook_field_base}.timeout_seconds"),
712 message: "must be greater than 0 when specified".to_string(),
713 });
714 }
715
716 if let Some(max_output_bytes) = hook.max_output_bytes
717 && max_output_bytes == 0
718 {
719 return Err(ConfigError::HookValidation {
720 field: format!("{hook_field_base}.max_output_bytes"),
721 message: "must be greater than 0 when specified".to_string(),
722 });
723 }
724
725 if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
726 return Err(ConfigError::HookValidation {
727 field: format!("{hook_field_base}.suspend_mode"),
728 message: "requires on_error: suspend".to_string(),
729 });
730 }
731
732 Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
733 Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
734 }
735 }
736
737 Ok(())
738 }
739
740 fn validate_non_v1_hook_fields(
741 path_prefix: &str,
742 fields: &HashMap<String, serde_yaml::Value>,
743 ) -> Result<(), ConfigError> {
744 for key in fields.keys() {
745 let field = format!("{path_prefix}.{key}");
746 match key.as_str() {
747 "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
748 return Err(ConfigError::UnsupportedHookField {
749 field,
750 reason: "Use ~/.ralph/config.yml for user-level defaults; per-hook `global`/`scope` fields are not supported in v1"
751 .to_string(),
752 });
753 }
754 "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
755 return Err(ConfigError::UnsupportedHookField {
756 field,
757 reason:
758 "Parallel hook execution is out of scope for v1; hooks must run sequentially"
759 .to_string(),
760 });
761 }
762 _ => {}
763 }
764 }
765
766 Ok(())
767 }
768
769 fn validate_mutation_contract(
770 hook_field_base: &str,
771 mutate: &HookMutationConfig,
772 ) -> Result<(), ConfigError> {
773 let mutate_field_base = format!("{hook_field_base}.mutate");
774
775 if !mutate.enabled {
776 if mutate.format.is_some() || !mutate.extra.is_empty() {
777 return Err(ConfigError::HookValidation {
778 field: mutate_field_base,
779 message: "mutation settings require mutate.enabled: true".to_string(),
780 });
781 }
782 return Ok(());
783 }
784
785 if let Some(format) = mutate.format.as_deref()
786 && !format.eq_ignore_ascii_case("json")
787 {
788 return Err(ConfigError::HookValidation {
789 field: format!("{mutate_field_base}.format"),
790 message: "only 'json' is supported for v1 mutation payloads".to_string(),
791 });
792 }
793
794 if let Some(key) = mutate.extra.keys().next() {
795 let field = format!("{mutate_field_base}.{key}");
796 let reason = match key.as_str() {
797 "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
798 "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
799 .to_string()
800 }
801 "xml" => "v1 mutation payloads are JSON-only".to_string(),
802 _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
803 };
804
805 return Err(ConfigError::UnsupportedHookField { field, reason });
806 }
807
808 Ok(())
809 }
810
811 pub fn effective_backend(&self) -> &str {
813 &self.cli.backend
814 }
815
816 pub fn get_agent_priority(&self) -> Vec<&str> {
819 if self.agent_priority.is_empty() {
820 vec![
821 "claude", "kiro", "kiro-acp", "gemini", "codex", "forge", "amp", "copilot",
822 "opencode", "pi", "roo",
823 ]
824 } else {
825 self.agent_priority.iter().map(String::as_str).collect()
826 }
827 }
828
829 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
832 match backend {
833 "claude" => &self.adapters.claude,
834 "gemini" => &self.adapters.gemini,
835 "kiro" => &self.adapters.kiro,
836 "codex" => &self.adapters.codex,
837 "amp" => &self.adapters.amp,
838 _ => &self.adapters.claude, }
840 }
841}
842
843#[derive(Debug, Clone)]
845pub enum ConfigWarning {
846 DeferredFeature { field: String, message: String },
848 DroppedField { field: String, reason: String },
850 InvalidValue { field: String, message: String },
852}
853
854impl std::fmt::Display for ConfigWarning {
855 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857 match self {
858 ConfigWarning::DeferredFeature { field, message }
859 | ConfigWarning::InvalidValue { field, message } => {
860 write!(f, "Warning [{field}]: {message}")
861 }
862 ConfigWarning::DroppedField { field, reason } => {
863 write!(f, "Warning [{field}]: Field ignored - {reason}")
864 }
865 }
866 }
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct EventLoopConfig {
872 pub prompt: Option<String>,
874
875 #[serde(default = "default_prompt_file")]
877 pub prompt_file: String,
878
879 #[serde(default = "default_completion_promise")]
881 pub completion_promise: String,
882
883 #[serde(default = "default_max_iterations")]
885 pub max_iterations: u32,
886
887 #[serde(default = "default_max_runtime")]
889 pub max_runtime_seconds: u64,
890
891 pub max_cost_usd: Option<f64>,
893
894 #[serde(default = "default_max_failures")]
896 pub max_consecutive_failures: u32,
897
898 #[serde(default)]
901 pub cooldown_delay_seconds: u64,
902
903 pub starting_hat: Option<String>,
905
906 pub starting_event: Option<String>,
916
917 #[serde(default)]
921 pub mutation_score_warn_threshold: Option<f64>,
922
923 #[serde(default)]
930 pub persistent: bool,
931
932 #[serde(default)]
936 pub required_events: Vec<String>,
937
938 #[serde(default)]
942 pub cancellation_promise: String,
943
944 #[serde(default)]
948 pub enforce_hat_scope: bool,
949
950 #[serde(default)]
955 pub context_window_tokens: Option<u64>,
956}
957
958pub fn resolve_context_window(cfg: &RalphConfig) -> u64 {
965 resolve_context_window_for_backend(cfg, cfg.cli.backend.as_str())
966}
967
968pub fn resolve_context_window_for_backend(cfg: &RalphConfig, backend: &str) -> u64 {
973 if let Some(n) = cfg.event_loop.context_window_tokens {
974 return n;
975 }
976 match backend {
977 "claude" | "pi" => 200_000,
978 _ => 0,
979 }
980}
981
982fn default_prompt_file() -> String {
983 "PROMPT.md".to_string()
984}
985
986fn default_completion_promise() -> String {
987 "LOOP_COMPLETE".to_string()
988}
989
990fn default_max_iterations() -> u32 {
991 100
992}
993
994fn default_max_runtime() -> u64 {
995 14400 }
997
998fn default_max_failures() -> u32 {
999 5
1000}
1001
1002impl Default for EventLoopConfig {
1003 fn default() -> Self {
1004 Self {
1005 prompt: None,
1006 prompt_file: default_prompt_file(),
1007 completion_promise: default_completion_promise(),
1008 max_iterations: default_max_iterations(),
1009 max_runtime_seconds: default_max_runtime(),
1010 max_cost_usd: None,
1011 max_consecutive_failures: default_max_failures(),
1012 cooldown_delay_seconds: 0,
1013 starting_hat: None,
1014 starting_event: None,
1015 mutation_score_warn_threshold: None,
1016 persistent: false,
1017 required_events: Vec::new(),
1018 cancellation_promise: String::new(),
1019 enforce_hat_scope: false,
1020 context_window_tokens: None,
1021 }
1022 }
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct CoreConfig {
1030 #[serde(default, deserialize_with = "deserialize_scratchpad_config")]
1033 pub scratchpad: ScratchpadConfig,
1034
1035 #[serde(default = "default_specs_dir")]
1037 pub specs_dir: String,
1038
1039 #[serde(default = "default_guardrails")]
1043 pub guardrails: Vec<String>,
1044
1045 #[serde(skip)]
1052 pub workspace_root: std::path::PathBuf,
1053}
1054
1055fn default_specs_dir() -> String {
1056 ".ralph/specs/".to_string()
1057}
1058
1059fn default_guardrails() -> Vec<String> {
1060 vec![
1061 "Fresh context each iteration - scratchpad is memory".to_string(),
1062 "Don't assume 'not implemented' - search first".to_string(),
1063 "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
1064 "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(),
1065 "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(),
1066 "Commit atomically - one logical change per commit, capture the why".to_string(),
1067 ]
1068}
1069
1070impl Default for CoreConfig {
1071 fn default() -> Self {
1072 Self {
1073 scratchpad: ScratchpadConfig::default(),
1074 specs_dir: default_specs_dir(),
1075 guardrails: default_guardrails(),
1076 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
1077 .map(std::path::PathBuf::from)
1078 .unwrap_or_else(|_| {
1079 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
1080 }),
1081 }
1082 }
1083}
1084
1085impl CoreConfig {
1086 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
1090 self.workspace_root = root.into();
1091 self
1092 }
1093
1094 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
1099 let path = std::path::Path::new(relative);
1100 if path.is_absolute() {
1101 path.to_path_buf()
1102 } else {
1103 self.workspace_root.join(path)
1104 }
1105 }
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize)]
1110pub struct CliConfig {
1111 #[serde(default = "default_backend")]
1114 pub backend: String,
1115
1116 pub command: Option<String>,
1119
1120 #[serde(default = "default_prompt_mode")]
1122 pub prompt_mode: String,
1123
1124 #[serde(default = "default_mode")]
1127 pub default_mode: String,
1128
1129 #[serde(default = "default_idle_timeout")]
1133 pub idle_timeout_secs: u32,
1134
1135 #[serde(default)]
1138 pub args: Vec<String>,
1139
1140 #[serde(default)]
1143 pub prompt_flag: Option<String>,
1144}
1145
1146fn default_backend() -> String {
1147 "claude".to_string()
1148}
1149
1150fn default_prompt_mode() -> String {
1151 "arg".to_string()
1152}
1153
1154fn default_mode() -> String {
1155 "autonomous".to_string()
1156}
1157
1158fn default_idle_timeout() -> u32 {
1159 30 }
1161
1162impl Default for CliConfig {
1163 fn default() -> Self {
1164 Self {
1165 backend: default_backend(),
1166 command: None,
1167 prompt_mode: default_prompt_mode(),
1168 default_mode: default_mode(),
1169 idle_timeout_secs: default_idle_timeout(),
1170 args: Vec::new(),
1171 prompt_flag: None,
1172 }
1173 }
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1178pub struct TuiConfig {
1179 #[serde(default = "default_prefix_key")]
1181 pub prefix_key: String,
1182}
1183
1184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1188#[serde(rename_all = "lowercase")]
1189pub enum InjectMode {
1190 #[default]
1192 Auto,
1193 Manual,
1195 None,
1197}
1198
1199impl std::fmt::Display for InjectMode {
1200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1201 match self {
1202 Self::Auto => write!(f, "auto"),
1203 Self::Manual => write!(f, "manual"),
1204 Self::None => write!(f, "none"),
1205 }
1206 }
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1225pub struct MemoriesConfig {
1226 #[serde(default)]
1230 pub enabled: bool,
1231
1232 #[serde(default)]
1234 pub inject: InjectMode,
1235
1236 #[serde(default)]
1240 pub budget: usize,
1241
1242 #[serde(default)]
1244 pub filter: MemoriesFilter,
1245}
1246
1247impl Default for MemoriesConfig {
1248 fn default() -> Self {
1249 Self {
1250 enabled: true, inject: InjectMode::Auto,
1252 budget: 0,
1253 filter: MemoriesFilter::default(),
1254 }
1255 }
1256}
1257
1258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1262pub struct MemoriesFilter {
1263 #[serde(default)]
1265 pub types: Vec<String>,
1266
1267 #[serde(default)]
1269 pub tags: Vec<String>,
1270
1271 #[serde(default)]
1273 pub recent: u32,
1274}
1275
1276#[derive(Debug, Clone, Serialize, Deserialize)]
1289pub struct TasksConfig {
1290 #[serde(default = "default_true")]
1294 pub enabled: bool,
1295}
1296
1297impl Default for TasksConfig {
1298 fn default() -> Self {
1299 Self {
1300 enabled: true, }
1302 }
1303}
1304
1305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1325pub struct HooksConfig {
1326 #[serde(default)]
1328 pub enabled: bool,
1329
1330 #[serde(default)]
1332 pub defaults: HookDefaults,
1333
1334 #[serde(default)]
1336 pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1337
1338 #[serde(default, flatten)]
1340 pub extra: HashMap<String, serde_yaml::Value>,
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1345pub struct HookDefaults {
1346 #[serde(default = "default_hook_timeout_seconds")]
1348 pub timeout_seconds: u64,
1349
1350 #[serde(default = "default_hook_max_output_bytes")]
1352 pub max_output_bytes: u64,
1353
1354 #[serde(default)]
1356 pub suspend_mode: HookSuspendMode,
1357}
1358
1359fn default_hook_timeout_seconds() -> u64 {
1360 30
1361}
1362
1363fn default_hook_max_output_bytes() -> u64 {
1364 8192
1365}
1366
1367impl Default for HookDefaults {
1368 fn default() -> Self {
1369 Self {
1370 timeout_seconds: default_hook_timeout_seconds(),
1371 max_output_bytes: default_hook_max_output_bytes(),
1372 suspend_mode: HookSuspendMode::default(),
1373 }
1374 }
1375}
1376
1377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1379pub enum HookPhaseEvent {
1380 #[serde(rename = "pre.loop.start")]
1381 PreLoopStart,
1382 #[serde(rename = "post.loop.start")]
1383 PostLoopStart,
1384 #[serde(rename = "pre.iteration.start")]
1385 PreIterationStart,
1386 #[serde(rename = "post.iteration.start")]
1387 PostIterationStart,
1388 #[serde(rename = "pre.plan.created")]
1389 PrePlanCreated,
1390 #[serde(rename = "post.plan.created")]
1391 PostPlanCreated,
1392 #[serde(rename = "pre.human.interact")]
1393 PreHumanInteract,
1394 #[serde(rename = "post.human.interact")]
1395 PostHumanInteract,
1396 #[serde(rename = "pre.loop.complete")]
1397 PreLoopComplete,
1398 #[serde(rename = "post.loop.complete")]
1399 PostLoopComplete,
1400 #[serde(rename = "pre.loop.error")]
1401 PreLoopError,
1402 #[serde(rename = "post.loop.error")]
1403 PostLoopError,
1404}
1405
1406impl HookPhaseEvent {
1407 pub fn as_str(self) -> &'static str {
1409 match self {
1410 Self::PreLoopStart => "pre.loop.start",
1411 Self::PostLoopStart => "post.loop.start",
1412 Self::PreIterationStart => "pre.iteration.start",
1413 Self::PostIterationStart => "post.iteration.start",
1414 Self::PrePlanCreated => "pre.plan.created",
1415 Self::PostPlanCreated => "post.plan.created",
1416 Self::PreHumanInteract => "pre.human.interact",
1417 Self::PostHumanInteract => "post.human.interact",
1418 Self::PreLoopComplete => "pre.loop.complete",
1419 Self::PostLoopComplete => "post.loop.complete",
1420 Self::PreLoopError => "pre.loop.error",
1421 Self::PostLoopError => "post.loop.error",
1422 }
1423 }
1424
1425 pub fn parse(value: &str) -> Option<Self> {
1427 match value {
1428 "pre.loop.start" => Some(Self::PreLoopStart),
1429 "post.loop.start" => Some(Self::PostLoopStart),
1430 "pre.iteration.start" => Some(Self::PreIterationStart),
1431 "post.iteration.start" => Some(Self::PostIterationStart),
1432 "pre.plan.created" => Some(Self::PrePlanCreated),
1433 "post.plan.created" => Some(Self::PostPlanCreated),
1434 "pre.human.interact" => Some(Self::PreHumanInteract),
1435 "post.human.interact" => Some(Self::PostHumanInteract),
1436 "pre.loop.complete" => Some(Self::PreLoopComplete),
1437 "post.loop.complete" => Some(Self::PostLoopComplete),
1438 "pre.loop.error" => Some(Self::PreLoopError),
1439 "post.loop.error" => Some(Self::PostLoopError),
1440 _ => None,
1441 }
1442 }
1443}
1444
1445impl std::fmt::Display for HookPhaseEvent {
1446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1447 f.write_str((*self).as_str())
1448 }
1449}
1450
1451fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1452 let Some(root) = value.as_mapping() else {
1453 return Ok(());
1454 };
1455
1456 let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1457 return Ok(());
1458 };
1459
1460 let Some(hooks_map) = hooks.as_mapping() else {
1461 return Ok(());
1462 };
1463
1464 let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1465 return Ok(());
1466 };
1467
1468 let Some(events_map) = events.as_mapping() else {
1469 return Ok(());
1470 };
1471
1472 for key in events_map.keys() {
1473 if let Some(phase_event) = key.as_str()
1474 && HookPhaseEvent::parse(phase_event).is_none()
1475 {
1476 return Err(ConfigError::InvalidHookPhaseEvent {
1477 phase_event: phase_event.to_string(),
1478 });
1479 }
1480 }
1481
1482 Ok(())
1483}
1484
1485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1487#[serde(rename_all = "snake_case")]
1488pub enum HookOnError {
1489 Warn,
1491 Block,
1493 Suspend,
1495}
1496
1497#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1499#[serde(rename_all = "snake_case")]
1500pub enum HookSuspendMode {
1501 #[default]
1503 WaitForResume,
1504 RetryBackoff,
1506 WaitThenRetry,
1508}
1509
1510#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1512pub struct HookMutationConfig {
1513 #[serde(default)]
1515 pub enabled: bool,
1516
1517 #[serde(default)]
1519 pub format: Option<String>,
1520
1521 #[serde(default, flatten)]
1523 pub extra: HashMap<String, serde_yaml::Value>,
1524}
1525
1526#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct HookSpec {
1529 #[serde(default)]
1531 pub name: String,
1532
1533 #[serde(default)]
1535 pub command: Vec<String>,
1536
1537 #[serde(default)]
1539 pub cwd: Option<PathBuf>,
1540
1541 #[serde(default)]
1543 pub env: HashMap<String, String>,
1544
1545 #[serde(default)]
1547 pub timeout_seconds: Option<u64>,
1548
1549 #[serde(default)]
1551 pub max_output_bytes: Option<u64>,
1552
1553 #[serde(default)]
1555 pub on_error: Option<HookOnError>,
1556
1557 #[serde(default)]
1559 pub suspend_mode: Option<HookSuspendMode>,
1560
1561 #[serde(default)]
1563 pub mutate: HookMutationConfig,
1564
1565 #[serde(default, flatten)]
1567 pub extra: HashMap<String, serde_yaml::Value>,
1568}
1569
1570#[derive(Debug, Clone, Serialize, Deserialize)]
1593pub struct SkillsConfig {
1594 #[serde(default = "default_true")]
1596 pub enabled: bool,
1597
1598 #[serde(default)]
1601 pub dirs: Vec<PathBuf>,
1602
1603 #[serde(default)]
1605 pub overrides: HashMap<String, SkillOverride>,
1606}
1607
1608impl Default for SkillsConfig {
1609 fn default() -> Self {
1610 Self {
1611 enabled: true, dirs: vec![],
1613 overrides: HashMap::new(),
1614 }
1615 }
1616}
1617
1618#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1623pub struct SkillOverride {
1624 #[serde(default)]
1626 pub enabled: Option<bool>,
1627
1628 #[serde(default)]
1630 pub hats: Vec<String>,
1631
1632 #[serde(default)]
1634 pub backends: Vec<String>,
1635
1636 #[serde(default)]
1638 pub tags: Vec<String>,
1639
1640 #[serde(default)]
1642 pub auto_inject: Option<bool>,
1643}
1644
1645#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1647pub struct PreflightConfig {
1648 #[serde(default)]
1650 pub enabled: bool,
1651
1652 #[serde(default)]
1654 pub strict: bool,
1655
1656 #[serde(default)]
1658 pub skip: Vec<String>,
1659}
1660
1661#[derive(Debug, Clone, Serialize, Deserialize)]
1677pub struct FeaturesConfig {
1678 #[serde(default = "default_true")]
1683 pub parallel: bool,
1684
1685 #[serde(default)]
1691 pub auto_merge: bool,
1692
1693 #[serde(default)]
1699 pub loop_naming: crate::loop_name::LoopNamingConfig,
1700
1701 #[serde(default)]
1703 pub preflight: PreflightConfig,
1704}
1705
1706impl Default for FeaturesConfig {
1707 fn default() -> Self {
1708 Self {
1709 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1712 preflight: PreflightConfig::default(),
1713 }
1714 }
1715}
1716
1717fn default_prefix_key() -> String {
1718 "ctrl-a".to_string()
1719}
1720
1721impl Default for TuiConfig {
1722 fn default() -> Self {
1723 Self {
1724 prefix_key: default_prefix_key(),
1725 }
1726 }
1727}
1728
1729impl TuiConfig {
1730 pub fn parse_prefix(
1733 &self,
1734 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1735 use crossterm::event::{KeyCode, KeyModifiers};
1736
1737 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1738 if parts.len() != 2 {
1739 return Err(format!(
1740 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1741 self.prefix_key
1742 ));
1743 }
1744
1745 let modifier = match parts[0].to_lowercase().as_str() {
1746 "ctrl" => KeyModifiers::CONTROL,
1747 _ => {
1748 return Err(format!(
1749 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1750 parts[0]
1751 ));
1752 }
1753 };
1754
1755 let key_str = parts[1];
1756 if key_str.len() != 1 {
1757 return Err(format!(
1758 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1759 key_str
1760 ));
1761 }
1762
1763 let key_char = key_str.chars().next().unwrap();
1764 let key_code = KeyCode::Char(key_char);
1765
1766 Ok((key_code, modifier))
1767 }
1768}
1769
1770#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1785pub struct EventMetadata {
1786 #[serde(default)]
1788 pub description: String,
1789
1790 #[serde(default)]
1793 pub on_trigger: String,
1794
1795 #[serde(default)]
1798 pub on_publish: String,
1799}
1800
1801#[derive(Debug, Clone, Serialize, Deserialize)]
1803#[serde(untagged)]
1804pub enum HatBackend {
1805 KiroAgent {
1808 #[serde(rename = "type")]
1809 backend_type: String,
1810 agent: String,
1811 #[serde(default)]
1812 args: Vec<String>,
1813 },
1814 NamedWithArgs {
1816 #[serde(rename = "type")]
1817 backend_type: String,
1818 #[serde(default)]
1819 args: Vec<String>,
1820 },
1821 Named(String),
1823 Custom {
1825 command: String,
1826 #[serde(default)]
1827 args: Vec<String>,
1828 },
1829}
1830
1831impl HatBackend {
1832 pub fn to_cli_backend(&self) -> String {
1834 match self {
1835 HatBackend::Named(name) => name.clone(),
1836 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1837 HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1838 HatBackend::Custom { .. } => "custom".to_string(),
1839 }
1840 }
1841}
1842
1843#[derive(Debug, Clone, Serialize, Deserialize)]
1845pub struct HatConfig {
1846 pub name: String,
1848
1849 pub description: Option<String>,
1852
1853 #[serde(default)]
1856 pub triggers: Vec<String>,
1857
1858 #[serde(default)]
1860 pub publishes: Vec<String>,
1861
1862 #[serde(default)]
1864 pub instructions: String,
1865
1866 #[serde(default)]
1883 pub extra_instructions: Vec<String>,
1884
1885 #[serde(default)]
1887 pub backend: Option<HatBackend>,
1888
1889 #[serde(default, alias = "args")]
1893 pub backend_args: Option<Vec<String>>,
1894
1895 #[serde(default)]
1897 pub default_publishes: Option<String>,
1898
1899 pub max_activations: Option<u32>,
1904
1905 #[serde(default, deserialize_with = "deserialize_optional_scratchpad_config")]
1908 pub scratchpad: Option<ScratchpadConfig>,
1909
1910 #[serde(default)]
1916 pub disallowed_tools: Vec<String>,
1917
1918 #[serde(default)]
1923 pub timeout: Option<u32>,
1924
1925 #[serde(default = "default_concurrency")]
1930 pub concurrency: u32,
1931
1932 #[serde(default)]
1938 pub aggregate: Option<AggregateConfig>,
1939}
1940
1941fn default_concurrency() -> u32 {
1942 1
1943}
1944
1945#[derive(Debug, Clone, Serialize, Deserialize)]
1947pub struct AggregateConfig {
1948 pub mode: AggregateMode,
1950
1951 pub timeout: u32,
1954}
1955
1956#[derive(Debug, Clone, Serialize, Deserialize)]
1958#[serde(rename_all = "snake_case")]
1959pub enum AggregateMode {
1960 WaitForAll,
1962}
1963
1964impl HatConfig {
1965 pub fn trigger_topics(&self) -> Vec<Topic> {
1967 self.triggers.iter().map(|s| Topic::new(s)).collect()
1968 }
1969
1970 pub fn publish_topics(&self) -> Vec<Topic> {
1972 self.publishes.iter().map(|s| Topic::new(s)).collect()
1973 }
1974}
1975
1976#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1978#[serde(rename_all = "lowercase")]
1979pub enum RobotMode {
1980 #[default]
1982 Telegram,
1983 Web,
1985}
1986
1987impl RobotMode {
1988 pub fn is_telegram(self) -> bool {
1990 matches!(self, Self::Telegram)
1991 }
1992
1993 pub fn is_web(self) -> bool {
1995 matches!(self, Self::Web)
1996 }
1997}
1998
1999#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2017pub struct RobotConfig {
2018 #[serde(default)]
2020 pub enabled: bool,
2021
2022 #[serde(default)]
2024 pub mode: RobotMode,
2025
2026 pub timeout_seconds: Option<u64>,
2030
2031 pub checkin_interval_seconds: Option<u64>,
2035
2036 #[serde(default)]
2038 pub telegram: Option<TelegramBotConfig>,
2039}
2040
2041impl RobotConfig {
2042 pub fn validate(&self) -> Result<(), ConfigError> {
2044 if !self.enabled {
2045 return Ok(());
2046 }
2047
2048 if self.timeout_seconds.is_none() {
2049 return Err(ConfigError::RobotMissingField {
2050 field: "RObot.timeout_seconds".to_string(),
2051 hint: "timeout_seconds is required when RObot is enabled".to_string(),
2052 });
2053 }
2054
2055 if self.mode.is_telegram() && self.resolve_bot_token().is_none() {
2057 return Err(ConfigError::RobotMissingField {
2058 field: "RObot.telegram.bot_token".to_string(),
2059 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
2060 .to_string(),
2061 });
2062 }
2063
2064 Ok(())
2065 }
2066
2067 pub fn resolve_bot_token(&self) -> Option<String> {
2074 let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
2076 let config_token = self
2077 .telegram
2078 .as_ref()
2079 .and_then(|telegram| telegram.bot_token.clone());
2080
2081 if cfg!(test) {
2082 return env_token.or(config_token);
2083 }
2084
2085 env_token
2086 .or(config_token)
2088 .or_else(|| {
2090 std::panic::catch_unwind(|| {
2091 keyring::Entry::new("ralph", "telegram-bot-token")
2092 .ok()
2093 .and_then(|e| e.get_password().ok())
2094 })
2095 .ok()
2096 .flatten()
2097 })
2098 }
2099
2100 pub fn resolve_api_url(&self) -> Option<String> {
2106 std::env::var("RALPH_TELEGRAM_API_URL").ok().or_else(|| {
2107 self.telegram
2108 .as_ref()
2109 .and_then(|telegram| telegram.api_url.clone())
2110 })
2111 }
2112}
2113
2114#[derive(Debug, Clone, Serialize, Deserialize)]
2116pub struct TelegramBotConfig {
2117 pub bot_token: Option<String>,
2119
2120 pub api_url: Option<String>,
2125}
2126
2127#[derive(Debug, thiserror::Error)]
2129pub enum ConfigError {
2130 #[error("IO error: {0}")]
2131 Io(#[from] std::io::Error),
2132
2133 #[error("YAML parse error: {0}")]
2134 Yaml(#[from] serde_yaml::Error),
2135
2136 #[error(
2137 "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"
2138 )]
2139 AmbiguousRouting {
2140 trigger: String,
2141 hat1: String,
2142 hat2: String,
2143 },
2144
2145 #[error(
2146 "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"
2147 )]
2148 MutuallyExclusive { field1: String, field2: String },
2149
2150 #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
2151 InvalidCompletionPromise,
2152
2153 #[error(
2154 "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"
2155 )]
2156 CustomBackendRequiresCommand,
2157
2158 #[error(
2159 "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"
2160 )]
2161 ReservedTrigger { trigger: String, hat: String },
2162
2163 #[error(
2164 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
2165 )]
2166 MissingDescription { hat: String },
2167
2168 #[error(
2169 "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
2170 )]
2171 RobotMissingField { field: String, hint: String },
2172
2173 #[error(
2174 "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."
2175 )]
2176 InvalidHookPhaseEvent { phase_event: String },
2177
2178 #[error(
2179 "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
2180 )]
2181 HookValidation { field: String, message: String },
2182
2183 #[error(
2184 "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
2185 )]
2186 UnsupportedHookField { field: String, reason: String },
2187
2188 #[error(
2189 "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
2190 )]
2191 DeprecatedProjectKey,
2192
2193 #[error(
2194 "Hat '{hat}' has invalid concurrency: {value}. Must be >= 1.\nFix: set 'concurrency' to 1 or higher."
2195 )]
2196 InvalidConcurrency { hat: String, value: u32 },
2197
2198 #[error(
2199 "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."
2200 )]
2201 AggregateOnConcurrentHat { hat: String },
2202}
2203
2204#[cfg(test)]
2205mod tests {
2206 use super::*;
2207
2208 #[test]
2209 fn test_default_config() {
2210 let config = RalphConfig::default();
2211 assert!(config.hats.is_empty());
2213 assert_eq!(config.event_loop.max_iterations, 100);
2214 assert!(!config.verbose);
2215 assert!(!config.features.preflight.enabled);
2216 assert!(!config.features.preflight.strict);
2217 assert!(config.features.preflight.skip.is_empty());
2218 }
2219
2220 #[test]
2221 fn test_resolve_context_window_explicit_override_wins() {
2222 let mut config = RalphConfig::default();
2223 config.cli.backend = "claude".to_string();
2224 config.event_loop.context_window_tokens = Some(1_000_000);
2225 assert_eq!(resolve_context_window(&config), 1_000_000);
2226 }
2227
2228 #[test]
2229 fn test_resolve_context_window_claude_default() {
2230 let mut config = RalphConfig::default();
2231 config.cli.backend = "claude".to_string();
2232 config.event_loop.context_window_tokens = None;
2233 assert_eq!(resolve_context_window(&config), 200_000);
2234 }
2235
2236 #[test]
2237 fn test_resolve_context_window_pi_default() {
2238 let mut config = RalphConfig::default();
2239 config.cli.backend = "pi".to_string();
2240 config.event_loop.context_window_tokens = None;
2241 assert_eq!(resolve_context_window(&config), 200_000);
2242 }
2243
2244 #[test]
2245 fn test_resolve_context_window_unknown_backend_is_zero() {
2246 let mut config = RalphConfig::default();
2247 config.cli.backend = "gemini".to_string();
2248 config.event_loop.context_window_tokens = None;
2249 assert_eq!(resolve_context_window(&config), 0);
2250 }
2251
2252 #[test]
2253 fn test_resolve_context_window_override_beats_unknown_backend() {
2254 let mut config = RalphConfig::default();
2256 config.cli.backend = "kiro".to_string();
2257 config.event_loop.context_window_tokens = Some(128_000);
2258 assert_eq!(resolve_context_window(&config), 128_000);
2259 }
2260
2261 #[test]
2262 fn test_resolve_context_window_for_effective_hat_backend() {
2263 let mut config = RalphConfig::default();
2264 config.cli.backend = "kiro".to_string();
2265 config.event_loop.context_window_tokens = None;
2266
2267 assert_eq!(resolve_context_window(&config), 0);
2268 assert_eq!(resolve_context_window_for_backend(&config, "pi"), 200_000);
2269 assert_eq!(
2270 resolve_context_window_for_backend(&config, "claude"),
2271 200_000
2272 );
2273 assert_eq!(resolve_context_window_for_backend(&config, "gemini"), 0);
2274
2275 config.event_loop.context_window_tokens = Some(1_000_000);
2276 assert_eq!(
2277 resolve_context_window_for_backend(&config, "gemini"),
2278 1_000_000
2279 );
2280 }
2281
2282 #[test]
2283 fn test_parse_yaml_with_custom_hats() {
2284 let yaml = r#"
2285event_loop:
2286 prompt_file: "TASK.md"
2287 completion_promise: "DONE"
2288 max_iterations: 50
2289cli:
2290 backend: "claude"
2291hats:
2292 implementer:
2293 name: "Implementer"
2294 triggers: ["task.*", "review.done"]
2295 publishes: ["impl.done"]
2296 instructions: "You are the implementation agent."
2297"#;
2298 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2299 assert_eq!(config.hats.len(), 1);
2301 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2302
2303 let hat = config.hats.get("implementer").unwrap();
2304 assert_eq!(hat.triggers.len(), 2);
2305 }
2306
2307 #[test]
2308 fn test_preflight_config_deserialize() {
2309 let yaml = r#"
2310features:
2311 preflight:
2312 enabled: true
2313 strict: true
2314 skip: ["telegram", "git"]
2315"#;
2316 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2317 assert!(config.features.preflight.enabled);
2318 assert!(config.features.preflight.strict);
2319 assert_eq!(
2320 config.features.preflight.skip,
2321 vec!["telegram".to_string(), "git".to_string()]
2322 );
2323 }
2324
2325 #[test]
2326 fn test_parse_yaml_v1_format() {
2327 let yaml = r#"
2329agent: gemini
2330prompt_file: "TASK.md"
2331completion_promise: "RALPH_DONE"
2332max_iterations: 75
2333max_runtime: 7200
2334max_cost: 10.0
2335verbose: true
2336"#;
2337 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2338
2339 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
2345
2346 assert_eq!(config.cli.backend, "gemini");
2348 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2349 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
2350 assert_eq!(config.event_loop.max_iterations, 75);
2351 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
2352 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
2353 assert!(config.verbose);
2354 }
2355
2356 #[test]
2357 fn test_agent_priority() {
2358 let yaml = r"
2359agent: auto
2360agent_priority: [gemini, claude, codex]
2361";
2362 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2363 let priority = config.get_agent_priority();
2364 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2365 }
2366
2367 #[test]
2368 fn test_default_agent_priority() {
2369 let config = RalphConfig::default();
2370 let priority = config.get_agent_priority();
2371 assert_eq!(
2372 priority,
2373 vec![
2374 "claude", "kiro", "kiro-acp", "gemini", "codex", "forge", "amp", "copilot",
2375 "opencode", "pi", "roo"
2376 ]
2377 );
2378 }
2379
2380 #[test]
2381 fn test_validate_deferred_features() {
2382 let yaml = r"
2383archive_prompts: true
2384enable_metrics: true
2385";
2386 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2387 let warnings = config.validate().unwrap();
2388
2389 assert_eq!(warnings.len(), 2);
2390 assert!(warnings
2391 .iter()
2392 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2393 assert!(warnings
2394 .iter()
2395 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2396 }
2397
2398 #[test]
2399 fn test_validate_dropped_fields() {
2400 let yaml = r#"
2401max_tokens: 4096
2402retry_delay: 5
2403adapters:
2404 claude:
2405 tool_permissions: ["read", "write"]
2406"#;
2407 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2408 let warnings = config.validate().unwrap();
2409
2410 assert_eq!(warnings.len(), 3);
2411 assert!(warnings.iter().any(
2412 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2413 ));
2414 assert!(warnings.iter().any(
2415 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2416 ));
2417 assert!(warnings
2418 .iter()
2419 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2420 }
2421
2422 #[test]
2423 fn test_suppress_warnings() {
2424 let yaml = r"
2425_suppress_warnings: true
2426archive_prompts: true
2427max_tokens: 4096
2428";
2429 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2430 let warnings = config.validate().unwrap();
2431
2432 assert!(warnings.is_empty());
2434 }
2435
2436 #[test]
2437 fn test_adapter_settings() {
2438 let yaml = r"
2439adapters:
2440 claude:
2441 timeout: 600
2442 enabled: true
2443 gemini:
2444 timeout: 300
2445 enabled: false
2446";
2447 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2448
2449 let claude = config.adapter_settings("claude");
2450 assert_eq!(claude.timeout, 600);
2451 assert!(claude.enabled);
2452
2453 let gemini = config.adapter_settings("gemini");
2454 assert_eq!(gemini.timeout, 300);
2455 assert!(!gemini.enabled);
2456 }
2457
2458 #[test]
2459 fn test_unknown_fields_ignored() {
2460 let yaml = r#"
2462agent: claude
2463unknown_field: "some value"
2464future_feature: true
2465"#;
2466 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2467 assert!(result.is_ok());
2469 }
2470
2471 #[test]
2472 fn test_custom_backend_args_shorthand() {
2473 let yaml = r#"
2474hats:
2475 opencode_builder:
2476 name: "Opencode"
2477 description: "Opencode hat"
2478 backend: "opencode"
2479 args: ["-m", "model"]
2480"#;
2481 let config = RalphConfig::parse_yaml(yaml).unwrap();
2482 let hat = config.hats.get("opencode_builder").unwrap();
2483 assert!(hat.backend_args.is_some());
2484 assert_eq!(
2485 hat.backend_args.as_ref().unwrap(),
2486 &vec!["-m".to_string(), "model".to_string()]
2487 );
2488 }
2489
2490 #[test]
2491 fn test_custom_backend_args_explicit_key() {
2492 let yaml = r#"
2493hats:
2494 opencode_builder:
2495 name: "Opencode"
2496 description: "Opencode hat"
2497 backend: "opencode"
2498 backend_args: ["-m", "model"]
2499"#;
2500 let config = RalphConfig::parse_yaml(yaml).unwrap();
2501 let hat = config.hats.get("opencode_builder").unwrap();
2502 assert!(hat.backend_args.is_some());
2503 assert_eq!(
2504 hat.backend_args.as_ref().unwrap(),
2505 &vec!["-m".to_string(), "model".to_string()]
2506 );
2507 }
2508
2509 #[test]
2510 fn test_project_key_rejected() {
2511 let yaml = r#"
2512project:
2513 specs_dir: "my_specs"
2514"#;
2515 let result = RalphConfig::parse_yaml(yaml);
2516 assert!(result.is_err());
2517 assert!(matches!(
2518 result.unwrap_err(),
2519 ConfigError::DeprecatedProjectKey
2520 ));
2521 }
2522
2523 #[test]
2524 fn test_ambiguous_routing_rejected() {
2525 let yaml = r#"
2528hats:
2529 planner:
2530 name: "Planner"
2531 description: "Plans tasks"
2532 triggers: ["planning.start", "build.done"]
2533 builder:
2534 name: "Builder"
2535 description: "Builds code"
2536 triggers: ["build.task", "build.done"]
2537"#;
2538 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2539 let result = config.validate();
2540
2541 assert!(result.is_err());
2542 let err = result.unwrap_err();
2543 assert!(
2544 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2545 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2546 err
2547 );
2548 }
2549
2550 #[test]
2551 fn test_unique_triggers_accepted() {
2552 let yaml = r#"
2555hats:
2556 planner:
2557 name: "Planner"
2558 description: "Plans tasks"
2559 triggers: ["planning.start", "build.done", "build.blocked"]
2560 builder:
2561 name: "Builder"
2562 description: "Builds code"
2563 triggers: ["build.task"]
2564"#;
2565 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2566 let result = config.validate();
2567
2568 assert!(
2569 result.is_ok(),
2570 "Expected valid config, got: {:?}",
2571 result.unwrap_err()
2572 );
2573 }
2574
2575 #[test]
2576 fn test_reserved_trigger_task_start_rejected() {
2577 let yaml = r#"
2579hats:
2580 my_hat:
2581 name: "My Hat"
2582 description: "Test hat"
2583 triggers: ["task.start"]
2584"#;
2585 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2586 let result = config.validate();
2587
2588 assert!(result.is_err());
2589 let err = result.unwrap_err();
2590 assert!(
2591 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2592 if trigger == "task.start" && hat == "my_hat"),
2593 "Expected ReservedTrigger error for 'task.start', got: {:?}",
2594 err
2595 );
2596 }
2597
2598 #[test]
2599 fn test_reserved_trigger_task_resume_rejected() {
2600 let yaml = r#"
2602hats:
2603 my_hat:
2604 name: "My Hat"
2605 description: "Test hat"
2606 triggers: ["task.resume", "other.event"]
2607"#;
2608 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2609 let result = config.validate();
2610
2611 assert!(result.is_err());
2612 let err = result.unwrap_err();
2613 assert!(
2614 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2615 if trigger == "task.resume" && hat == "my_hat"),
2616 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2617 err
2618 );
2619 }
2620
2621 #[test]
2622 fn test_missing_description_rejected() {
2623 let yaml = r#"
2625hats:
2626 my_hat:
2627 name: "My Hat"
2628 triggers: ["build.task"]
2629"#;
2630 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2631 let result = config.validate();
2632
2633 assert!(result.is_err());
2634 let err = result.unwrap_err();
2635 assert!(
2636 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2637 "Expected MissingDescription error, got: {:?}",
2638 err
2639 );
2640 }
2641
2642 #[test]
2643 fn test_empty_description_rejected() {
2644 let yaml = r#"
2646hats:
2647 my_hat:
2648 name: "My Hat"
2649 description: " "
2650 triggers: ["build.task"]
2651"#;
2652 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2653 let result = config.validate();
2654
2655 assert!(result.is_err());
2656 let err = result.unwrap_err();
2657 assert!(
2658 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2659 "Expected MissingDescription error for empty description, got: {:?}",
2660 err
2661 );
2662 }
2663
2664 #[test]
2665 fn test_core_config_defaults() {
2666 let config = RalphConfig::default();
2667 assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
2668 assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
2669 assert!(config.core.scratchpad.enabled);
2670 assert_eq!(config.core.specs_dir, ".ralph/specs/");
2671 assert_eq!(config.core.guardrails.len(), 6);
2673 assert!(config.core.guardrails[0].contains("Fresh context"));
2674 assert!(config.core.guardrails[1].contains("search first"));
2675 assert!(config.core.guardrails[2].contains("Backpressure"));
2676 assert!(config.core.guardrails[3].contains("strongest available harness"));
2677 assert!(config.core.guardrails[4].contains("Confidence protocol"));
2678 assert!(config.core.guardrails[5].contains("Commit atomically"));
2679 }
2680
2681 #[test]
2682 fn test_core_config_customizable() {
2683 let yaml = r#"
2684core:
2685 scratchpad: ".workspace/plan.md"
2686 specs_dir: "./specifications/"
2687"#;
2688 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2689 assert_eq!(config.core.scratchpad.path, ".workspace/plan.md");
2690 assert!(config.core.scratchpad.enabled);
2691 assert_eq!(config.core.specs_dir, "./specifications/");
2692 assert_eq!(config.core.guardrails.len(), 6);
2694 }
2695
2696 #[test]
2697 fn test_core_config_custom_guardrails() {
2698 let yaml = r#"
2699core:
2700 scratchpad: ".ralph/agent/scratchpad.md"
2701 specs_dir: "./specs/"
2702 guardrails:
2703 - "Custom rule one"
2704 - "Custom rule two"
2705"#;
2706 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2707 assert_eq!(config.core.guardrails.len(), 2);
2708 assert_eq!(config.core.guardrails[0], "Custom rule one");
2709 assert_eq!(config.core.guardrails[1], "Custom rule two");
2710 }
2711
2712 #[test]
2713 fn test_prompt_and_prompt_file_mutually_exclusive() {
2714 let yaml = r#"
2716event_loop:
2717 prompt: "inline text"
2718 prompt_file: "custom.md"
2719"#;
2720 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2721 let result = config.validate();
2722
2723 assert!(result.is_err());
2724 let err = result.unwrap_err();
2725 assert!(
2726 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2727 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2728 "Expected MutuallyExclusive error, got: {:?}",
2729 err
2730 );
2731 }
2732
2733 #[test]
2734 fn test_prompt_with_default_prompt_file_allowed() {
2735 let yaml = r#"
2737event_loop:
2738 prompt: "inline text"
2739"#;
2740 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2741 let result = config.validate();
2742
2743 assert!(
2744 result.is_ok(),
2745 "Should allow inline prompt with default prompt_file"
2746 );
2747 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2748 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2749 }
2750
2751 #[test]
2752 fn test_custom_backend_requires_command() {
2753 let yaml = r#"
2755cli:
2756 backend: "custom"
2757"#;
2758 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2759 let result = config.validate();
2760
2761 assert!(result.is_err());
2762 let err = result.unwrap_err();
2763 assert!(
2764 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2765 "Expected CustomBackendRequiresCommand error, got: {:?}",
2766 err
2767 );
2768 }
2769
2770 #[test]
2771 fn test_empty_completion_promise_rejected() {
2772 let yaml = r#"
2773event_loop:
2774 completion_promise: " "
2775"#;
2776 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2777 let result = config.validate();
2778
2779 assert!(result.is_err());
2780 let err = result.unwrap_err();
2781 assert!(
2782 matches!(&err, ConfigError::InvalidCompletionPromise),
2783 "Expected InvalidCompletionPromise error, got: {:?}",
2784 err
2785 );
2786 }
2787
2788 #[test]
2789 fn test_custom_backend_with_empty_command_errors() {
2790 let yaml = r#"
2792cli:
2793 backend: "custom"
2794 command: ""
2795"#;
2796 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2797 let result = config.validate();
2798
2799 assert!(result.is_err());
2800 let err = result.unwrap_err();
2801 assert!(
2802 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2803 "Expected CustomBackendRequiresCommand error, got: {:?}",
2804 err
2805 );
2806 }
2807
2808 #[test]
2809 fn test_custom_backend_with_command_succeeds() {
2810 let yaml = r#"
2812cli:
2813 backend: "custom"
2814 command: "my-agent"
2815"#;
2816 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2817 let result = config.validate();
2818
2819 assert!(
2820 result.is_ok(),
2821 "Should allow custom backend with command: {:?}",
2822 result.unwrap_err()
2823 );
2824 }
2825
2826 #[test]
2827 fn test_custom_backend_requires_command_message_actionable() {
2828 let err = ConfigError::CustomBackendRequiresCommand;
2829 let msg = err.to_string();
2830 assert!(msg.contains("cli.command"));
2831 assert!(msg.contains("ralph init --backend custom"));
2832 assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2833 }
2834
2835 #[test]
2836 fn test_reserved_trigger_message_actionable() {
2837 let err = ConfigError::ReservedTrigger {
2838 trigger: "task.start".to_string(),
2839 hat: "builder".to_string(),
2840 };
2841 let msg = err.to_string();
2842 assert!(msg.contains("Reserved trigger"));
2843 assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2844 }
2845
2846 #[test]
2847 fn test_prompt_file_with_no_inline_allowed() {
2848 let yaml = r#"
2850event_loop:
2851 prompt_file: "custom.md"
2852"#;
2853 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2854 let result = config.validate();
2855
2856 assert!(
2857 result.is_ok(),
2858 "Should allow prompt_file without inline prompt"
2859 );
2860 assert_eq!(config.event_loop.prompt, None);
2861 assert_eq!(config.event_loop.prompt_file, "custom.md");
2862 }
2863
2864 #[test]
2865 fn test_default_prompt_file_value() {
2866 let config = RalphConfig::default();
2867 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2868 assert_eq!(config.event_loop.prompt, None);
2869 }
2870
2871 #[test]
2872 fn test_tui_config_default() {
2873 let config = RalphConfig::default();
2874 assert_eq!(config.tui.prefix_key, "ctrl-a");
2875 }
2876
2877 #[test]
2878 fn test_tui_config_parse_ctrl_b() {
2879 let yaml = r#"
2880tui:
2881 prefix_key: "ctrl-b"
2882"#;
2883 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2884 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2885
2886 use crossterm::event::{KeyCode, KeyModifiers};
2887 assert_eq!(key_code, KeyCode::Char('b'));
2888 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2889 }
2890
2891 #[test]
2892 fn test_tui_config_parse_invalid_format() {
2893 let tui_config = TuiConfig {
2894 prefix_key: "invalid".to_string(),
2895 };
2896 let result = tui_config.parse_prefix();
2897 assert!(result.is_err());
2898 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2899 }
2900
2901 #[test]
2902 fn test_tui_config_parse_invalid_modifier() {
2903 let tui_config = TuiConfig {
2904 prefix_key: "alt-a".to_string(),
2905 };
2906 let result = tui_config.parse_prefix();
2907 assert!(result.is_err());
2908 assert!(result.unwrap_err().contains("Invalid modifier"));
2909 }
2910
2911 #[test]
2912 fn test_tui_config_parse_invalid_key() {
2913 let tui_config = TuiConfig {
2914 prefix_key: "ctrl-abc".to_string(),
2915 };
2916 let result = tui_config.parse_prefix();
2917 assert!(result.is_err());
2918 assert!(result.unwrap_err().contains("Invalid key"));
2919 }
2920
2921 #[test]
2922 fn test_hat_backend_named() {
2923 let yaml = r#""claude""#;
2924 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2925 assert_eq!(backend.to_cli_backend(), "claude");
2926 match backend {
2927 HatBackend::Named(name) => assert_eq!(name, "claude"),
2928 _ => panic!("Expected Named variant"),
2929 }
2930 }
2931
2932 #[test]
2933 fn test_hat_backend_kiro_agent() {
2934 let yaml = r#"
2935type: "kiro"
2936agent: "builder"
2937"#;
2938 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2939 assert_eq!(backend.to_cli_backend(), "kiro");
2940 match backend {
2941 HatBackend::KiroAgent {
2942 backend_type,
2943 agent,
2944 args,
2945 } => {
2946 assert_eq!(backend_type, "kiro");
2947 assert_eq!(agent, "builder");
2948 assert!(args.is_empty());
2949 }
2950 _ => panic!("Expected KiroAgent variant"),
2951 }
2952 }
2953
2954 #[test]
2955 fn test_hat_backend_kiro_agent_with_args() {
2956 let yaml = r#"
2957type: "kiro"
2958agent: "builder"
2959args: ["--verbose", "--debug"]
2960"#;
2961 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2962 assert_eq!(backend.to_cli_backend(), "kiro");
2963 match backend {
2964 HatBackend::KiroAgent {
2965 backend_type,
2966 agent,
2967 args,
2968 } => {
2969 assert_eq!(backend_type, "kiro");
2970 assert_eq!(agent, "builder");
2971 assert_eq!(args, vec!["--verbose", "--debug"]);
2972 }
2973 _ => panic!("Expected KiroAgent variant"),
2974 }
2975 }
2976
2977 #[test]
2978 fn test_hat_backend_named_with_args() {
2979 let yaml = r#"
2980type: "claude"
2981args: ["--model", "claude-sonnet-4"]
2982"#;
2983 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2984 assert_eq!(backend.to_cli_backend(), "claude");
2985 match backend {
2986 HatBackend::NamedWithArgs { backend_type, args } => {
2987 assert_eq!(backend_type, "claude");
2988 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2989 }
2990 _ => panic!("Expected NamedWithArgs variant"),
2991 }
2992 }
2993
2994 #[test]
2995 fn test_hat_backend_named_with_args_empty() {
2996 let yaml = r#"
2998type: "gemini"
2999"#;
3000 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
3001 assert_eq!(backend.to_cli_backend(), "gemini");
3002 match backend {
3003 HatBackend::NamedWithArgs { backend_type, args } => {
3004 assert_eq!(backend_type, "gemini");
3005 assert!(args.is_empty());
3006 }
3007 _ => panic!("Expected NamedWithArgs variant"),
3008 }
3009 }
3010
3011 #[test]
3012 fn test_hat_backend_custom() {
3013 let yaml = r#"
3014command: "/usr/bin/my-agent"
3015args: ["--flag", "value"]
3016"#;
3017 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
3018 assert_eq!(backend.to_cli_backend(), "custom");
3019 match backend {
3020 HatBackend::Custom { command, args } => {
3021 assert_eq!(command, "/usr/bin/my-agent");
3022 assert_eq!(args, vec!["--flag", "value"]);
3023 }
3024 _ => panic!("Expected Custom variant"),
3025 }
3026 }
3027
3028 #[test]
3029 fn test_hat_config_with_backend() {
3030 let yaml = r#"
3031name: "Custom Builder"
3032triggers: ["build.task"]
3033publishes: ["build.done"]
3034instructions: "Build stuff"
3035backend: "gemini"
3036default_publishes: "task.done"
3037"#;
3038 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
3039 assert_eq!(hat.name, "Custom Builder");
3040 assert!(hat.backend.is_some());
3041 match hat.backend.unwrap() {
3042 HatBackend::Named(name) => assert_eq!(name, "gemini"),
3043 _ => panic!("Expected Named backend"),
3044 }
3045 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
3046 }
3047
3048 #[test]
3049 fn test_hat_config_without_backend() {
3050 let yaml = r#"
3051name: "Default Hat"
3052triggers: ["task.start"]
3053publishes: ["task.done"]
3054instructions: "Do work"
3055"#;
3056 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
3057 assert_eq!(hat.name, "Default Hat");
3058 assert!(hat.backend.is_none());
3059 assert!(hat.default_publishes.is_none());
3060 }
3061
3062 #[test]
3063 fn test_mixed_backends_config() {
3064 let yaml = r#"
3065event_loop:
3066 prompt_file: "TASK.md"
3067 max_iterations: 50
3068
3069cli:
3070 backend: "claude"
3071
3072hats:
3073 planner:
3074 name: "Planner"
3075 triggers: ["task.start"]
3076 publishes: ["build.task"]
3077 instructions: "Plan the work"
3078 backend: "claude"
3079
3080 builder:
3081 name: "Builder"
3082 triggers: ["build.task"]
3083 publishes: ["build.done"]
3084 instructions: "Build the thing"
3085 backend:
3086 type: "kiro"
3087 agent: "builder"
3088
3089 reviewer:
3090 name: "Reviewer"
3091 triggers: ["build.done"]
3092 publishes: ["review.complete"]
3093 instructions: "Review the work"
3094 backend:
3095 command: "/usr/local/bin/custom-agent"
3096 args: ["--mode", "review"]
3097 default_publishes: "review.complete"
3098"#;
3099 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3100 assert_eq!(config.hats.len(), 3);
3101
3102 let planner = config.hats.get("planner").unwrap();
3104 assert!(planner.backend.is_some());
3105 match planner.backend.as_ref().unwrap() {
3106 HatBackend::Named(name) => assert_eq!(name, "claude"),
3107 _ => panic!("Expected Named backend for planner"),
3108 }
3109
3110 let builder = config.hats.get("builder").unwrap();
3112 assert!(builder.backend.is_some());
3113 match builder.backend.as_ref().unwrap() {
3114 HatBackend::KiroAgent {
3115 backend_type,
3116 agent,
3117 args,
3118 } => {
3119 assert_eq!(backend_type, "kiro");
3120 assert_eq!(agent, "builder");
3121 assert!(args.is_empty());
3122 }
3123 _ => panic!("Expected KiroAgent backend for builder"),
3124 }
3125
3126 let reviewer = config.hats.get("reviewer").unwrap();
3128 assert!(reviewer.backend.is_some());
3129 match reviewer.backend.as_ref().unwrap() {
3130 HatBackend::Custom { command, args } => {
3131 assert_eq!(command, "/usr/local/bin/custom-agent");
3132 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
3133 }
3134 _ => panic!("Expected Custom backend for reviewer"),
3135 }
3136 assert_eq!(
3137 reviewer.default_publishes,
3138 Some("review.complete".to_string())
3139 );
3140 }
3141
3142 #[test]
3143 fn test_features_config_auto_merge_defaults_to_false() {
3144 let config = RalphConfig::default();
3147 assert!(
3148 !config.features.auto_merge,
3149 "auto_merge should default to false"
3150 );
3151 }
3152
3153 #[test]
3154 fn test_features_config_auto_merge_from_yaml() {
3155 let yaml = r"
3157features:
3158 auto_merge: true
3159";
3160 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3161 assert!(
3162 config.features.auto_merge,
3163 "auto_merge should be true when configured"
3164 );
3165 }
3166
3167 #[test]
3168 fn test_features_config_auto_merge_false_from_yaml() {
3169 let yaml = r"
3171features:
3172 auto_merge: false
3173";
3174 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3175 assert!(
3176 !config.features.auto_merge,
3177 "auto_merge should be false when explicitly configured"
3178 );
3179 }
3180
3181 #[test]
3182 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
3183 let yaml = r"
3185features:
3186 parallel: false
3187 auto_merge: true
3188";
3189 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3190 assert!(!config.features.parallel, "parallel should be false");
3191 assert!(config.features.auto_merge, "auto_merge should be true");
3192 }
3193
3194 #[test]
3195 fn test_skills_config_defaults_when_absent() {
3196 let yaml = r"
3198agent: claude
3199";
3200 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3201 assert!(config.skills.enabled);
3202 assert!(config.skills.dirs.is_empty());
3203 assert!(config.skills.overrides.is_empty());
3204 }
3205
3206 #[test]
3207 fn test_skills_config_deserializes_all_fields() {
3208 let yaml = r#"
3209skills:
3210 enabled: true
3211 dirs:
3212 - ".claude/skills"
3213 - "/shared/skills"
3214 overrides:
3215 pdd:
3216 enabled: false
3217 memories:
3218 auto_inject: true
3219 hats: ["ralph"]
3220 backends: ["claude"]
3221 tags: ["core"]
3222"#;
3223 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3224 assert!(config.skills.enabled);
3225 assert_eq!(config.skills.dirs.len(), 2);
3226 assert_eq!(
3227 config.skills.dirs[0],
3228 std::path::PathBuf::from(".claude/skills")
3229 );
3230 assert_eq!(config.skills.overrides.len(), 2);
3231
3232 let pdd = config.skills.overrides.get("pdd").unwrap();
3233 assert_eq!(pdd.enabled, Some(false));
3234
3235 let memories = config.skills.overrides.get("memories").unwrap();
3236 assert_eq!(memories.auto_inject, Some(true));
3237 assert_eq!(memories.hats, vec!["ralph"]);
3238 assert_eq!(memories.backends, vec!["claude"]);
3239 assert_eq!(memories.tags, vec!["core"]);
3240 }
3241
3242 #[test]
3243 fn test_skills_config_disabled() {
3244 let yaml = r"
3245skills:
3246 enabled: false
3247";
3248 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3249 assert!(!config.skills.enabled);
3250 assert!(config.skills.dirs.is_empty());
3251 }
3252
3253 #[test]
3254 fn test_skill_override_partial_fields() {
3255 let yaml = r#"
3256skills:
3257 overrides:
3258 my-skill:
3259 hats: ["builder", "reviewer"]
3260"#;
3261 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3262 let override_ = config.skills.overrides.get("my-skill").unwrap();
3263 assert_eq!(override_.enabled, None);
3264 assert_eq!(override_.auto_inject, None);
3265 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
3266 assert!(override_.backends.is_empty());
3267 assert!(override_.tags.is_empty());
3268 }
3269
3270 #[test]
3271 fn test_hooks_config_valid_yaml_parses_and_validates() {
3272 let yaml = r#"
3273hooks:
3274 enabled: true
3275 defaults:
3276 timeout_seconds: 45
3277 max_output_bytes: 16384
3278 suspend_mode: wait_for_resume
3279 events:
3280 pre.loop.start:
3281 - name: env-guard
3282 command: ["./scripts/hooks/env-guard.sh", "--check"]
3283 on_error: block
3284 post.loop.complete:
3285 - name: notify
3286 command: ["./scripts/hooks/notify.sh"]
3287 on_error: warn
3288 mutate:
3289 enabled: true
3290 format: json
3291"#;
3292 let config = RalphConfig::parse_yaml(yaml).unwrap();
3293
3294 assert!(config.hooks.enabled);
3295 assert_eq!(config.hooks.defaults.timeout_seconds, 45);
3296 assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
3297 assert_eq!(config.hooks.events.len(), 2);
3298
3299 let warnings = config.validate().unwrap();
3300 assert!(warnings.is_empty());
3301 }
3302
3303 #[test]
3304 fn test_hooks_parse_rejects_invalid_phase_event_key() {
3305 let yaml = r#"
3306hooks:
3307 enabled: true
3308 events:
3309 pre.loop.launch:
3310 - name: bad-phase
3311 command: ["./scripts/hooks/bad-phase.sh"]
3312 on_error: warn
3313"#;
3314
3315 let result = RalphConfig::parse_yaml(yaml);
3316 assert!(result.is_err());
3317
3318 let err = result.unwrap_err();
3319 assert!(matches!(
3320 &err,
3321 ConfigError::InvalidHookPhaseEvent { phase_event }
3322 if phase_event == "pre.loop.launch"
3323 ));
3324 }
3325
3326 #[test]
3327 fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
3328 let yaml = r#"
3329hooks:
3330 enabled: true
3331 events:
3332 pre.backpressure.triggered:
3333 - name: unsupported-backpressure
3334 command: ["./scripts/hooks/backpressure.sh"]
3335 on_error: warn
3336"#;
3337
3338 let result = RalphConfig::parse_yaml(yaml);
3339 assert!(result.is_err());
3340
3341 let err = result.unwrap_err();
3342 assert!(matches!(
3343 &err,
3344 ConfigError::InvalidHookPhaseEvent { phase_event }
3345 if phase_event == "pre.backpressure.triggered"
3346 ));
3347
3348 let message = err.to_string();
3349 assert!(message.contains("Supported v1 phase-events"));
3350 assert!(message.contains("pre.plan.created"));
3351 assert!(message.contains("post.loop.error"));
3352 }
3353
3354 #[test]
3355 fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
3356 let yaml = r#"
3357hooks:
3358 enabled: true
3359 events:
3360 pre.loop.start:
3361 - name: bad-on-error
3362 command: ["./scripts/hooks/bad-on-error.sh"]
3363 on_error: explode
3364"#;
3365
3366 let result = RalphConfig::parse_yaml(yaml);
3367 assert!(result.is_err());
3368
3369 let err = result.unwrap_err();
3370 assert!(matches!(&err, ConfigError::Yaml(_)));
3371
3372 let message = err.to_string();
3373 assert!(message.contains("unknown variant `explode`"));
3374 assert!(message.contains("warn"));
3375 assert!(message.contains("block"));
3376 assert!(message.contains("suspend"));
3377 }
3378
3379 #[test]
3380 fn test_hooks_validate_rejects_missing_name() {
3381 let yaml = r#"
3382hooks:
3383 enabled: true
3384 events:
3385 pre.loop.start:
3386 - command: ["./scripts/hooks/no-name.sh"]
3387 on_error: block
3388"#;
3389 let config = RalphConfig::parse_yaml(yaml).unwrap();
3390
3391 let result = config.validate();
3392 assert!(result.is_err());
3393
3394 let err = result.unwrap_err();
3395 assert!(matches!(
3396 &err,
3397 ConfigError::HookValidation { field, .. }
3398 if field == "hooks.events.pre.loop.start[0].name"
3399 ));
3400 }
3401
3402 #[test]
3403 fn test_hooks_validate_rejects_missing_command() {
3404 let yaml = r"
3405hooks:
3406 enabled: true
3407 events:
3408 pre.loop.start:
3409 - name: missing-command
3410 on_error: block
3411";
3412 let config = RalphConfig::parse_yaml(yaml).unwrap();
3413
3414 let result = config.validate();
3415 assert!(result.is_err());
3416
3417 let err = result.unwrap_err();
3418 assert!(matches!(
3419 &err,
3420 ConfigError::HookValidation { field, .. }
3421 if field == "hooks.events.pre.loop.start[0].command"
3422 ));
3423 }
3424
3425 #[test]
3426 fn test_hooks_validate_rejects_missing_on_error() {
3427 let yaml = r#"
3428hooks:
3429 enabled: true
3430 events:
3431 pre.loop.start:
3432 - name: missing-on-error
3433 command: ["./scripts/hooks/no-on-error.sh"]
3434"#;
3435 let config = RalphConfig::parse_yaml(yaml).unwrap();
3436
3437 let result = config.validate();
3438 assert!(result.is_err());
3439
3440 let err = result.unwrap_err();
3441 assert!(matches!(
3442 &err,
3443 ConfigError::HookValidation { field, .. }
3444 if field == "hooks.events.pre.loop.start[0].on_error"
3445 ));
3446 }
3447
3448 #[test]
3449 fn test_hooks_validate_rejects_zero_timeout_seconds() {
3450 let yaml = r"
3451hooks:
3452 enabled: true
3453 defaults:
3454 timeout_seconds: 0
3455";
3456 let config = RalphConfig::parse_yaml(yaml).unwrap();
3457
3458 let result = config.validate();
3459 assert!(result.is_err());
3460
3461 let err = result.unwrap_err();
3462 assert!(matches!(
3463 &err,
3464 ConfigError::HookValidation { field, .. }
3465 if field == "hooks.defaults.timeout_seconds"
3466 ));
3467 }
3468
3469 #[test]
3470 fn test_hooks_validate_rejects_zero_max_output_bytes() {
3471 let yaml = r"
3472hooks:
3473 enabled: true
3474 defaults:
3475 max_output_bytes: 0
3476";
3477 let config = RalphConfig::parse_yaml(yaml).unwrap();
3478
3479 let result = config.validate();
3480 assert!(result.is_err());
3481
3482 let err = result.unwrap_err();
3483 assert!(matches!(
3484 &err,
3485 ConfigError::HookValidation { field, .. }
3486 if field == "hooks.defaults.max_output_bytes"
3487 ));
3488 }
3489
3490 #[test]
3491 fn test_hooks_validate_rejects_parallel_non_v1_field() {
3492 let yaml = r"
3493hooks:
3494 enabled: true
3495 parallel: true
3496";
3497 let config = RalphConfig::parse_yaml(yaml).unwrap();
3498
3499 let result = config.validate();
3500 assert!(result.is_err());
3501
3502 let err = result.unwrap_err();
3503 assert!(matches!(
3504 &err,
3505 ConfigError::UnsupportedHookField { field, .. }
3506 if field == "hooks.parallel"
3507 ));
3508 }
3509
3510 #[test]
3511 fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3512 let yaml = r#"
3513hooks:
3514 enabled: true
3515 events:
3516 pre.loop.start:
3517 - name: global-scope
3518 command: ["./scripts/hooks/global.sh"]
3519 on_error: warn
3520 scope: global
3521"#;
3522 let config = RalphConfig::parse_yaml(yaml).unwrap();
3523
3524 let result = config.validate();
3525 assert!(result.is_err());
3526
3527 let err = result.unwrap_err();
3528 assert!(matches!(
3529 &err,
3530 ConfigError::UnsupportedHookField { field, .. }
3531 if field == "hooks.events.pre.loop.start[0].scope"
3532 ));
3533 }
3534
3535 #[test]
3540 fn test_robot_config_defaults_disabled() {
3541 let config = RalphConfig::default();
3542 assert!(!config.robot.enabled);
3543 assert_eq!(config.robot.mode, RobotMode::Telegram);
3544 assert!(config.robot.timeout_seconds.is_none());
3545 assert!(config.robot.telegram.is_none());
3546 }
3547
3548 #[test]
3549 fn test_robot_config_absent_parses_as_default() {
3550 let yaml = r"
3552agent: claude
3553";
3554 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3555 assert!(!config.robot.enabled);
3556 assert_eq!(config.robot.mode, RobotMode::Telegram);
3557 assert!(config.robot.timeout_seconds.is_none());
3558 }
3559
3560 #[test]
3561 fn test_robot_config_valid_full() {
3562 let yaml = r#"
3563RObot:
3564 enabled: true
3565 timeout_seconds: 300
3566 telegram:
3567 bot_token: "123456:ABC-DEF"
3568"#;
3569 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3570 assert!(config.robot.enabled);
3571 assert_eq!(config.robot.mode, RobotMode::Telegram);
3572 assert_eq!(config.robot.timeout_seconds, Some(300));
3573 let telegram = config.robot.telegram.as_ref().unwrap();
3574 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3575
3576 assert!(config.validate().is_ok());
3578 }
3579
3580 #[test]
3581 fn test_robot_config_web_mode_does_not_require_telegram() {
3582 let yaml = r"
3583RObot:
3584 enabled: true
3585 mode: web
3586 timeout_seconds: 0
3587";
3588 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3589 assert!(config.robot.enabled);
3590 assert_eq!(config.robot.mode, RobotMode::Web);
3591 assert_eq!(config.robot.timeout_seconds, Some(0));
3592 assert!(config.robot.telegram.is_none());
3593 assert!(config.validate().is_ok());
3594 }
3595
3596 #[test]
3597 fn test_robot_config_disabled_skips_validation() {
3598 let yaml = r"
3600RObot:
3601 enabled: false
3602";
3603 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3604 assert!(!config.robot.enabled);
3605 assert!(config.validate().is_ok());
3606 }
3607
3608 #[test]
3609 fn test_robot_config_enabled_missing_timeout_fails() {
3610 let yaml = r#"
3611RObot:
3612 enabled: true
3613 telegram:
3614 bot_token: "123456:ABC-DEF"
3615"#;
3616 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3617 let result = config.validate();
3618 assert!(result.is_err());
3619 let err = result.unwrap_err();
3620 assert!(
3621 matches!(&err, ConfigError::RobotMissingField { field, .. }
3622 if field == "RObot.timeout_seconds"),
3623 "Expected RobotMissingField for timeout_seconds, got: {:?}",
3624 err
3625 );
3626 }
3627
3628 #[test]
3629 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3630 let robot = RobotConfig {
3632 enabled: true,
3633 mode: RobotMode::Telegram,
3634 timeout_seconds: None,
3635 checkin_interval_seconds: None,
3636 telegram: None,
3637 };
3638 let result = robot.validate();
3639 assert!(result.is_err());
3640 let err = result.unwrap_err();
3641 assert!(
3642 matches!(&err, ConfigError::RobotMissingField { field, .. }
3643 if field == "RObot.timeout_seconds"),
3644 "Expected timeout validation failure first, got: {:?}",
3645 err
3646 );
3647 }
3648
3649 #[test]
3650 fn test_robot_config_resolve_bot_token_from_config() {
3651 let config = RobotConfig {
3655 enabled: true,
3656 mode: RobotMode::Telegram,
3657 timeout_seconds: Some(300),
3658 checkin_interval_seconds: None,
3659 telegram: Some(TelegramBotConfig {
3660 bot_token: Some("config-token".to_string()),
3661 api_url: None,
3662 }),
3663 };
3664
3665 let resolved = config.resolve_bot_token();
3668 assert!(resolved.is_some());
3671 }
3672
3673 #[test]
3674 fn test_robot_config_resolve_bot_token_none_without_config() {
3675 let config = RobotConfig {
3677 enabled: true,
3678 mode: RobotMode::Telegram,
3679 timeout_seconds: Some(300),
3680 checkin_interval_seconds: None,
3681 telegram: None,
3682 };
3683
3684 let resolved = config.resolve_bot_token();
3687 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3688 assert!(resolved.is_none());
3689 }
3690 }
3691
3692 #[test]
3693 fn test_robot_config_validate_with_config_token() {
3694 let robot = RobotConfig {
3696 enabled: true,
3697 mode: RobotMode::Telegram,
3698 timeout_seconds: Some(300),
3699 checkin_interval_seconds: None,
3700 telegram: Some(TelegramBotConfig {
3701 bot_token: Some("test-token".to_string()),
3702 api_url: None,
3703 }),
3704 };
3705 assert!(robot.validate().is_ok());
3706 }
3707
3708 #[test]
3709 fn test_robot_config_validate_missing_telegram_section() {
3710 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3713 return;
3714 }
3715
3716 let robot = RobotConfig {
3717 enabled: true,
3718 mode: RobotMode::Telegram,
3719 timeout_seconds: Some(300),
3720 checkin_interval_seconds: None,
3721 telegram: None,
3722 };
3723 let result = robot.validate();
3724 assert!(result.is_err());
3725 let err = result.unwrap_err();
3726 assert!(
3727 matches!(&err, ConfigError::RobotMissingField { field, .. }
3728 if field == "RObot.telegram.bot_token"),
3729 "Expected bot_token validation failure, got: {:?}",
3730 err
3731 );
3732 }
3733
3734 #[test]
3735 fn test_robot_config_validate_empty_bot_token() {
3736 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3739 return;
3740 }
3741
3742 let robot = RobotConfig {
3743 enabled: true,
3744 mode: RobotMode::Telegram,
3745 timeout_seconds: Some(300),
3746 checkin_interval_seconds: None,
3747 telegram: Some(TelegramBotConfig {
3748 bot_token: None,
3749 api_url: None,
3750 }),
3751 };
3752 let result = robot.validate();
3753 assert!(result.is_err());
3754 let err = result.unwrap_err();
3755 assert!(
3756 matches!(&err, ConfigError::RobotMissingField { field, .. }
3757 if field == "RObot.telegram.bot_token"),
3758 "Expected bot_token validation failure, got: {:?}",
3759 err
3760 );
3761 }
3762
3763 #[test]
3764 fn test_extra_instructions_merged_during_normalize() {
3765 let yaml = r#"
3766_fragments:
3767 shared_protocol: &shared_protocol |
3768 ### Shared Protocol
3769 Follow this protocol.
3770
3771hats:
3772 builder:
3773 name: "Builder"
3774 triggers: ["build.start"]
3775 instructions: |
3776 ## BUILDER MODE
3777 Build things.
3778 extra_instructions:
3779 - *shared_protocol
3780"#;
3781 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3782 let hat = config.hats.get("builder").unwrap();
3783
3784 assert_eq!(hat.extra_instructions.len(), 1);
3786 assert!(!hat.instructions.contains("Shared Protocol"));
3787
3788 config.normalize();
3789
3790 let hat = config.hats.get("builder").unwrap();
3791 assert!(hat.extra_instructions.is_empty());
3793 assert!(hat.instructions.contains("## BUILDER MODE"));
3794 assert!(hat.instructions.contains("### Shared Protocol"));
3795 assert!(hat.instructions.contains("Follow this protocol."));
3796 }
3797
3798 #[test]
3799 fn test_extra_instructions_empty_by_default() {
3800 let yaml = r#"
3801hats:
3802 simple:
3803 name: "Simple"
3804 triggers: ["start"]
3805 instructions: "Do the thing."
3806"#;
3807 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3808 let hat = config.hats.get("simple").unwrap();
3809 assert!(hat.extra_instructions.is_empty());
3810 }
3811
3812 #[test]
3816 fn test_scratchpad_legacy_plain_string() {
3817 let yaml = r#"
3818core:
3819 scratchpad: ".workspace/plan.md"
3820"#;
3821 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3822 assert_eq!(
3823 config.core.scratchpad,
3824 ScratchpadConfig {
3825 enabled: true,
3826 path: ".workspace/plan.md".to_string()
3827 }
3828 );
3829 }
3830
3831 #[test]
3833 fn test_scratchpad_structured_config() {
3834 let yaml = r#"
3835core:
3836 scratchpad:
3837 enabled: true
3838 path: ".ralph/agent/scratchpad.md"
3839"#;
3840 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3841 assert_eq!(
3842 config.core.scratchpad,
3843 ScratchpadConfig {
3844 enabled: true,
3845 path: ".ralph/agent/scratchpad.md".to_string()
3846 }
3847 );
3848 }
3849
3850 #[test]
3852 fn test_scratchpad_structured_disabled() {
3853 let yaml = r"
3854core:
3855 scratchpad:
3856 enabled: false
3857";
3858 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3859 assert!(!config.core.scratchpad.enabled);
3860 assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
3861 }
3862
3863 #[test]
3865 fn test_scratchpad_default_config() {
3866 let config = RalphConfig::default();
3867 assert_eq!(
3868 config.core.scratchpad,
3869 ScratchpadConfig {
3870 enabled: true,
3871 path: ".ralph/agent/scratchpad.md".to_string()
3872 }
3873 );
3874 }
3875
3876 #[test]
3878 fn test_hat_scratchpad_plain_string() {
3879 let yaml = r#"
3880hats:
3881 planner:
3882 name: "Planner"
3883 triggers: ["plan.start"]
3884 scratchpad: ".ralph/agent/planner.md"
3885"#;
3886 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3887 let hat = config.hats.get("planner").unwrap();
3888 assert_eq!(
3889 hat.scratchpad,
3890 Some(ScratchpadConfig {
3891 enabled: true,
3892 path: ".ralph/agent/planner.md".to_string()
3893 })
3894 );
3895 }
3896
3897 #[test]
3899 fn test_hat_scratchpad_disabled() {
3900 let yaml = r#"
3901hats:
3902 validator:
3903 name: "Validator"
3904 triggers: ["validate.start"]
3905 scratchpad:
3906 enabled: false
3907"#;
3908 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3909 let hat = config.hats.get("validator").unwrap();
3910 assert_eq!(
3911 hat.scratchpad,
3912 Some(ScratchpadConfig {
3913 enabled: false,
3914 path: ".ralph/agent/scratchpad.md".to_string()
3915 })
3916 );
3917 }
3918
3919 #[test]
3921 fn test_hat_scratchpad_custom_path() {
3922 let yaml = r#"
3923hats:
3924 builder:
3925 name: "Builder"
3926 triggers: ["build.start"]
3927 scratchpad:
3928 path: ".ralph/agent/builder.md"
3929"#;
3930 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3931 let hat = config.hats.get("builder").unwrap();
3932 assert_eq!(
3933 hat.scratchpad,
3934 Some(ScratchpadConfig {
3935 enabled: true,
3936 path: ".ralph/agent/builder.md".to_string()
3937 })
3938 );
3939 }
3940
3941 #[test]
3943 fn test_hat_scratchpad_inherits_global() {
3944 let yaml = r#"
3945hats:
3946 reviewer:
3947 name: "Reviewer"
3948 triggers: ["review.start"]
3949 instructions: "Review the code."
3950"#;
3951 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3952 let hat = config.hats.get("reviewer").unwrap();
3953 assert!(
3954 hat.scratchpad.is_none(),
3955 "No scratchpad key means None (inherit global)"
3956 );
3957 }
3958
3959 #[test]
3961 fn test_scratchpad_resolve_hat_override() {
3962 let global = ScratchpadConfig {
3963 enabled: true,
3964 path: ".ralph/agent/scratchpad.md".to_string(),
3965 };
3966 let hat_override = ScratchpadConfig {
3967 enabled: true,
3968 path: ".ralph/agent/planner.md".to_string(),
3969 };
3970 let resolved = ScratchpadConfig::resolve(Some(&hat_override), &global);
3971 assert_eq!(resolved, hat_override);
3972 }
3973
3974 #[test]
3975 fn test_scratchpad_resolve_global_fallback() {
3976 let global = ScratchpadConfig {
3977 enabled: true,
3978 path: ".ralph/agent/scratchpad.md".to_string(),
3979 };
3980 let resolved = ScratchpadConfig::resolve(None, &global);
3981 assert_eq!(resolved, global);
3982 }
3983
3984 #[test]
3986 fn test_multiple_hats_different_scratchpad_configs() {
3987 let yaml = r#"
3988core:
3989 scratchpad:
3990 enabled: true
3991 path: ".ralph/agent/scratchpad.md"
3992hats:
3993 planner:
3994 name: "Planner"
3995 triggers: ["plan.start"]
3996 scratchpad:
3997 path: ".ralph/agent/planner.md"
3998 builder:
3999 name: "Builder"
4000 triggers: ["build.start"]
4001 validator:
4002 name: "Validator"
4003 triggers: ["validate.start"]
4004 scratchpad:
4005 enabled: false
4006"#;
4007 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4008
4009 let planner = config.hats.get("planner").unwrap();
4011 let planner_resolved =
4012 ScratchpadConfig::resolve(planner.scratchpad.as_ref(), &config.core.scratchpad);
4013 assert_eq!(planner_resolved.path, ".ralph/agent/planner.md");
4014 assert!(planner_resolved.enabled);
4015
4016 let builder = config.hats.get("builder").unwrap();
4018 let builder_resolved =
4019 ScratchpadConfig::resolve(builder.scratchpad.as_ref(), &config.core.scratchpad);
4020 assert_eq!(builder_resolved.path, ".ralph/agent/scratchpad.md");
4021 assert!(builder_resolved.enabled);
4022
4023 let validator = config.hats.get("validator").unwrap();
4025 let validator_resolved =
4026 ScratchpadConfig::resolve(validator.scratchpad.as_ref(), &config.core.scratchpad);
4027 assert!(!validator_resolved.enabled);
4028 }
4029
4030 #[test]
4032 fn test_scratchpad_missing_defaults() {
4033 let yaml = r#"
4034core:
4035 specs_dir: "./specs/"
4036"#;
4037 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4038 assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
4039 }
4040
4041 #[test]
4043 fn test_hat_scratchpad_enabled_no_path() {
4044 let yaml = r#"
4045hats:
4046 worker:
4047 name: "Worker"
4048 triggers: ["work.start"]
4049 scratchpad:
4050 enabled: true
4051"#;
4052 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4053 let hat = config.hats.get("worker").unwrap();
4054 let sc = hat.scratchpad.as_ref().unwrap();
4055 assert!(sc.enabled);
4056 assert_eq!(sc.path, ".ralph/agent/scratchpad.md");
4057 }
4058
4059 #[test]
4061 fn test_hat_scratchpad_path_no_enabled() {
4062 let yaml = r#"
4063hats:
4064 worker:
4065 name: "Worker"
4066 triggers: ["work.start"]
4067 scratchpad:
4068 path: ".ralph/agent/worker.md"
4069"#;
4070 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4071 let hat = config.hats.get("worker").unwrap();
4072 let sc = hat.scratchpad.as_ref().unwrap();
4073 assert!(sc.enabled);
4074 assert_eq!(sc.path, ".ralph/agent/worker.md");
4075 }
4076
4077 #[test]
4080 fn test_wave_config_concurrency_and_aggregate_parse() {
4081 let yaml = r#"
4082hats:
4083 reviewer:
4084 name: "Reviewer"
4085 description: "Reviews files in parallel"
4086 triggers: ["review.file"]
4087 publishes: ["review.done"]
4088 instructions: "Review the file."
4089 concurrency: 3
4090 aggregator:
4091 name: "Aggregator"
4092 description: "Aggregates review results"
4093 triggers: ["review.done"]
4094 publishes: ["review.complete"]
4095 instructions: "Aggregate results."
4096 aggregate:
4097 mode: wait_for_all
4098 timeout: 600
4099"#;
4100 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4101
4102 let reviewer = config.hats.get("reviewer").unwrap();
4103 assert_eq!(reviewer.concurrency, 3);
4104 assert!(reviewer.aggregate.is_none());
4105
4106 let aggregator = config.hats.get("aggregator").unwrap();
4107 assert_eq!(aggregator.concurrency, 1); let agg = aggregator.aggregate.as_ref().unwrap();
4109 assert!(matches!(agg.mode, AggregateMode::WaitForAll));
4110 assert_eq!(agg.timeout, 600);
4111 }
4112
4113 #[test]
4114 fn test_wave_config_defaults_without_new_fields() {
4115 let yaml = r#"
4117hats:
4118 builder:
4119 name: "Builder"
4120 description: "Builds code"
4121 triggers: ["build.task"]
4122 publishes: ["build.done"]
4123 instructions: "Build stuff."
4124"#;
4125 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4126 let hat = config.hats.get("builder").unwrap();
4127 assert_eq!(hat.concurrency, 1);
4128 assert!(hat.aggregate.is_none());
4129 }
4130
4131 #[test]
4132 fn test_wave_config_concurrency_zero_rejected() {
4133 let yaml = r#"
4134hats:
4135 worker:
4136 name: "Worker"
4137 description: "Parallel worker"
4138 triggers: ["work.item"]
4139 publishes: ["work.done"]
4140 instructions: "Do work."
4141 concurrency: 0
4142"#;
4143 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4144 let result = config.validate();
4145
4146 assert!(result.is_err());
4147 let err = result.unwrap_err();
4148 assert!(
4149 matches!(&err, ConfigError::InvalidConcurrency { hat, .. } if hat == "worker"),
4150 "Expected InvalidConcurrency error, got: {:?}",
4151 err
4152 );
4153 }
4154
4155 #[test]
4156 fn test_wave_config_aggregate_on_concurrent_hat_rejected() {
4157 let yaml = r#"
4159hats:
4160 hybrid:
4161 name: "Hybrid"
4162 description: "Invalid: both concurrent and aggregator"
4163 triggers: ["work.item"]
4164 publishes: ["work.done"]
4165 instructions: "Invalid config."
4166 concurrency: 3
4167 aggregate:
4168 mode: wait_for_all
4169 timeout: 300
4170"#;
4171 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4172 let result = config.validate();
4173
4174 assert!(result.is_err());
4175 let err = result.unwrap_err();
4176 assert!(
4177 matches!(&err, ConfigError::AggregateOnConcurrentHat { hat, .. } if hat == "hybrid"),
4178 "Expected AggregateOnConcurrentHat error, got: {:?}",
4179 err
4180 );
4181 }
4182
4183 #[test]
4184 fn test_wave_config_aggregate_on_non_concurrent_hat_valid() {
4185 let yaml = r#"
4187hats:
4188 aggregator:
4189 name: "Aggregator"
4190 description: "Collects results"
4191 triggers: ["work.done"]
4192 publishes: ["work.complete"]
4193 instructions: "Aggregate."
4194 aggregate:
4195 mode: wait_for_all
4196 timeout: 300
4197"#;
4198 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4199 let result = config.validate();
4200
4201 assert!(
4202 result.is_ok(),
4203 "Aggregate on non-concurrent hat should be valid: {:?}",
4204 result.unwrap_err()
4205 );
4206 }
4207}