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)]
187 pub agent: Option<String>,
188
189 #[serde(default)]
191 pub agent_priority: Vec<String>,
192
193 #[serde(default)]
195 pub prompt_file: Option<String>,
196
197 #[serde(default)]
199 pub completion_promise: Option<String>,
200
201 #[serde(default)]
203 pub max_iterations: Option<u32>,
204
205 #[serde(default)]
207 pub max_runtime: Option<u64>,
208
209 #[serde(default)]
211 pub max_cost: Option<f64>,
212
213 #[serde(default)]
218 pub verbose: bool,
219
220 #[serde(default)]
222 pub archive_prompts: bool,
223
224 #[serde(default)]
226 pub enable_metrics: bool,
227
228 #[serde(default)]
233 pub max_tokens: Option<u32>,
234
235 #[serde(default)]
237 pub retry_delay: Option<u32>,
238
239 #[serde(default)]
241 pub adapters: AdaptersConfig,
242
243 #[serde(default, rename = "_suppress_warnings")]
248 pub suppress_warnings: bool,
249
250 #[serde(default)]
252 pub tui: TuiConfig,
253
254 #[serde(default)]
256 pub memories: MemoriesConfig,
257
258 #[serde(default)]
260 pub tasks: TasksConfig,
261
262 #[serde(default)]
264 pub hooks: HooksConfig,
265
266 #[serde(default)]
268 pub skills: SkillsConfig,
269
270 #[serde(default)]
272 pub features: FeaturesConfig,
273
274 #[serde(default, rename = "RObot")]
276 pub robot: RobotConfig,
277}
278
279fn default_true() -> bool {
280 true
281}
282
283#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
285 fn default() -> Self {
286 Self {
287 event_loop: EventLoopConfig::default(),
288 cli: CliConfig::default(),
289 core: CoreConfig::default(),
290 hats: HashMap::new(),
291 events: HashMap::new(),
292 agent: None,
294 agent_priority: vec![],
295 prompt_file: None,
296 completion_promise: None,
297 max_iterations: None,
298 max_runtime: None,
299 max_cost: None,
300 verbose: false,
302 archive_prompts: false,
303 enable_metrics: false,
304 max_tokens: None,
306 retry_delay: None,
307 adapters: AdaptersConfig::default(),
308 suppress_warnings: false,
310 tui: TuiConfig::default(),
312 memories: MemoriesConfig::default(),
314 tasks: TasksConfig::default(),
316 hooks: HooksConfig::default(),
318 skills: SkillsConfig::default(),
320 features: FeaturesConfig::default(),
322 robot: RobotConfig::default(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
330pub struct AdaptersConfig {
331 #[serde(default)]
333 pub claude: AdapterSettings,
334
335 #[serde(default)]
337 pub gemini: AdapterSettings,
338
339 #[serde(default)]
341 pub kiro: AdapterSettings,
342
343 #[serde(default)]
345 pub codex: AdapterSettings,
346
347 #[serde(default)]
349 pub amp: AdapterSettings,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct AdapterSettings {
355 #[serde(default = "default_timeout")]
357 pub timeout: u64,
358
359 #[serde(default = "default_true")]
361 pub enabled: bool,
362
363 #[serde(default)]
365 pub tool_permissions: Option<Vec<String>>,
366}
367
368fn default_timeout() -> u64 {
369 300 }
371
372impl Default for AdapterSettings {
373 fn default() -> Self {
374 Self {
375 timeout: default_timeout(),
376 enabled: true,
377 tool_permissions: None,
378 }
379 }
380}
381
382impl RalphConfig {
383 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
385 let path_ref = path.as_ref();
386 debug!(path = %path_ref.display(), "Loading configuration from file");
387 let content = std::fs::read_to_string(path_ref)?;
388 Self::parse_yaml(&content)
389 }
390
391 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
393 let value: serde_yaml::Value = serde_yaml::from_str(content)?;
395 if let Some(map) = value.as_mapping()
396 && map.contains_key(serde_yaml::Value::String("project".to_string()))
397 {
398 return Err(ConfigError::DeprecatedProjectKey);
399 }
400
401 validate_hooks_phase_event_keys(&value)?;
402
403 let config: Self = serde_yaml::from_value(value)?;
404 debug!(
405 backend = %config.cli.backend,
406 has_v1_fields = config.agent.is_some(),
407 custom_hats = config.hats.len(),
408 "Configuration loaded"
409 );
410 Ok(config)
411 }
412
413 pub fn normalize(&mut self) {
418 let mut normalized_count = 0;
419
420 if let Some(ref agent) = self.agent {
422 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
423 self.cli.backend = agent.clone();
424 normalized_count += 1;
425 }
426
427 if let Some(ref pf) = self.prompt_file {
429 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
430 self.event_loop.prompt_file = pf.clone();
431 normalized_count += 1;
432 }
433
434 if let Some(ref cp) = self.completion_promise {
436 debug!(
437 from = "completion_promise",
438 to = "event_loop.completion_promise",
439 "Normalizing v1 field"
440 );
441 self.event_loop.completion_promise = cp.clone();
442 normalized_count += 1;
443 }
444
445 if let Some(mi) = self.max_iterations {
447 debug!(
448 from = "max_iterations",
449 to = "event_loop.max_iterations",
450 value = mi,
451 "Normalizing v1 field"
452 );
453 self.event_loop.max_iterations = mi;
454 normalized_count += 1;
455 }
456
457 if let Some(mr) = self.max_runtime {
459 debug!(
460 from = "max_runtime",
461 to = "event_loop.max_runtime_seconds",
462 value = mr,
463 "Normalizing v1 field"
464 );
465 self.event_loop.max_runtime_seconds = mr;
466 normalized_count += 1;
467 }
468
469 if self.max_cost.is_some() {
471 debug!(
472 from = "max_cost",
473 to = "event_loop.max_cost_usd",
474 "Normalizing v1 field"
475 );
476 self.event_loop.max_cost_usd = self.max_cost;
477 normalized_count += 1;
478 }
479
480 for (hat_id, hat) in &mut self.hats {
482 if !hat.extra_instructions.is_empty() {
483 for fragment in hat.extra_instructions.drain(..) {
484 if !hat.instructions.ends_with('\n') {
485 hat.instructions.push('\n');
486 }
487 hat.instructions.push_str(&fragment);
488 }
489 debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
490 normalized_count += 1;
491 }
492 }
493
494 if normalized_count > 0 {
495 debug!(
496 fields_normalized = normalized_count,
497 "V1 to V2 config normalization complete"
498 );
499 }
500 }
501
502 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
512 let mut warnings = Vec::new();
513
514 if self.suppress_warnings {
516 return Ok(warnings);
517 }
518
519 if self.event_loop.prompt.is_some()
522 && !self.event_loop.prompt_file.is_empty()
523 && self.event_loop.prompt_file != default_prompt_file()
524 {
525 return Err(ConfigError::MutuallyExclusive {
526 field1: "event_loop.prompt".to_string(),
527 field2: "event_loop.prompt_file".to_string(),
528 });
529 }
530 if self.event_loop.completion_promise.trim().is_empty() {
531 return Err(ConfigError::InvalidCompletionPromise);
532 }
533
534 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
536 return Err(ConfigError::CustomBackendRequiresCommand);
537 }
538
539 if self.archive_prompts {
541 warnings.push(ConfigWarning::DeferredFeature {
542 field: "archive_prompts".to_string(),
543 message: "Feature not yet available in v2".to_string(),
544 });
545 }
546
547 if self.enable_metrics {
548 warnings.push(ConfigWarning::DeferredFeature {
549 field: "enable_metrics".to_string(),
550 message: "Feature not yet available in v2".to_string(),
551 });
552 }
553
554 if self.max_tokens.is_some() {
556 warnings.push(ConfigWarning::DroppedField {
557 field: "max_tokens".to_string(),
558 reason: "Token limits are controlled by the CLI tool".to_string(),
559 });
560 }
561
562 if self.retry_delay.is_some() {
563 warnings.push(ConfigWarning::DroppedField {
564 field: "retry_delay".to_string(),
565 reason: "Retry logic handled differently in v2".to_string(),
566 });
567 }
568
569 if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
570 && !(0.0..=100.0).contains(&threshold)
571 {
572 warnings.push(ConfigWarning::InvalidValue {
573 field: "event_loop.mutation_score_warn_threshold".to_string(),
574 message: "Value must be between 0 and 100".to_string(),
575 });
576 }
577
578 if self.adapters.claude.tool_permissions.is_some()
580 || self.adapters.gemini.tool_permissions.is_some()
581 || self.adapters.codex.tool_permissions.is_some()
582 || self.adapters.amp.tool_permissions.is_some()
583 {
584 warnings.push(ConfigWarning::DroppedField {
585 field: "adapters.*.tool_permissions".to_string(),
586 reason: "CLI tool manages its own permissions".to_string(),
587 });
588 }
589
590 self.robot.validate()?;
592
593 self.validate_hooks()?;
595
596 for (hat_id, hat_config) in &self.hats {
598 if hat_config
599 .description
600 .as_ref()
601 .is_none_or(|d| d.trim().is_empty())
602 {
603 return Err(ConfigError::MissingDescription {
604 hat: hat_id.clone(),
605 });
606 }
607 }
608
609 for (hat_id, hat_config) in &self.hats {
611 if hat_config.concurrency == 0 {
612 return Err(ConfigError::InvalidConcurrency {
613 hat: hat_id.clone(),
614 value: 0,
615 });
616 }
617 if hat_config.aggregate.is_some() && hat_config.concurrency > 1 {
618 return Err(ConfigError::AggregateOnConcurrentHat {
619 hat: hat_id.clone(),
620 });
621 }
622 }
623
624 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
627 for (hat_id, hat_config) in &self.hats {
628 for trigger in &hat_config.triggers {
629 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
630 return Err(ConfigError::ReservedTrigger {
631 trigger: trigger.clone(),
632 hat: hat_id.clone(),
633 });
634 }
635 }
636 }
637
638 if !self.hats.is_empty() {
641 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
642 for (hat_id, hat_config) in &self.hats {
643 for trigger in &hat_config.triggers {
644 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
645 return Err(ConfigError::AmbiguousRouting {
646 trigger: trigger.clone(),
647 hat1: (*existing_hat).to_string(),
648 hat2: hat_id.clone(),
649 });
650 }
651 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
652 }
653 }
654 }
655
656 Ok(warnings)
657 }
658
659 fn validate_hooks(&self) -> Result<(), ConfigError> {
660 Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
661
662 if self.hooks.defaults.timeout_seconds == 0 {
663 return Err(ConfigError::HookValidation {
664 field: "hooks.defaults.timeout_seconds".to_string(),
665 message: "must be greater than 0".to_string(),
666 });
667 }
668
669 if self.hooks.defaults.max_output_bytes == 0 {
670 return Err(ConfigError::HookValidation {
671 field: "hooks.defaults.max_output_bytes".to_string(),
672 message: "must be greater than 0".to_string(),
673 });
674 }
675
676 for (phase_event, hook_specs) in &self.hooks.events {
677 for (index, hook) in hook_specs.iter().enumerate() {
678 let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
679
680 if hook.name.trim().is_empty() {
681 return Err(ConfigError::HookValidation {
682 field: format!("{hook_field_base}.name"),
683 message: "is required and must be non-empty".to_string(),
684 });
685 }
686
687 if hook
688 .command
689 .first()
690 .is_none_or(|command| command.trim().is_empty())
691 {
692 return Err(ConfigError::HookValidation {
693 field: format!("{hook_field_base}.command"),
694 message: "is required and must include an executable at command[0]"
695 .to_string(),
696 });
697 }
698
699 if hook.on_error.is_none() {
700 return Err(ConfigError::HookValidation {
701 field: format!("{hook_field_base}.on_error"),
702 message: "is required in v1 (warn | block | suspend)".to_string(),
703 });
704 }
705
706 if let Some(timeout_seconds) = hook.timeout_seconds
707 && timeout_seconds == 0
708 {
709 return Err(ConfigError::HookValidation {
710 field: format!("{hook_field_base}.timeout_seconds"),
711 message: "must be greater than 0 when specified".to_string(),
712 });
713 }
714
715 if let Some(max_output_bytes) = hook.max_output_bytes
716 && max_output_bytes == 0
717 {
718 return Err(ConfigError::HookValidation {
719 field: format!("{hook_field_base}.max_output_bytes"),
720 message: "must be greater than 0 when specified".to_string(),
721 });
722 }
723
724 if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
725 return Err(ConfigError::HookValidation {
726 field: format!("{hook_field_base}.suspend_mode"),
727 message: "requires on_error: suspend".to_string(),
728 });
729 }
730
731 Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
732 Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
733 }
734 }
735
736 Ok(())
737 }
738
739 fn validate_non_v1_hook_fields(
740 path_prefix: &str,
741 fields: &HashMap<String, serde_yaml::Value>,
742 ) -> Result<(), ConfigError> {
743 for key in fields.keys() {
744 let field = format!("{path_prefix}.{key}");
745 match key.as_str() {
746 "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
747 return Err(ConfigError::UnsupportedHookField {
748 field,
749 reason: "Use ~/.ralph/config.yml for user-level defaults; per-hook `global`/`scope` fields are not supported in v1"
750 .to_string(),
751 });
752 }
753 "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
754 return Err(ConfigError::UnsupportedHookField {
755 field,
756 reason:
757 "Parallel hook execution is out of scope for v1; hooks must run sequentially"
758 .to_string(),
759 });
760 }
761 _ => {}
762 }
763 }
764
765 Ok(())
766 }
767
768 fn validate_mutation_contract(
769 hook_field_base: &str,
770 mutate: &HookMutationConfig,
771 ) -> Result<(), ConfigError> {
772 let mutate_field_base = format!("{hook_field_base}.mutate");
773
774 if !mutate.enabled {
775 if mutate.format.is_some() || !mutate.extra.is_empty() {
776 return Err(ConfigError::HookValidation {
777 field: mutate_field_base,
778 message: "mutation settings require mutate.enabled: true".to_string(),
779 });
780 }
781 return Ok(());
782 }
783
784 if let Some(format) = mutate.format.as_deref()
785 && !format.eq_ignore_ascii_case("json")
786 {
787 return Err(ConfigError::HookValidation {
788 field: format!("{mutate_field_base}.format"),
789 message: "only 'json' is supported for v1 mutation payloads".to_string(),
790 });
791 }
792
793 if let Some(key) = mutate.extra.keys().next() {
794 let field = format!("{mutate_field_base}.{key}");
795 let reason = match key.as_str() {
796 "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
797 "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
798 .to_string()
799 }
800 "xml" => "v1 mutation payloads are JSON-only".to_string(),
801 _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
802 };
803
804 return Err(ConfigError::UnsupportedHookField { field, reason });
805 }
806
807 Ok(())
808 }
809
810 pub fn effective_backend(&self) -> &str {
812 &self.cli.backend
813 }
814
815 pub fn get_agent_priority(&self) -> Vec<&str> {
818 if self.agent_priority.is_empty() {
819 vec!["claude", "kiro", "gemini", "codex", "amp"]
820 } else {
821 self.agent_priority.iter().map(String::as_str).collect()
822 }
823 }
824
825 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
828 match backend {
829 "claude" => &self.adapters.claude,
830 "gemini" => &self.adapters.gemini,
831 "kiro" => &self.adapters.kiro,
832 "codex" => &self.adapters.codex,
833 "amp" => &self.adapters.amp,
834 _ => &self.adapters.claude, }
836 }
837}
838
839#[derive(Debug, Clone)]
841pub enum ConfigWarning {
842 DeferredFeature { field: String, message: String },
844 DroppedField { field: String, reason: String },
846 InvalidValue { field: String, message: String },
848}
849
850impl std::fmt::Display for ConfigWarning {
851 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
853 match self {
854 ConfigWarning::DeferredFeature { field, message }
855 | ConfigWarning::InvalidValue { field, message } => {
856 write!(f, "Warning [{field}]: {message}")
857 }
858 ConfigWarning::DroppedField { field, reason } => {
859 write!(f, "Warning [{field}]: Field ignored - {reason}")
860 }
861 }
862 }
863}
864
865#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct EventLoopConfig {
868 pub prompt: Option<String>,
870
871 #[serde(default = "default_prompt_file")]
873 pub prompt_file: String,
874
875 #[serde(default = "default_completion_promise")]
877 pub completion_promise: String,
878
879 #[serde(default = "default_max_iterations")]
881 pub max_iterations: u32,
882
883 #[serde(default = "default_max_runtime")]
885 pub max_runtime_seconds: u64,
886
887 pub max_cost_usd: Option<f64>,
889
890 #[serde(default = "default_max_failures")]
892 pub max_consecutive_failures: u32,
893
894 #[serde(default)]
897 pub cooldown_delay_seconds: u64,
898
899 pub starting_hat: Option<String>,
901
902 pub starting_event: Option<String>,
912
913 #[serde(default)]
917 pub mutation_score_warn_threshold: Option<f64>,
918
919 #[serde(default)]
926 pub persistent: bool,
927
928 #[serde(default)]
932 pub required_events: Vec<String>,
933
934 #[serde(default)]
938 pub cancellation_promise: String,
939
940 #[serde(default)]
944 pub enforce_hat_scope: bool,
945}
946
947fn default_prompt_file() -> String {
948 "PROMPT.md".to_string()
949}
950
951fn default_completion_promise() -> String {
952 "LOOP_COMPLETE".to_string()
953}
954
955fn default_max_iterations() -> u32 {
956 100
957}
958
959fn default_max_runtime() -> u64 {
960 14400 }
962
963fn default_max_failures() -> u32 {
964 5
965}
966
967impl Default for EventLoopConfig {
968 fn default() -> Self {
969 Self {
970 prompt: None,
971 prompt_file: default_prompt_file(),
972 completion_promise: default_completion_promise(),
973 max_iterations: default_max_iterations(),
974 max_runtime_seconds: default_max_runtime(),
975 max_cost_usd: None,
976 max_consecutive_failures: default_max_failures(),
977 cooldown_delay_seconds: 0,
978 starting_hat: None,
979 starting_event: None,
980 mutation_score_warn_threshold: None,
981 persistent: false,
982 required_events: Vec::new(),
983 cancellation_promise: String::new(),
984 enforce_hat_scope: false,
985 }
986 }
987}
988
989#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct CoreConfig {
994 #[serde(default, deserialize_with = "deserialize_scratchpad_config")]
997 pub scratchpad: ScratchpadConfig,
998
999 #[serde(default = "default_specs_dir")]
1001 pub specs_dir: String,
1002
1003 #[serde(default = "default_guardrails")]
1007 pub guardrails: Vec<String>,
1008
1009 #[serde(skip)]
1016 pub workspace_root: std::path::PathBuf,
1017}
1018
1019fn default_specs_dir() -> String {
1020 ".ralph/specs/".to_string()
1021}
1022
1023fn default_guardrails() -> Vec<String> {
1024 vec![
1025 "Fresh context each iteration - scratchpad is memory".to_string(),
1026 "Don't assume 'not implemented' - search first".to_string(),
1027 "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
1028 "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(),
1029 "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(),
1030 "Commit atomically - one logical change per commit, capture the why".to_string(),
1031 ]
1032}
1033
1034impl Default for CoreConfig {
1035 fn default() -> Self {
1036 Self {
1037 scratchpad: ScratchpadConfig::default(),
1038 specs_dir: default_specs_dir(),
1039 guardrails: default_guardrails(),
1040 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
1041 .map(std::path::PathBuf::from)
1042 .unwrap_or_else(|_| {
1043 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
1044 }),
1045 }
1046 }
1047}
1048
1049impl CoreConfig {
1050 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
1054 self.workspace_root = root.into();
1055 self
1056 }
1057
1058 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
1063 let path = std::path::Path::new(relative);
1064 if path.is_absolute() {
1065 path.to_path_buf()
1066 } else {
1067 self.workspace_root.join(path)
1068 }
1069 }
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1074pub struct CliConfig {
1075 #[serde(default = "default_backend")]
1077 pub backend: String,
1078
1079 pub command: Option<String>,
1082
1083 #[serde(default = "default_prompt_mode")]
1085 pub prompt_mode: String,
1086
1087 #[serde(default = "default_mode")]
1090 pub default_mode: String,
1091
1092 #[serde(default = "default_idle_timeout")]
1096 pub idle_timeout_secs: u32,
1097
1098 #[serde(default)]
1101 pub args: Vec<String>,
1102
1103 #[serde(default)]
1106 pub prompt_flag: Option<String>,
1107}
1108
1109fn default_backend() -> String {
1110 "claude".to_string()
1111}
1112
1113fn default_prompt_mode() -> String {
1114 "arg".to_string()
1115}
1116
1117fn default_mode() -> String {
1118 "autonomous".to_string()
1119}
1120
1121fn default_idle_timeout() -> u32 {
1122 30 }
1124
1125impl Default for CliConfig {
1126 fn default() -> Self {
1127 Self {
1128 backend: default_backend(),
1129 command: None,
1130 prompt_mode: default_prompt_mode(),
1131 default_mode: default_mode(),
1132 idle_timeout_secs: default_idle_timeout(),
1133 args: Vec::new(),
1134 prompt_flag: None,
1135 }
1136 }
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1141pub struct TuiConfig {
1142 #[serde(default = "default_prefix_key")]
1144 pub prefix_key: String,
1145}
1146
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1151#[serde(rename_all = "lowercase")]
1152pub enum InjectMode {
1153 #[default]
1155 Auto,
1156 Manual,
1158 None,
1160}
1161
1162impl std::fmt::Display for InjectMode {
1163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164 match self {
1165 Self::Auto => write!(f, "auto"),
1166 Self::Manual => write!(f, "manual"),
1167 Self::None => write!(f, "none"),
1168 }
1169 }
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub struct MemoriesConfig {
1189 #[serde(default)]
1193 pub enabled: bool,
1194
1195 #[serde(default)]
1197 pub inject: InjectMode,
1198
1199 #[serde(default)]
1203 pub budget: usize,
1204
1205 #[serde(default)]
1207 pub filter: MemoriesFilter,
1208}
1209
1210impl Default for MemoriesConfig {
1211 fn default() -> Self {
1212 Self {
1213 enabled: true, inject: InjectMode::Auto,
1215 budget: 0,
1216 filter: MemoriesFilter::default(),
1217 }
1218 }
1219}
1220
1221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1225pub struct MemoriesFilter {
1226 #[serde(default)]
1228 pub types: Vec<String>,
1229
1230 #[serde(default)]
1232 pub tags: Vec<String>,
1233
1234 #[serde(default)]
1236 pub recent: u32,
1237}
1238
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1252pub struct TasksConfig {
1253 #[serde(default = "default_true")]
1257 pub enabled: bool,
1258}
1259
1260impl Default for TasksConfig {
1261 fn default() -> Self {
1262 Self {
1263 enabled: true, }
1265 }
1266}
1267
1268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1288pub struct HooksConfig {
1289 #[serde(default)]
1291 pub enabled: bool,
1292
1293 #[serde(default)]
1295 pub defaults: HookDefaults,
1296
1297 #[serde(default)]
1299 pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1300
1301 #[serde(default, flatten)]
1303 pub extra: HashMap<String, serde_yaml::Value>,
1304}
1305
1306#[derive(Debug, Clone, Serialize, Deserialize)]
1308pub struct HookDefaults {
1309 #[serde(default = "default_hook_timeout_seconds")]
1311 pub timeout_seconds: u64,
1312
1313 #[serde(default = "default_hook_max_output_bytes")]
1315 pub max_output_bytes: u64,
1316
1317 #[serde(default)]
1319 pub suspend_mode: HookSuspendMode,
1320}
1321
1322fn default_hook_timeout_seconds() -> u64 {
1323 30
1324}
1325
1326fn default_hook_max_output_bytes() -> u64 {
1327 8192
1328}
1329
1330impl Default for HookDefaults {
1331 fn default() -> Self {
1332 Self {
1333 timeout_seconds: default_hook_timeout_seconds(),
1334 max_output_bytes: default_hook_max_output_bytes(),
1335 suspend_mode: HookSuspendMode::default(),
1336 }
1337 }
1338}
1339
1340#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1342pub enum HookPhaseEvent {
1343 #[serde(rename = "pre.loop.start")]
1344 PreLoopStart,
1345 #[serde(rename = "post.loop.start")]
1346 PostLoopStart,
1347 #[serde(rename = "pre.iteration.start")]
1348 PreIterationStart,
1349 #[serde(rename = "post.iteration.start")]
1350 PostIterationStart,
1351 #[serde(rename = "pre.plan.created")]
1352 PrePlanCreated,
1353 #[serde(rename = "post.plan.created")]
1354 PostPlanCreated,
1355 #[serde(rename = "pre.human.interact")]
1356 PreHumanInteract,
1357 #[serde(rename = "post.human.interact")]
1358 PostHumanInteract,
1359 #[serde(rename = "pre.loop.complete")]
1360 PreLoopComplete,
1361 #[serde(rename = "post.loop.complete")]
1362 PostLoopComplete,
1363 #[serde(rename = "pre.loop.error")]
1364 PreLoopError,
1365 #[serde(rename = "post.loop.error")]
1366 PostLoopError,
1367}
1368
1369impl HookPhaseEvent {
1370 pub fn as_str(self) -> &'static str {
1372 match self {
1373 Self::PreLoopStart => "pre.loop.start",
1374 Self::PostLoopStart => "post.loop.start",
1375 Self::PreIterationStart => "pre.iteration.start",
1376 Self::PostIterationStart => "post.iteration.start",
1377 Self::PrePlanCreated => "pre.plan.created",
1378 Self::PostPlanCreated => "post.plan.created",
1379 Self::PreHumanInteract => "pre.human.interact",
1380 Self::PostHumanInteract => "post.human.interact",
1381 Self::PreLoopComplete => "pre.loop.complete",
1382 Self::PostLoopComplete => "post.loop.complete",
1383 Self::PreLoopError => "pre.loop.error",
1384 Self::PostLoopError => "post.loop.error",
1385 }
1386 }
1387
1388 pub fn parse(value: &str) -> Option<Self> {
1390 match value {
1391 "pre.loop.start" => Some(Self::PreLoopStart),
1392 "post.loop.start" => Some(Self::PostLoopStart),
1393 "pre.iteration.start" => Some(Self::PreIterationStart),
1394 "post.iteration.start" => Some(Self::PostIterationStart),
1395 "pre.plan.created" => Some(Self::PrePlanCreated),
1396 "post.plan.created" => Some(Self::PostPlanCreated),
1397 "pre.human.interact" => Some(Self::PreHumanInteract),
1398 "post.human.interact" => Some(Self::PostHumanInteract),
1399 "pre.loop.complete" => Some(Self::PreLoopComplete),
1400 "post.loop.complete" => Some(Self::PostLoopComplete),
1401 "pre.loop.error" => Some(Self::PreLoopError),
1402 "post.loop.error" => Some(Self::PostLoopError),
1403 _ => None,
1404 }
1405 }
1406}
1407
1408impl std::fmt::Display for HookPhaseEvent {
1409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1410 f.write_str((*self).as_str())
1411 }
1412}
1413
1414fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1415 let Some(root) = value.as_mapping() else {
1416 return Ok(());
1417 };
1418
1419 let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1420 return Ok(());
1421 };
1422
1423 let Some(hooks_map) = hooks.as_mapping() else {
1424 return Ok(());
1425 };
1426
1427 let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1428 return Ok(());
1429 };
1430
1431 let Some(events_map) = events.as_mapping() else {
1432 return Ok(());
1433 };
1434
1435 for key in events_map.keys() {
1436 if let Some(phase_event) = key.as_str()
1437 && HookPhaseEvent::parse(phase_event).is_none()
1438 {
1439 return Err(ConfigError::InvalidHookPhaseEvent {
1440 phase_event: phase_event.to_string(),
1441 });
1442 }
1443 }
1444
1445 Ok(())
1446}
1447
1448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1450#[serde(rename_all = "snake_case")]
1451pub enum HookOnError {
1452 Warn,
1454 Block,
1456 Suspend,
1458}
1459
1460#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1462#[serde(rename_all = "snake_case")]
1463pub enum HookSuspendMode {
1464 #[default]
1466 WaitForResume,
1467 RetryBackoff,
1469 WaitThenRetry,
1471}
1472
1473#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1475pub struct HookMutationConfig {
1476 #[serde(default)]
1478 pub enabled: bool,
1479
1480 #[serde(default)]
1482 pub format: Option<String>,
1483
1484 #[serde(default, flatten)]
1486 pub extra: HashMap<String, serde_yaml::Value>,
1487}
1488
1489#[derive(Debug, Clone, Serialize, Deserialize)]
1491pub struct HookSpec {
1492 #[serde(default)]
1494 pub name: String,
1495
1496 #[serde(default)]
1498 pub command: Vec<String>,
1499
1500 #[serde(default)]
1502 pub cwd: Option<PathBuf>,
1503
1504 #[serde(default)]
1506 pub env: HashMap<String, String>,
1507
1508 #[serde(default)]
1510 pub timeout_seconds: Option<u64>,
1511
1512 #[serde(default)]
1514 pub max_output_bytes: Option<u64>,
1515
1516 #[serde(default)]
1518 pub on_error: Option<HookOnError>,
1519
1520 #[serde(default)]
1522 pub suspend_mode: Option<HookSuspendMode>,
1523
1524 #[serde(default)]
1526 pub mutate: HookMutationConfig,
1527
1528 #[serde(default, flatten)]
1530 pub extra: HashMap<String, serde_yaml::Value>,
1531}
1532
1533#[derive(Debug, Clone, Serialize, Deserialize)]
1556pub struct SkillsConfig {
1557 #[serde(default = "default_true")]
1559 pub enabled: bool,
1560
1561 #[serde(default)]
1564 pub dirs: Vec<PathBuf>,
1565
1566 #[serde(default)]
1568 pub overrides: HashMap<String, SkillOverride>,
1569}
1570
1571impl Default for SkillsConfig {
1572 fn default() -> Self {
1573 Self {
1574 enabled: true, dirs: vec![],
1576 overrides: HashMap::new(),
1577 }
1578 }
1579}
1580
1581#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1586pub struct SkillOverride {
1587 #[serde(default)]
1589 pub enabled: Option<bool>,
1590
1591 #[serde(default)]
1593 pub hats: Vec<String>,
1594
1595 #[serde(default)]
1597 pub backends: Vec<String>,
1598
1599 #[serde(default)]
1601 pub tags: Vec<String>,
1602
1603 #[serde(default)]
1605 pub auto_inject: Option<bool>,
1606}
1607
1608#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1610pub struct PreflightConfig {
1611 #[serde(default)]
1613 pub enabled: bool,
1614
1615 #[serde(default)]
1617 pub strict: bool,
1618
1619 #[serde(default)]
1621 pub skip: Vec<String>,
1622}
1623
1624#[derive(Debug, Clone, Serialize, Deserialize)]
1640pub struct FeaturesConfig {
1641 #[serde(default = "default_true")]
1646 pub parallel: bool,
1647
1648 #[serde(default)]
1654 pub auto_merge: bool,
1655
1656 #[serde(default)]
1662 pub loop_naming: crate::loop_name::LoopNamingConfig,
1663
1664 #[serde(default)]
1666 pub preflight: PreflightConfig,
1667}
1668
1669impl Default for FeaturesConfig {
1670 fn default() -> Self {
1671 Self {
1672 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1675 preflight: PreflightConfig::default(),
1676 }
1677 }
1678}
1679
1680fn default_prefix_key() -> String {
1681 "ctrl-a".to_string()
1682}
1683
1684impl Default for TuiConfig {
1685 fn default() -> Self {
1686 Self {
1687 prefix_key: default_prefix_key(),
1688 }
1689 }
1690}
1691
1692impl TuiConfig {
1693 pub fn parse_prefix(
1696 &self,
1697 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1698 use crossterm::event::{KeyCode, KeyModifiers};
1699
1700 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1701 if parts.len() != 2 {
1702 return Err(format!(
1703 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1704 self.prefix_key
1705 ));
1706 }
1707
1708 let modifier = match parts[0].to_lowercase().as_str() {
1709 "ctrl" => KeyModifiers::CONTROL,
1710 _ => {
1711 return Err(format!(
1712 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1713 parts[0]
1714 ));
1715 }
1716 };
1717
1718 let key_str = parts[1];
1719 if key_str.len() != 1 {
1720 return Err(format!(
1721 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1722 key_str
1723 ));
1724 }
1725
1726 let key_char = key_str.chars().next().unwrap();
1727 let key_code = KeyCode::Char(key_char);
1728
1729 Ok((key_code, modifier))
1730 }
1731}
1732
1733#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1748pub struct EventMetadata {
1749 #[serde(default)]
1751 pub description: String,
1752
1753 #[serde(default)]
1756 pub on_trigger: String,
1757
1758 #[serde(default)]
1761 pub on_publish: String,
1762}
1763
1764#[derive(Debug, Clone, Serialize, Deserialize)]
1766#[serde(untagged)]
1767pub enum HatBackend {
1768 KiroAgent {
1771 #[serde(rename = "type")]
1772 backend_type: String,
1773 agent: String,
1774 #[serde(default)]
1775 args: Vec<String>,
1776 },
1777 NamedWithArgs {
1779 #[serde(rename = "type")]
1780 backend_type: String,
1781 #[serde(default)]
1782 args: Vec<String>,
1783 },
1784 Named(String),
1786 Custom {
1788 command: String,
1789 #[serde(default)]
1790 args: Vec<String>,
1791 },
1792}
1793
1794impl HatBackend {
1795 pub fn to_cli_backend(&self) -> String {
1797 match self {
1798 HatBackend::Named(name) => name.clone(),
1799 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1800 HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1801 HatBackend::Custom { .. } => "custom".to_string(),
1802 }
1803 }
1804}
1805
1806#[derive(Debug, Clone, Serialize, Deserialize)]
1808pub struct HatConfig {
1809 pub name: String,
1811
1812 pub description: Option<String>,
1815
1816 #[serde(default)]
1819 pub triggers: Vec<String>,
1820
1821 #[serde(default)]
1823 pub publishes: Vec<String>,
1824
1825 #[serde(default)]
1827 pub instructions: String,
1828
1829 #[serde(default)]
1846 pub extra_instructions: Vec<String>,
1847
1848 #[serde(default)]
1850 pub backend: Option<HatBackend>,
1851
1852 #[serde(default, alias = "args")]
1856 pub backend_args: Option<Vec<String>>,
1857
1858 #[serde(default)]
1860 pub default_publishes: Option<String>,
1861
1862 pub max_activations: Option<u32>,
1867
1868 #[serde(default, deserialize_with = "deserialize_optional_scratchpad_config")]
1871 pub scratchpad: Option<ScratchpadConfig>,
1872
1873 #[serde(default)]
1879 pub disallowed_tools: Vec<String>,
1880
1881 #[serde(default)]
1886 pub timeout: Option<u32>,
1887
1888 #[serde(default = "default_concurrency")]
1893 pub concurrency: u32,
1894
1895 #[serde(default)]
1901 pub aggregate: Option<AggregateConfig>,
1902}
1903
1904fn default_concurrency() -> u32 {
1905 1
1906}
1907
1908#[derive(Debug, Clone, Serialize, Deserialize)]
1910pub struct AggregateConfig {
1911 pub mode: AggregateMode,
1913
1914 pub timeout: u32,
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1921#[serde(rename_all = "snake_case")]
1922pub enum AggregateMode {
1923 WaitForAll,
1925}
1926
1927impl HatConfig {
1928 pub fn trigger_topics(&self) -> Vec<Topic> {
1930 self.triggers.iter().map(|s| Topic::new(s)).collect()
1931 }
1932
1933 pub fn publish_topics(&self) -> Vec<Topic> {
1935 self.publishes.iter().map(|s| Topic::new(s)).collect()
1936 }
1937}
1938
1939#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1956pub struct RobotConfig {
1957 #[serde(default)]
1959 pub enabled: bool,
1960
1961 pub timeout_seconds: Option<u64>,
1964
1965 pub checkin_interval_seconds: Option<u64>,
1969
1970 #[serde(default)]
1972 pub telegram: Option<TelegramBotConfig>,
1973}
1974
1975impl RobotConfig {
1976 pub fn validate(&self) -> Result<(), ConfigError> {
1978 if !self.enabled {
1979 return Ok(());
1980 }
1981
1982 if self.timeout_seconds.is_none() {
1983 return Err(ConfigError::RobotMissingField {
1984 field: "RObot.timeout_seconds".to_string(),
1985 hint: "timeout_seconds is required when RObot is enabled".to_string(),
1986 });
1987 }
1988
1989 if self.resolve_bot_token().is_none() {
1991 return Err(ConfigError::RobotMissingField {
1992 field: "RObot.telegram.bot_token".to_string(),
1993 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1994 .to_string(),
1995 });
1996 }
1997
1998 Ok(())
1999 }
2000
2001 pub fn resolve_bot_token(&self) -> Option<String> {
2008 let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
2010 let config_token = self
2011 .telegram
2012 .as_ref()
2013 .and_then(|telegram| telegram.bot_token.clone());
2014
2015 if cfg!(test) {
2016 return env_token.or(config_token);
2017 }
2018
2019 env_token
2020 .or(config_token)
2022 .or_else(|| {
2024 std::panic::catch_unwind(|| {
2025 keyring::Entry::new("ralph", "telegram-bot-token")
2026 .ok()
2027 .and_then(|e| e.get_password().ok())
2028 })
2029 .ok()
2030 .flatten()
2031 })
2032 }
2033
2034 pub fn resolve_api_url(&self) -> Option<String> {
2040 std::env::var("RALPH_TELEGRAM_API_URL").ok().or_else(|| {
2041 self.telegram
2042 .as_ref()
2043 .and_then(|telegram| telegram.api_url.clone())
2044 })
2045 }
2046}
2047
2048#[derive(Debug, Clone, Serialize, Deserialize)]
2050pub struct TelegramBotConfig {
2051 pub bot_token: Option<String>,
2053
2054 pub api_url: Option<String>,
2059}
2060
2061#[derive(Debug, thiserror::Error)]
2063pub enum ConfigError {
2064 #[error("IO error: {0}")]
2065 Io(#[from] std::io::Error),
2066
2067 #[error("YAML parse error: {0}")]
2068 Yaml(#[from] serde_yaml::Error),
2069
2070 #[error(
2071 "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"
2072 )]
2073 AmbiguousRouting {
2074 trigger: String,
2075 hat1: String,
2076 hat2: String,
2077 },
2078
2079 #[error(
2080 "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"
2081 )]
2082 MutuallyExclusive { field1: String, field2: String },
2083
2084 #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
2085 InvalidCompletionPromise,
2086
2087 #[error(
2088 "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"
2089 )]
2090 CustomBackendRequiresCommand,
2091
2092 #[error(
2093 "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"
2094 )]
2095 ReservedTrigger { trigger: String, hat: String },
2096
2097 #[error(
2098 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
2099 )]
2100 MissingDescription { hat: String },
2101
2102 #[error(
2103 "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
2104 )]
2105 RobotMissingField { field: String, hint: String },
2106
2107 #[error(
2108 "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."
2109 )]
2110 InvalidHookPhaseEvent { phase_event: String },
2111
2112 #[error(
2113 "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
2114 )]
2115 HookValidation { field: String, message: String },
2116
2117 #[error(
2118 "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
2119 )]
2120 UnsupportedHookField { field: String, reason: String },
2121
2122 #[error(
2123 "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
2124 )]
2125 DeprecatedProjectKey,
2126
2127 #[error(
2128 "Hat '{hat}' has invalid concurrency: {value}. Must be >= 1.\nFix: set 'concurrency' to 1 or higher."
2129 )]
2130 InvalidConcurrency { hat: String, value: u32 },
2131
2132 #[error(
2133 "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."
2134 )]
2135 AggregateOnConcurrentHat { hat: String },
2136}
2137
2138#[cfg(test)]
2139mod tests {
2140 use super::*;
2141
2142 #[test]
2143 fn test_default_config() {
2144 let config = RalphConfig::default();
2145 assert!(config.hats.is_empty());
2147 assert_eq!(config.event_loop.max_iterations, 100);
2148 assert!(!config.verbose);
2149 assert!(!config.features.preflight.enabled);
2150 assert!(!config.features.preflight.strict);
2151 assert!(config.features.preflight.skip.is_empty());
2152 }
2153
2154 #[test]
2155 fn test_parse_yaml_with_custom_hats() {
2156 let yaml = r#"
2157event_loop:
2158 prompt_file: "TASK.md"
2159 completion_promise: "DONE"
2160 max_iterations: 50
2161cli:
2162 backend: "claude"
2163hats:
2164 implementer:
2165 name: "Implementer"
2166 triggers: ["task.*", "review.done"]
2167 publishes: ["impl.done"]
2168 instructions: "You are the implementation agent."
2169"#;
2170 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2171 assert_eq!(config.hats.len(), 1);
2173 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2174
2175 let hat = config.hats.get("implementer").unwrap();
2176 assert_eq!(hat.triggers.len(), 2);
2177 }
2178
2179 #[test]
2180 fn test_preflight_config_deserialize() {
2181 let yaml = r#"
2182features:
2183 preflight:
2184 enabled: true
2185 strict: true
2186 skip: ["telegram", "git"]
2187"#;
2188 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2189 assert!(config.features.preflight.enabled);
2190 assert!(config.features.preflight.strict);
2191 assert_eq!(
2192 config.features.preflight.skip,
2193 vec!["telegram".to_string(), "git".to_string()]
2194 );
2195 }
2196
2197 #[test]
2198 fn test_parse_yaml_v1_format() {
2199 let yaml = r#"
2201agent: gemini
2202prompt_file: "TASK.md"
2203completion_promise: "RALPH_DONE"
2204max_iterations: 75
2205max_runtime: 7200
2206max_cost: 10.0
2207verbose: true
2208"#;
2209 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2210
2211 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
2217
2218 assert_eq!(config.cli.backend, "gemini");
2220 assert_eq!(config.event_loop.prompt_file, "TASK.md");
2221 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
2222 assert_eq!(config.event_loop.max_iterations, 75);
2223 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
2224 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
2225 assert!(config.verbose);
2226 }
2227
2228 #[test]
2229 fn test_agent_priority() {
2230 let yaml = r"
2231agent: auto
2232agent_priority: [gemini, claude, codex]
2233";
2234 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2235 let priority = config.get_agent_priority();
2236 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2237 }
2238
2239 #[test]
2240 fn test_default_agent_priority() {
2241 let config = RalphConfig::default();
2242 let priority = config.get_agent_priority();
2243 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
2244 }
2245
2246 #[test]
2247 fn test_validate_deferred_features() {
2248 let yaml = r"
2249archive_prompts: true
2250enable_metrics: true
2251";
2252 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2253 let warnings = config.validate().unwrap();
2254
2255 assert_eq!(warnings.len(), 2);
2256 assert!(warnings
2257 .iter()
2258 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2259 assert!(warnings
2260 .iter()
2261 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2262 }
2263
2264 #[test]
2265 fn test_validate_dropped_fields() {
2266 let yaml = r#"
2267max_tokens: 4096
2268retry_delay: 5
2269adapters:
2270 claude:
2271 tool_permissions: ["read", "write"]
2272"#;
2273 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2274 let warnings = config.validate().unwrap();
2275
2276 assert_eq!(warnings.len(), 3);
2277 assert!(warnings.iter().any(
2278 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2279 ));
2280 assert!(warnings.iter().any(
2281 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2282 ));
2283 assert!(warnings
2284 .iter()
2285 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2286 }
2287
2288 #[test]
2289 fn test_suppress_warnings() {
2290 let yaml = r"
2291_suppress_warnings: true
2292archive_prompts: true
2293max_tokens: 4096
2294";
2295 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2296 let warnings = config.validate().unwrap();
2297
2298 assert!(warnings.is_empty());
2300 }
2301
2302 #[test]
2303 fn test_adapter_settings() {
2304 let yaml = r"
2305adapters:
2306 claude:
2307 timeout: 600
2308 enabled: true
2309 gemini:
2310 timeout: 300
2311 enabled: false
2312";
2313 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2314
2315 let claude = config.adapter_settings("claude");
2316 assert_eq!(claude.timeout, 600);
2317 assert!(claude.enabled);
2318
2319 let gemini = config.adapter_settings("gemini");
2320 assert_eq!(gemini.timeout, 300);
2321 assert!(!gemini.enabled);
2322 }
2323
2324 #[test]
2325 fn test_unknown_fields_ignored() {
2326 let yaml = r#"
2328agent: claude
2329unknown_field: "some value"
2330future_feature: true
2331"#;
2332 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2333 assert!(result.is_ok());
2335 }
2336
2337 #[test]
2338 fn test_custom_backend_args_shorthand() {
2339 let yaml = r#"
2340hats:
2341 opencode_builder:
2342 name: "Opencode"
2343 description: "Opencode hat"
2344 backend: "opencode"
2345 args: ["-m", "model"]
2346"#;
2347 let config = RalphConfig::parse_yaml(yaml).unwrap();
2348 let hat = config.hats.get("opencode_builder").unwrap();
2349 assert!(hat.backend_args.is_some());
2350 assert_eq!(
2351 hat.backend_args.as_ref().unwrap(),
2352 &vec!["-m".to_string(), "model".to_string()]
2353 );
2354 }
2355
2356 #[test]
2357 fn test_custom_backend_args_explicit_key() {
2358 let yaml = r#"
2359hats:
2360 opencode_builder:
2361 name: "Opencode"
2362 description: "Opencode hat"
2363 backend: "opencode"
2364 backend_args: ["-m", "model"]
2365"#;
2366 let config = RalphConfig::parse_yaml(yaml).unwrap();
2367 let hat = config.hats.get("opencode_builder").unwrap();
2368 assert!(hat.backend_args.is_some());
2369 assert_eq!(
2370 hat.backend_args.as_ref().unwrap(),
2371 &vec!["-m".to_string(), "model".to_string()]
2372 );
2373 }
2374
2375 #[test]
2376 fn test_project_key_rejected() {
2377 let yaml = r#"
2378project:
2379 specs_dir: "my_specs"
2380"#;
2381 let result = RalphConfig::parse_yaml(yaml);
2382 assert!(result.is_err());
2383 assert!(matches!(
2384 result.unwrap_err(),
2385 ConfigError::DeprecatedProjectKey
2386 ));
2387 }
2388
2389 #[test]
2390 fn test_ambiguous_routing_rejected() {
2391 let yaml = r#"
2394hats:
2395 planner:
2396 name: "Planner"
2397 description: "Plans tasks"
2398 triggers: ["planning.start", "build.done"]
2399 builder:
2400 name: "Builder"
2401 description: "Builds code"
2402 triggers: ["build.task", "build.done"]
2403"#;
2404 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2405 let result = config.validate();
2406
2407 assert!(result.is_err());
2408 let err = result.unwrap_err();
2409 assert!(
2410 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2411 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2412 err
2413 );
2414 }
2415
2416 #[test]
2417 fn test_unique_triggers_accepted() {
2418 let yaml = r#"
2421hats:
2422 planner:
2423 name: "Planner"
2424 description: "Plans tasks"
2425 triggers: ["planning.start", "build.done", "build.blocked"]
2426 builder:
2427 name: "Builder"
2428 description: "Builds code"
2429 triggers: ["build.task"]
2430"#;
2431 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2432 let result = config.validate();
2433
2434 assert!(
2435 result.is_ok(),
2436 "Expected valid config, got: {:?}",
2437 result.unwrap_err()
2438 );
2439 }
2440
2441 #[test]
2442 fn test_reserved_trigger_task_start_rejected() {
2443 let yaml = r#"
2445hats:
2446 my_hat:
2447 name: "My Hat"
2448 description: "Test hat"
2449 triggers: ["task.start"]
2450"#;
2451 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2452 let result = config.validate();
2453
2454 assert!(result.is_err());
2455 let err = result.unwrap_err();
2456 assert!(
2457 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2458 if trigger == "task.start" && hat == "my_hat"),
2459 "Expected ReservedTrigger error for 'task.start', got: {:?}",
2460 err
2461 );
2462 }
2463
2464 #[test]
2465 fn test_reserved_trigger_task_resume_rejected() {
2466 let yaml = r#"
2468hats:
2469 my_hat:
2470 name: "My Hat"
2471 description: "Test hat"
2472 triggers: ["task.resume", "other.event"]
2473"#;
2474 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2475 let result = config.validate();
2476
2477 assert!(result.is_err());
2478 let err = result.unwrap_err();
2479 assert!(
2480 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2481 if trigger == "task.resume" && hat == "my_hat"),
2482 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2483 err
2484 );
2485 }
2486
2487 #[test]
2488 fn test_missing_description_rejected() {
2489 let yaml = r#"
2491hats:
2492 my_hat:
2493 name: "My Hat"
2494 triggers: ["build.task"]
2495"#;
2496 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2497 let result = config.validate();
2498
2499 assert!(result.is_err());
2500 let err = result.unwrap_err();
2501 assert!(
2502 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2503 "Expected MissingDescription error, got: {:?}",
2504 err
2505 );
2506 }
2507
2508 #[test]
2509 fn test_empty_description_rejected() {
2510 let yaml = r#"
2512hats:
2513 my_hat:
2514 name: "My Hat"
2515 description: " "
2516 triggers: ["build.task"]
2517"#;
2518 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2519 let result = config.validate();
2520
2521 assert!(result.is_err());
2522 let err = result.unwrap_err();
2523 assert!(
2524 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2525 "Expected MissingDescription error for empty description, got: {:?}",
2526 err
2527 );
2528 }
2529
2530 #[test]
2531 fn test_core_config_defaults() {
2532 let config = RalphConfig::default();
2533 assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
2534 assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
2535 assert!(config.core.scratchpad.enabled);
2536 assert_eq!(config.core.specs_dir, ".ralph/specs/");
2537 assert_eq!(config.core.guardrails.len(), 6);
2539 assert!(config.core.guardrails[0].contains("Fresh context"));
2540 assert!(config.core.guardrails[1].contains("search first"));
2541 assert!(config.core.guardrails[2].contains("Backpressure"));
2542 assert!(config.core.guardrails[3].contains("strongest available harness"));
2543 assert!(config.core.guardrails[4].contains("Confidence protocol"));
2544 assert!(config.core.guardrails[5].contains("Commit atomically"));
2545 }
2546
2547 #[test]
2548 fn test_core_config_customizable() {
2549 let yaml = r#"
2550core:
2551 scratchpad: ".workspace/plan.md"
2552 specs_dir: "./specifications/"
2553"#;
2554 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2555 assert_eq!(config.core.scratchpad.path, ".workspace/plan.md");
2556 assert!(config.core.scratchpad.enabled);
2557 assert_eq!(config.core.specs_dir, "./specifications/");
2558 assert_eq!(config.core.guardrails.len(), 6);
2560 }
2561
2562 #[test]
2563 fn test_core_config_custom_guardrails() {
2564 let yaml = r#"
2565core:
2566 scratchpad: ".ralph/agent/scratchpad.md"
2567 specs_dir: "./specs/"
2568 guardrails:
2569 - "Custom rule one"
2570 - "Custom rule two"
2571"#;
2572 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2573 assert_eq!(config.core.guardrails.len(), 2);
2574 assert_eq!(config.core.guardrails[0], "Custom rule one");
2575 assert_eq!(config.core.guardrails[1], "Custom rule two");
2576 }
2577
2578 #[test]
2579 fn test_prompt_and_prompt_file_mutually_exclusive() {
2580 let yaml = r#"
2582event_loop:
2583 prompt: "inline text"
2584 prompt_file: "custom.md"
2585"#;
2586 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2587 let result = config.validate();
2588
2589 assert!(result.is_err());
2590 let err = result.unwrap_err();
2591 assert!(
2592 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2593 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2594 "Expected MutuallyExclusive error, got: {:?}",
2595 err
2596 );
2597 }
2598
2599 #[test]
2600 fn test_prompt_with_default_prompt_file_allowed() {
2601 let yaml = r#"
2603event_loop:
2604 prompt: "inline text"
2605"#;
2606 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2607 let result = config.validate();
2608
2609 assert!(
2610 result.is_ok(),
2611 "Should allow inline prompt with default prompt_file"
2612 );
2613 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2614 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2615 }
2616
2617 #[test]
2618 fn test_custom_backend_requires_command() {
2619 let yaml = r#"
2621cli:
2622 backend: "custom"
2623"#;
2624 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2625 let result = config.validate();
2626
2627 assert!(result.is_err());
2628 let err = result.unwrap_err();
2629 assert!(
2630 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2631 "Expected CustomBackendRequiresCommand error, got: {:?}",
2632 err
2633 );
2634 }
2635
2636 #[test]
2637 fn test_empty_completion_promise_rejected() {
2638 let yaml = r#"
2639event_loop:
2640 completion_promise: " "
2641"#;
2642 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2643 let result = config.validate();
2644
2645 assert!(result.is_err());
2646 let err = result.unwrap_err();
2647 assert!(
2648 matches!(&err, ConfigError::InvalidCompletionPromise),
2649 "Expected InvalidCompletionPromise error, got: {:?}",
2650 err
2651 );
2652 }
2653
2654 #[test]
2655 fn test_custom_backend_with_empty_command_errors() {
2656 let yaml = r#"
2658cli:
2659 backend: "custom"
2660 command: ""
2661"#;
2662 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2663 let result = config.validate();
2664
2665 assert!(result.is_err());
2666 let err = result.unwrap_err();
2667 assert!(
2668 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2669 "Expected CustomBackendRequiresCommand error, got: {:?}",
2670 err
2671 );
2672 }
2673
2674 #[test]
2675 fn test_custom_backend_with_command_succeeds() {
2676 let yaml = r#"
2678cli:
2679 backend: "custom"
2680 command: "my-agent"
2681"#;
2682 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2683 let result = config.validate();
2684
2685 assert!(
2686 result.is_ok(),
2687 "Should allow custom backend with command: {:?}",
2688 result.unwrap_err()
2689 );
2690 }
2691
2692 #[test]
2693 fn test_custom_backend_requires_command_message_actionable() {
2694 let err = ConfigError::CustomBackendRequiresCommand;
2695 let msg = err.to_string();
2696 assert!(msg.contains("cli.command"));
2697 assert!(msg.contains("ralph init --backend custom"));
2698 assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2699 }
2700
2701 #[test]
2702 fn test_reserved_trigger_message_actionable() {
2703 let err = ConfigError::ReservedTrigger {
2704 trigger: "task.start".to_string(),
2705 hat: "builder".to_string(),
2706 };
2707 let msg = err.to_string();
2708 assert!(msg.contains("Reserved trigger"));
2709 assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2710 }
2711
2712 #[test]
2713 fn test_prompt_file_with_no_inline_allowed() {
2714 let yaml = r#"
2716event_loop:
2717 prompt_file: "custom.md"
2718"#;
2719 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2720 let result = config.validate();
2721
2722 assert!(
2723 result.is_ok(),
2724 "Should allow prompt_file without inline prompt"
2725 );
2726 assert_eq!(config.event_loop.prompt, None);
2727 assert_eq!(config.event_loop.prompt_file, "custom.md");
2728 }
2729
2730 #[test]
2731 fn test_default_prompt_file_value() {
2732 let config = RalphConfig::default();
2733 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2734 assert_eq!(config.event_loop.prompt, None);
2735 }
2736
2737 #[test]
2738 fn test_tui_config_default() {
2739 let config = RalphConfig::default();
2740 assert_eq!(config.tui.prefix_key, "ctrl-a");
2741 }
2742
2743 #[test]
2744 fn test_tui_config_parse_ctrl_b() {
2745 let yaml = r#"
2746tui:
2747 prefix_key: "ctrl-b"
2748"#;
2749 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2750 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2751
2752 use crossterm::event::{KeyCode, KeyModifiers};
2753 assert_eq!(key_code, KeyCode::Char('b'));
2754 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2755 }
2756
2757 #[test]
2758 fn test_tui_config_parse_invalid_format() {
2759 let tui_config = TuiConfig {
2760 prefix_key: "invalid".to_string(),
2761 };
2762 let result = tui_config.parse_prefix();
2763 assert!(result.is_err());
2764 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2765 }
2766
2767 #[test]
2768 fn test_tui_config_parse_invalid_modifier() {
2769 let tui_config = TuiConfig {
2770 prefix_key: "alt-a".to_string(),
2771 };
2772 let result = tui_config.parse_prefix();
2773 assert!(result.is_err());
2774 assert!(result.unwrap_err().contains("Invalid modifier"));
2775 }
2776
2777 #[test]
2778 fn test_tui_config_parse_invalid_key() {
2779 let tui_config = TuiConfig {
2780 prefix_key: "ctrl-abc".to_string(),
2781 };
2782 let result = tui_config.parse_prefix();
2783 assert!(result.is_err());
2784 assert!(result.unwrap_err().contains("Invalid key"));
2785 }
2786
2787 #[test]
2788 fn test_hat_backend_named() {
2789 let yaml = r#""claude""#;
2790 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2791 assert_eq!(backend.to_cli_backend(), "claude");
2792 match backend {
2793 HatBackend::Named(name) => assert_eq!(name, "claude"),
2794 _ => panic!("Expected Named variant"),
2795 }
2796 }
2797
2798 #[test]
2799 fn test_hat_backend_kiro_agent() {
2800 let yaml = r#"
2801type: "kiro"
2802agent: "builder"
2803"#;
2804 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2805 assert_eq!(backend.to_cli_backend(), "kiro");
2806 match backend {
2807 HatBackend::KiroAgent {
2808 backend_type,
2809 agent,
2810 args,
2811 } => {
2812 assert_eq!(backend_type, "kiro");
2813 assert_eq!(agent, "builder");
2814 assert!(args.is_empty());
2815 }
2816 _ => panic!("Expected KiroAgent variant"),
2817 }
2818 }
2819
2820 #[test]
2821 fn test_hat_backend_kiro_agent_with_args() {
2822 let yaml = r#"
2823type: "kiro"
2824agent: "builder"
2825args: ["--verbose", "--debug"]
2826"#;
2827 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2828 assert_eq!(backend.to_cli_backend(), "kiro");
2829 match backend {
2830 HatBackend::KiroAgent {
2831 backend_type,
2832 agent,
2833 args,
2834 } => {
2835 assert_eq!(backend_type, "kiro");
2836 assert_eq!(agent, "builder");
2837 assert_eq!(args, vec!["--verbose", "--debug"]);
2838 }
2839 _ => panic!("Expected KiroAgent variant"),
2840 }
2841 }
2842
2843 #[test]
2844 fn test_hat_backend_named_with_args() {
2845 let yaml = r#"
2846type: "claude"
2847args: ["--model", "claude-sonnet-4"]
2848"#;
2849 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2850 assert_eq!(backend.to_cli_backend(), "claude");
2851 match backend {
2852 HatBackend::NamedWithArgs { backend_type, args } => {
2853 assert_eq!(backend_type, "claude");
2854 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2855 }
2856 _ => panic!("Expected NamedWithArgs variant"),
2857 }
2858 }
2859
2860 #[test]
2861 fn test_hat_backend_named_with_args_empty() {
2862 let yaml = r#"
2864type: "gemini"
2865"#;
2866 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2867 assert_eq!(backend.to_cli_backend(), "gemini");
2868 match backend {
2869 HatBackend::NamedWithArgs { backend_type, args } => {
2870 assert_eq!(backend_type, "gemini");
2871 assert!(args.is_empty());
2872 }
2873 _ => panic!("Expected NamedWithArgs variant"),
2874 }
2875 }
2876
2877 #[test]
2878 fn test_hat_backend_custom() {
2879 let yaml = r#"
2880command: "/usr/bin/my-agent"
2881args: ["--flag", "value"]
2882"#;
2883 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2884 assert_eq!(backend.to_cli_backend(), "custom");
2885 match backend {
2886 HatBackend::Custom { command, args } => {
2887 assert_eq!(command, "/usr/bin/my-agent");
2888 assert_eq!(args, vec!["--flag", "value"]);
2889 }
2890 _ => panic!("Expected Custom variant"),
2891 }
2892 }
2893
2894 #[test]
2895 fn test_hat_config_with_backend() {
2896 let yaml = r#"
2897name: "Custom Builder"
2898triggers: ["build.task"]
2899publishes: ["build.done"]
2900instructions: "Build stuff"
2901backend: "gemini"
2902default_publishes: "task.done"
2903"#;
2904 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2905 assert_eq!(hat.name, "Custom Builder");
2906 assert!(hat.backend.is_some());
2907 match hat.backend.unwrap() {
2908 HatBackend::Named(name) => assert_eq!(name, "gemini"),
2909 _ => panic!("Expected Named backend"),
2910 }
2911 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2912 }
2913
2914 #[test]
2915 fn test_hat_config_without_backend() {
2916 let yaml = r#"
2917name: "Default Hat"
2918triggers: ["task.start"]
2919publishes: ["task.done"]
2920instructions: "Do work"
2921"#;
2922 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2923 assert_eq!(hat.name, "Default Hat");
2924 assert!(hat.backend.is_none());
2925 assert!(hat.default_publishes.is_none());
2926 }
2927
2928 #[test]
2929 fn test_mixed_backends_config() {
2930 let yaml = r#"
2931event_loop:
2932 prompt_file: "TASK.md"
2933 max_iterations: 50
2934
2935cli:
2936 backend: "claude"
2937
2938hats:
2939 planner:
2940 name: "Planner"
2941 triggers: ["task.start"]
2942 publishes: ["build.task"]
2943 instructions: "Plan the work"
2944 backend: "claude"
2945
2946 builder:
2947 name: "Builder"
2948 triggers: ["build.task"]
2949 publishes: ["build.done"]
2950 instructions: "Build the thing"
2951 backend:
2952 type: "kiro"
2953 agent: "builder"
2954
2955 reviewer:
2956 name: "Reviewer"
2957 triggers: ["build.done"]
2958 publishes: ["review.complete"]
2959 instructions: "Review the work"
2960 backend:
2961 command: "/usr/local/bin/custom-agent"
2962 args: ["--mode", "review"]
2963 default_publishes: "review.complete"
2964"#;
2965 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2966 assert_eq!(config.hats.len(), 3);
2967
2968 let planner = config.hats.get("planner").unwrap();
2970 assert!(planner.backend.is_some());
2971 match planner.backend.as_ref().unwrap() {
2972 HatBackend::Named(name) => assert_eq!(name, "claude"),
2973 _ => panic!("Expected Named backend for planner"),
2974 }
2975
2976 let builder = config.hats.get("builder").unwrap();
2978 assert!(builder.backend.is_some());
2979 match builder.backend.as_ref().unwrap() {
2980 HatBackend::KiroAgent {
2981 backend_type,
2982 agent,
2983 args,
2984 } => {
2985 assert_eq!(backend_type, "kiro");
2986 assert_eq!(agent, "builder");
2987 assert!(args.is_empty());
2988 }
2989 _ => panic!("Expected KiroAgent backend for builder"),
2990 }
2991
2992 let reviewer = config.hats.get("reviewer").unwrap();
2994 assert!(reviewer.backend.is_some());
2995 match reviewer.backend.as_ref().unwrap() {
2996 HatBackend::Custom { command, args } => {
2997 assert_eq!(command, "/usr/local/bin/custom-agent");
2998 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2999 }
3000 _ => panic!("Expected Custom backend for reviewer"),
3001 }
3002 assert_eq!(
3003 reviewer.default_publishes,
3004 Some("review.complete".to_string())
3005 );
3006 }
3007
3008 #[test]
3009 fn test_features_config_auto_merge_defaults_to_false() {
3010 let config = RalphConfig::default();
3013 assert!(
3014 !config.features.auto_merge,
3015 "auto_merge should default to false"
3016 );
3017 }
3018
3019 #[test]
3020 fn test_features_config_auto_merge_from_yaml() {
3021 let yaml = r"
3023features:
3024 auto_merge: true
3025";
3026 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3027 assert!(
3028 config.features.auto_merge,
3029 "auto_merge should be true when configured"
3030 );
3031 }
3032
3033 #[test]
3034 fn test_features_config_auto_merge_false_from_yaml() {
3035 let yaml = r"
3037features:
3038 auto_merge: false
3039";
3040 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3041 assert!(
3042 !config.features.auto_merge,
3043 "auto_merge should be false when explicitly configured"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
3049 let yaml = r"
3051features:
3052 parallel: false
3053 auto_merge: true
3054";
3055 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3056 assert!(!config.features.parallel, "parallel should be false");
3057 assert!(config.features.auto_merge, "auto_merge should be true");
3058 }
3059
3060 #[test]
3061 fn test_skills_config_defaults_when_absent() {
3062 let yaml = r"
3064agent: claude
3065";
3066 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3067 assert!(config.skills.enabled);
3068 assert!(config.skills.dirs.is_empty());
3069 assert!(config.skills.overrides.is_empty());
3070 }
3071
3072 #[test]
3073 fn test_skills_config_deserializes_all_fields() {
3074 let yaml = r#"
3075skills:
3076 enabled: true
3077 dirs:
3078 - ".claude/skills"
3079 - "/shared/skills"
3080 overrides:
3081 pdd:
3082 enabled: false
3083 memories:
3084 auto_inject: true
3085 hats: ["ralph"]
3086 backends: ["claude"]
3087 tags: ["core"]
3088"#;
3089 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3090 assert!(config.skills.enabled);
3091 assert_eq!(config.skills.dirs.len(), 2);
3092 assert_eq!(
3093 config.skills.dirs[0],
3094 std::path::PathBuf::from(".claude/skills")
3095 );
3096 assert_eq!(config.skills.overrides.len(), 2);
3097
3098 let pdd = config.skills.overrides.get("pdd").unwrap();
3099 assert_eq!(pdd.enabled, Some(false));
3100
3101 let memories = config.skills.overrides.get("memories").unwrap();
3102 assert_eq!(memories.auto_inject, Some(true));
3103 assert_eq!(memories.hats, vec!["ralph"]);
3104 assert_eq!(memories.backends, vec!["claude"]);
3105 assert_eq!(memories.tags, vec!["core"]);
3106 }
3107
3108 #[test]
3109 fn test_skills_config_disabled() {
3110 let yaml = r"
3111skills:
3112 enabled: false
3113";
3114 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3115 assert!(!config.skills.enabled);
3116 assert!(config.skills.dirs.is_empty());
3117 }
3118
3119 #[test]
3120 fn test_skill_override_partial_fields() {
3121 let yaml = r#"
3122skills:
3123 overrides:
3124 my-skill:
3125 hats: ["builder", "reviewer"]
3126"#;
3127 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3128 let override_ = config.skills.overrides.get("my-skill").unwrap();
3129 assert_eq!(override_.enabled, None);
3130 assert_eq!(override_.auto_inject, None);
3131 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
3132 assert!(override_.backends.is_empty());
3133 assert!(override_.tags.is_empty());
3134 }
3135
3136 #[test]
3137 fn test_hooks_config_valid_yaml_parses_and_validates() {
3138 let yaml = r#"
3139hooks:
3140 enabled: true
3141 defaults:
3142 timeout_seconds: 45
3143 max_output_bytes: 16384
3144 suspend_mode: wait_for_resume
3145 events:
3146 pre.loop.start:
3147 - name: env-guard
3148 command: ["./scripts/hooks/env-guard.sh", "--check"]
3149 on_error: block
3150 post.loop.complete:
3151 - name: notify
3152 command: ["./scripts/hooks/notify.sh"]
3153 on_error: warn
3154 mutate:
3155 enabled: true
3156 format: json
3157"#;
3158 let config = RalphConfig::parse_yaml(yaml).unwrap();
3159
3160 assert!(config.hooks.enabled);
3161 assert_eq!(config.hooks.defaults.timeout_seconds, 45);
3162 assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
3163 assert_eq!(config.hooks.events.len(), 2);
3164
3165 let warnings = config.validate().unwrap();
3166 assert!(warnings.is_empty());
3167 }
3168
3169 #[test]
3170 fn test_hooks_parse_rejects_invalid_phase_event_key() {
3171 let yaml = r#"
3172hooks:
3173 enabled: true
3174 events:
3175 pre.loop.launch:
3176 - name: bad-phase
3177 command: ["./scripts/hooks/bad-phase.sh"]
3178 on_error: warn
3179"#;
3180
3181 let result = RalphConfig::parse_yaml(yaml);
3182 assert!(result.is_err());
3183
3184 let err = result.unwrap_err();
3185 assert!(matches!(
3186 &err,
3187 ConfigError::InvalidHookPhaseEvent { phase_event }
3188 if phase_event == "pre.loop.launch"
3189 ));
3190 }
3191
3192 #[test]
3193 fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
3194 let yaml = r#"
3195hooks:
3196 enabled: true
3197 events:
3198 pre.backpressure.triggered:
3199 - name: unsupported-backpressure
3200 command: ["./scripts/hooks/backpressure.sh"]
3201 on_error: warn
3202"#;
3203
3204 let result = RalphConfig::parse_yaml(yaml);
3205 assert!(result.is_err());
3206
3207 let err = result.unwrap_err();
3208 assert!(matches!(
3209 &err,
3210 ConfigError::InvalidHookPhaseEvent { phase_event }
3211 if phase_event == "pre.backpressure.triggered"
3212 ));
3213
3214 let message = err.to_string();
3215 assert!(message.contains("Supported v1 phase-events"));
3216 assert!(message.contains("pre.plan.created"));
3217 assert!(message.contains("post.loop.error"));
3218 }
3219
3220 #[test]
3221 fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
3222 let yaml = r#"
3223hooks:
3224 enabled: true
3225 events:
3226 pre.loop.start:
3227 - name: bad-on-error
3228 command: ["./scripts/hooks/bad-on-error.sh"]
3229 on_error: explode
3230"#;
3231
3232 let result = RalphConfig::parse_yaml(yaml);
3233 assert!(result.is_err());
3234
3235 let err = result.unwrap_err();
3236 assert!(matches!(&err, ConfigError::Yaml(_)));
3237
3238 let message = err.to_string();
3239 assert!(message.contains("unknown variant `explode`"));
3240 assert!(message.contains("warn"));
3241 assert!(message.contains("block"));
3242 assert!(message.contains("suspend"));
3243 }
3244
3245 #[test]
3246 fn test_hooks_validate_rejects_missing_name() {
3247 let yaml = r#"
3248hooks:
3249 enabled: true
3250 events:
3251 pre.loop.start:
3252 - command: ["./scripts/hooks/no-name.sh"]
3253 on_error: block
3254"#;
3255 let config = RalphConfig::parse_yaml(yaml).unwrap();
3256
3257 let result = config.validate();
3258 assert!(result.is_err());
3259
3260 let err = result.unwrap_err();
3261 assert!(matches!(
3262 &err,
3263 ConfigError::HookValidation { field, .. }
3264 if field == "hooks.events.pre.loop.start[0].name"
3265 ));
3266 }
3267
3268 #[test]
3269 fn test_hooks_validate_rejects_missing_command() {
3270 let yaml = r"
3271hooks:
3272 enabled: true
3273 events:
3274 pre.loop.start:
3275 - name: missing-command
3276 on_error: block
3277";
3278 let config = RalphConfig::parse_yaml(yaml).unwrap();
3279
3280 let result = config.validate();
3281 assert!(result.is_err());
3282
3283 let err = result.unwrap_err();
3284 assert!(matches!(
3285 &err,
3286 ConfigError::HookValidation { field, .. }
3287 if field == "hooks.events.pre.loop.start[0].command"
3288 ));
3289 }
3290
3291 #[test]
3292 fn test_hooks_validate_rejects_missing_on_error() {
3293 let yaml = r#"
3294hooks:
3295 enabled: true
3296 events:
3297 pre.loop.start:
3298 - name: missing-on-error
3299 command: ["./scripts/hooks/no-on-error.sh"]
3300"#;
3301 let config = RalphConfig::parse_yaml(yaml).unwrap();
3302
3303 let result = config.validate();
3304 assert!(result.is_err());
3305
3306 let err = result.unwrap_err();
3307 assert!(matches!(
3308 &err,
3309 ConfigError::HookValidation { field, .. }
3310 if field == "hooks.events.pre.loop.start[0].on_error"
3311 ));
3312 }
3313
3314 #[test]
3315 fn test_hooks_validate_rejects_zero_timeout_seconds() {
3316 let yaml = r"
3317hooks:
3318 enabled: true
3319 defaults:
3320 timeout_seconds: 0
3321";
3322 let config = RalphConfig::parse_yaml(yaml).unwrap();
3323
3324 let result = config.validate();
3325 assert!(result.is_err());
3326
3327 let err = result.unwrap_err();
3328 assert!(matches!(
3329 &err,
3330 ConfigError::HookValidation { field, .. }
3331 if field == "hooks.defaults.timeout_seconds"
3332 ));
3333 }
3334
3335 #[test]
3336 fn test_hooks_validate_rejects_zero_max_output_bytes() {
3337 let yaml = r"
3338hooks:
3339 enabled: true
3340 defaults:
3341 max_output_bytes: 0
3342";
3343 let config = RalphConfig::parse_yaml(yaml).unwrap();
3344
3345 let result = config.validate();
3346 assert!(result.is_err());
3347
3348 let err = result.unwrap_err();
3349 assert!(matches!(
3350 &err,
3351 ConfigError::HookValidation { field, .. }
3352 if field == "hooks.defaults.max_output_bytes"
3353 ));
3354 }
3355
3356 #[test]
3357 fn test_hooks_validate_rejects_parallel_non_v1_field() {
3358 let yaml = r"
3359hooks:
3360 enabled: true
3361 parallel: true
3362";
3363 let config = RalphConfig::parse_yaml(yaml).unwrap();
3364
3365 let result = config.validate();
3366 assert!(result.is_err());
3367
3368 let err = result.unwrap_err();
3369 assert!(matches!(
3370 &err,
3371 ConfigError::UnsupportedHookField { field, .. }
3372 if field == "hooks.parallel"
3373 ));
3374 }
3375
3376 #[test]
3377 fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3378 let yaml = r#"
3379hooks:
3380 enabled: true
3381 events:
3382 pre.loop.start:
3383 - name: global-scope
3384 command: ["./scripts/hooks/global.sh"]
3385 on_error: warn
3386 scope: global
3387"#;
3388 let config = RalphConfig::parse_yaml(yaml).unwrap();
3389
3390 let result = config.validate();
3391 assert!(result.is_err());
3392
3393 let err = result.unwrap_err();
3394 assert!(matches!(
3395 &err,
3396 ConfigError::UnsupportedHookField { field, .. }
3397 if field == "hooks.events.pre.loop.start[0].scope"
3398 ));
3399 }
3400
3401 #[test]
3406 fn test_robot_config_defaults_disabled() {
3407 let config = RalphConfig::default();
3408 assert!(!config.robot.enabled);
3409 assert!(config.robot.timeout_seconds.is_none());
3410 assert!(config.robot.telegram.is_none());
3411 }
3412
3413 #[test]
3414 fn test_robot_config_absent_parses_as_default() {
3415 let yaml = r"
3417agent: claude
3418";
3419 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3420 assert!(!config.robot.enabled);
3421 assert!(config.robot.timeout_seconds.is_none());
3422 }
3423
3424 #[test]
3425 fn test_robot_config_valid_full() {
3426 let yaml = r#"
3427RObot:
3428 enabled: true
3429 timeout_seconds: 300
3430 telegram:
3431 bot_token: "123456:ABC-DEF"
3432"#;
3433 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3434 assert!(config.robot.enabled);
3435 assert_eq!(config.robot.timeout_seconds, Some(300));
3436 let telegram = config.robot.telegram.as_ref().unwrap();
3437 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3438
3439 assert!(config.validate().is_ok());
3441 }
3442
3443 #[test]
3444 fn test_robot_config_disabled_skips_validation() {
3445 let yaml = r"
3447RObot:
3448 enabled: false
3449";
3450 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3451 assert!(!config.robot.enabled);
3452 assert!(config.validate().is_ok());
3453 }
3454
3455 #[test]
3456 fn test_robot_config_enabled_missing_timeout_fails() {
3457 let yaml = r#"
3458RObot:
3459 enabled: true
3460 telegram:
3461 bot_token: "123456:ABC-DEF"
3462"#;
3463 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3464 let result = config.validate();
3465 assert!(result.is_err());
3466 let err = result.unwrap_err();
3467 assert!(
3468 matches!(&err, ConfigError::RobotMissingField { field, .. }
3469 if field == "RObot.timeout_seconds"),
3470 "Expected RobotMissingField for timeout_seconds, got: {:?}",
3471 err
3472 );
3473 }
3474
3475 #[test]
3476 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3477 let robot = RobotConfig {
3479 enabled: true,
3480 timeout_seconds: None,
3481 checkin_interval_seconds: None,
3482 telegram: None,
3483 };
3484 let result = robot.validate();
3485 assert!(result.is_err());
3486 let err = result.unwrap_err();
3487 assert!(
3488 matches!(&err, ConfigError::RobotMissingField { field, .. }
3489 if field == "RObot.timeout_seconds"),
3490 "Expected timeout validation failure first, got: {:?}",
3491 err
3492 );
3493 }
3494
3495 #[test]
3496 fn test_robot_config_resolve_bot_token_from_config() {
3497 let config = RobotConfig {
3501 enabled: true,
3502 timeout_seconds: Some(300),
3503 checkin_interval_seconds: None,
3504 telegram: Some(TelegramBotConfig {
3505 bot_token: Some("config-token".to_string()),
3506 api_url: None,
3507 }),
3508 };
3509
3510 let resolved = config.resolve_bot_token();
3513 assert!(resolved.is_some());
3516 }
3517
3518 #[test]
3519 fn test_robot_config_resolve_bot_token_none_without_config() {
3520 let config = RobotConfig {
3522 enabled: true,
3523 timeout_seconds: Some(300),
3524 checkin_interval_seconds: None,
3525 telegram: None,
3526 };
3527
3528 let resolved = config.resolve_bot_token();
3531 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3532 assert!(resolved.is_none());
3533 }
3534 }
3535
3536 #[test]
3537 fn test_robot_config_validate_with_config_token() {
3538 let robot = RobotConfig {
3540 enabled: true,
3541 timeout_seconds: Some(300),
3542 checkin_interval_seconds: None,
3543 telegram: Some(TelegramBotConfig {
3544 bot_token: Some("test-token".to_string()),
3545 api_url: None,
3546 }),
3547 };
3548 assert!(robot.validate().is_ok());
3549 }
3550
3551 #[test]
3552 fn test_robot_config_validate_missing_telegram_section() {
3553 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3556 return;
3557 }
3558
3559 let robot = RobotConfig {
3560 enabled: true,
3561 timeout_seconds: Some(300),
3562 checkin_interval_seconds: None,
3563 telegram: None,
3564 };
3565 let result = robot.validate();
3566 assert!(result.is_err());
3567 let err = result.unwrap_err();
3568 assert!(
3569 matches!(&err, ConfigError::RobotMissingField { field, .. }
3570 if field == "RObot.telegram.bot_token"),
3571 "Expected bot_token validation failure, got: {:?}",
3572 err
3573 );
3574 }
3575
3576 #[test]
3577 fn test_robot_config_validate_empty_bot_token() {
3578 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3581 return;
3582 }
3583
3584 let robot = RobotConfig {
3585 enabled: true,
3586 timeout_seconds: Some(300),
3587 checkin_interval_seconds: None,
3588 telegram: Some(TelegramBotConfig {
3589 bot_token: None,
3590 api_url: None,
3591 }),
3592 };
3593 let result = robot.validate();
3594 assert!(result.is_err());
3595 let err = result.unwrap_err();
3596 assert!(
3597 matches!(&err, ConfigError::RobotMissingField { field, .. }
3598 if field == "RObot.telegram.bot_token"),
3599 "Expected bot_token validation failure, got: {:?}",
3600 err
3601 );
3602 }
3603
3604 #[test]
3605 fn test_extra_instructions_merged_during_normalize() {
3606 let yaml = r#"
3607_fragments:
3608 shared_protocol: &shared_protocol |
3609 ### Shared Protocol
3610 Follow this protocol.
3611
3612hats:
3613 builder:
3614 name: "Builder"
3615 triggers: ["build.start"]
3616 instructions: |
3617 ## BUILDER MODE
3618 Build things.
3619 extra_instructions:
3620 - *shared_protocol
3621"#;
3622 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3623 let hat = config.hats.get("builder").unwrap();
3624
3625 assert_eq!(hat.extra_instructions.len(), 1);
3627 assert!(!hat.instructions.contains("Shared Protocol"));
3628
3629 config.normalize();
3630
3631 let hat = config.hats.get("builder").unwrap();
3632 assert!(hat.extra_instructions.is_empty());
3634 assert!(hat.instructions.contains("## BUILDER MODE"));
3635 assert!(hat.instructions.contains("### Shared Protocol"));
3636 assert!(hat.instructions.contains("Follow this protocol."));
3637 }
3638
3639 #[test]
3640 fn test_extra_instructions_empty_by_default() {
3641 let yaml = r#"
3642hats:
3643 simple:
3644 name: "Simple"
3645 triggers: ["start"]
3646 instructions: "Do the thing."
3647"#;
3648 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3649 let hat = config.hats.get("simple").unwrap();
3650 assert!(hat.extra_instructions.is_empty());
3651 }
3652
3653 #[test]
3657 fn test_scratchpad_legacy_plain_string() {
3658 let yaml = r#"
3659core:
3660 scratchpad: ".workspace/plan.md"
3661"#;
3662 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3663 assert_eq!(
3664 config.core.scratchpad,
3665 ScratchpadConfig {
3666 enabled: true,
3667 path: ".workspace/plan.md".to_string()
3668 }
3669 );
3670 }
3671
3672 #[test]
3674 fn test_scratchpad_structured_config() {
3675 let yaml = r#"
3676core:
3677 scratchpad:
3678 enabled: true
3679 path: ".ralph/agent/scratchpad.md"
3680"#;
3681 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3682 assert_eq!(
3683 config.core.scratchpad,
3684 ScratchpadConfig {
3685 enabled: true,
3686 path: ".ralph/agent/scratchpad.md".to_string()
3687 }
3688 );
3689 }
3690
3691 #[test]
3693 fn test_scratchpad_structured_disabled() {
3694 let yaml = r"
3695core:
3696 scratchpad:
3697 enabled: false
3698";
3699 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3700 assert!(!config.core.scratchpad.enabled);
3701 assert_eq!(config.core.scratchpad.path, ".ralph/agent/scratchpad.md");
3702 }
3703
3704 #[test]
3706 fn test_scratchpad_default_config() {
3707 let config = RalphConfig::default();
3708 assert_eq!(
3709 config.core.scratchpad,
3710 ScratchpadConfig {
3711 enabled: true,
3712 path: ".ralph/agent/scratchpad.md".to_string()
3713 }
3714 );
3715 }
3716
3717 #[test]
3719 fn test_hat_scratchpad_plain_string() {
3720 let yaml = r#"
3721hats:
3722 planner:
3723 name: "Planner"
3724 triggers: ["plan.start"]
3725 scratchpad: ".ralph/agent/planner.md"
3726"#;
3727 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3728 let hat = config.hats.get("planner").unwrap();
3729 assert_eq!(
3730 hat.scratchpad,
3731 Some(ScratchpadConfig {
3732 enabled: true,
3733 path: ".ralph/agent/planner.md".to_string()
3734 })
3735 );
3736 }
3737
3738 #[test]
3740 fn test_hat_scratchpad_disabled() {
3741 let yaml = r#"
3742hats:
3743 validator:
3744 name: "Validator"
3745 triggers: ["validate.start"]
3746 scratchpad:
3747 enabled: false
3748"#;
3749 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3750 let hat = config.hats.get("validator").unwrap();
3751 assert_eq!(
3752 hat.scratchpad,
3753 Some(ScratchpadConfig {
3754 enabled: false,
3755 path: ".ralph/agent/scratchpad.md".to_string()
3756 })
3757 );
3758 }
3759
3760 #[test]
3762 fn test_hat_scratchpad_custom_path() {
3763 let yaml = r#"
3764hats:
3765 builder:
3766 name: "Builder"
3767 triggers: ["build.start"]
3768 scratchpad:
3769 path: ".ralph/agent/builder.md"
3770"#;
3771 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3772 let hat = config.hats.get("builder").unwrap();
3773 assert_eq!(
3774 hat.scratchpad,
3775 Some(ScratchpadConfig {
3776 enabled: true,
3777 path: ".ralph/agent/builder.md".to_string()
3778 })
3779 );
3780 }
3781
3782 #[test]
3784 fn test_hat_scratchpad_inherits_global() {
3785 let yaml = r#"
3786hats:
3787 reviewer:
3788 name: "Reviewer"
3789 triggers: ["review.start"]
3790 instructions: "Review the code."
3791"#;
3792 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3793 let hat = config.hats.get("reviewer").unwrap();
3794 assert!(
3795 hat.scratchpad.is_none(),
3796 "No scratchpad key means None (inherit global)"
3797 );
3798 }
3799
3800 #[test]
3802 fn test_scratchpad_resolve_hat_override() {
3803 let global = ScratchpadConfig {
3804 enabled: true,
3805 path: ".ralph/agent/scratchpad.md".to_string(),
3806 };
3807 let hat_override = ScratchpadConfig {
3808 enabled: true,
3809 path: ".ralph/agent/planner.md".to_string(),
3810 };
3811 let resolved = ScratchpadConfig::resolve(Some(&hat_override), &global);
3812 assert_eq!(resolved, hat_override);
3813 }
3814
3815 #[test]
3816 fn test_scratchpad_resolve_global_fallback() {
3817 let global = ScratchpadConfig {
3818 enabled: true,
3819 path: ".ralph/agent/scratchpad.md".to_string(),
3820 };
3821 let resolved = ScratchpadConfig::resolve(None, &global);
3822 assert_eq!(resolved, global);
3823 }
3824
3825 #[test]
3827 fn test_multiple_hats_different_scratchpad_configs() {
3828 let yaml = r#"
3829core:
3830 scratchpad:
3831 enabled: true
3832 path: ".ralph/agent/scratchpad.md"
3833hats:
3834 planner:
3835 name: "Planner"
3836 triggers: ["plan.start"]
3837 scratchpad:
3838 path: ".ralph/agent/planner.md"
3839 builder:
3840 name: "Builder"
3841 triggers: ["build.start"]
3842 validator:
3843 name: "Validator"
3844 triggers: ["validate.start"]
3845 scratchpad:
3846 enabled: false
3847"#;
3848 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3849
3850 let planner = config.hats.get("planner").unwrap();
3852 let planner_resolved =
3853 ScratchpadConfig::resolve(planner.scratchpad.as_ref(), &config.core.scratchpad);
3854 assert_eq!(planner_resolved.path, ".ralph/agent/planner.md");
3855 assert!(planner_resolved.enabled);
3856
3857 let builder = config.hats.get("builder").unwrap();
3859 let builder_resolved =
3860 ScratchpadConfig::resolve(builder.scratchpad.as_ref(), &config.core.scratchpad);
3861 assert_eq!(builder_resolved.path, ".ralph/agent/scratchpad.md");
3862 assert!(builder_resolved.enabled);
3863
3864 let validator = config.hats.get("validator").unwrap();
3866 let validator_resolved =
3867 ScratchpadConfig::resolve(validator.scratchpad.as_ref(), &config.core.scratchpad);
3868 assert!(!validator_resolved.enabled);
3869 }
3870
3871 #[test]
3873 fn test_scratchpad_missing_defaults() {
3874 let yaml = r#"
3875core:
3876 specs_dir: "./specs/"
3877"#;
3878 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3879 assert_eq!(config.core.scratchpad, ScratchpadConfig::default());
3880 }
3881
3882 #[test]
3884 fn test_hat_scratchpad_enabled_no_path() {
3885 let yaml = r#"
3886hats:
3887 worker:
3888 name: "Worker"
3889 triggers: ["work.start"]
3890 scratchpad:
3891 enabled: true
3892"#;
3893 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3894 let hat = config.hats.get("worker").unwrap();
3895 let sc = hat.scratchpad.as_ref().unwrap();
3896 assert!(sc.enabled);
3897 assert_eq!(sc.path, ".ralph/agent/scratchpad.md");
3898 }
3899
3900 #[test]
3902 fn test_hat_scratchpad_path_no_enabled() {
3903 let yaml = r#"
3904hats:
3905 worker:
3906 name: "Worker"
3907 triggers: ["work.start"]
3908 scratchpad:
3909 path: ".ralph/agent/worker.md"
3910"#;
3911 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3912 let hat = config.hats.get("worker").unwrap();
3913 let sc = hat.scratchpad.as_ref().unwrap();
3914 assert!(sc.enabled);
3915 assert_eq!(sc.path, ".ralph/agent/worker.md");
3916 }
3917
3918 #[test]
3921 fn test_wave_config_concurrency_and_aggregate_parse() {
3922 let yaml = r#"
3923hats:
3924 reviewer:
3925 name: "Reviewer"
3926 description: "Reviews files in parallel"
3927 triggers: ["review.file"]
3928 publishes: ["review.done"]
3929 instructions: "Review the file."
3930 concurrency: 3
3931 aggregator:
3932 name: "Aggregator"
3933 description: "Aggregates review results"
3934 triggers: ["review.done"]
3935 publishes: ["review.complete"]
3936 instructions: "Aggregate results."
3937 aggregate:
3938 mode: wait_for_all
3939 timeout: 600
3940"#;
3941 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3942
3943 let reviewer = config.hats.get("reviewer").unwrap();
3944 assert_eq!(reviewer.concurrency, 3);
3945 assert!(reviewer.aggregate.is_none());
3946
3947 let aggregator = config.hats.get("aggregator").unwrap();
3948 assert_eq!(aggregator.concurrency, 1); let agg = aggregator.aggregate.as_ref().unwrap();
3950 assert!(matches!(agg.mode, AggregateMode::WaitForAll));
3951 assert_eq!(agg.timeout, 600);
3952 }
3953
3954 #[test]
3955 fn test_wave_config_defaults_without_new_fields() {
3956 let yaml = r#"
3958hats:
3959 builder:
3960 name: "Builder"
3961 description: "Builds code"
3962 triggers: ["build.task"]
3963 publishes: ["build.done"]
3964 instructions: "Build stuff."
3965"#;
3966 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3967 let hat = config.hats.get("builder").unwrap();
3968 assert_eq!(hat.concurrency, 1);
3969 assert!(hat.aggregate.is_none());
3970 }
3971
3972 #[test]
3973 fn test_wave_config_concurrency_zero_rejected() {
3974 let yaml = r#"
3975hats:
3976 worker:
3977 name: "Worker"
3978 description: "Parallel worker"
3979 triggers: ["work.item"]
3980 publishes: ["work.done"]
3981 instructions: "Do work."
3982 concurrency: 0
3983"#;
3984 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3985 let result = config.validate();
3986
3987 assert!(result.is_err());
3988 let err = result.unwrap_err();
3989 assert!(
3990 matches!(&err, ConfigError::InvalidConcurrency { hat, .. } if hat == "worker"),
3991 "Expected InvalidConcurrency error, got: {:?}",
3992 err
3993 );
3994 }
3995
3996 #[test]
3997 fn test_wave_config_aggregate_on_concurrent_hat_rejected() {
3998 let yaml = r#"
4000hats:
4001 hybrid:
4002 name: "Hybrid"
4003 description: "Invalid: both concurrent and aggregator"
4004 triggers: ["work.item"]
4005 publishes: ["work.done"]
4006 instructions: "Invalid config."
4007 concurrency: 3
4008 aggregate:
4009 mode: wait_for_all
4010 timeout: 300
4011"#;
4012 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4013 let result = config.validate();
4014
4015 assert!(result.is_err());
4016 let err = result.unwrap_err();
4017 assert!(
4018 matches!(&err, ConfigError::AggregateOnConcurrentHat { hat, .. } if hat == "hybrid"),
4019 "Expected AggregateOnConcurrentHat error, got: {:?}",
4020 err
4021 );
4022 }
4023
4024 #[test]
4025 fn test_wave_config_aggregate_on_non_concurrent_hat_valid() {
4026 let yaml = r#"
4028hats:
4029 aggregator:
4030 name: "Aggregator"
4031 description: "Collects results"
4032 triggers: ["work.done"]
4033 publishes: ["work.complete"]
4034 instructions: "Aggregate."
4035 aggregate:
4036 mode: wait_for_all
4037 timeout: 300
4038"#;
4039 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
4040 let result = config.validate();
4041
4042 assert!(
4043 result.is_ok(),
4044 "Aggregate on non-concurrent hat should be valid: {:?}",
4045 result.unwrap_err()
4046 );
4047 }
4048}