1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
20 #[serde(default)]
22 pub event_loop: EventLoopConfig,
23
24 #[serde(default)]
26 pub cli: CliConfig,
27
28 #[serde(default)]
30 pub core: CoreConfig,
31
32 #[serde(default)]
35 pub hats: HashMap<String, HatConfig>,
36
37 #[serde(default)]
41 pub events: HashMap<String, EventMetadata>,
42
43 #[serde(default)]
50 pub agent: Option<String>,
51
52 #[serde(default)]
54 pub agent_priority: Vec<String>,
55
56 #[serde(default)]
58 pub prompt_file: Option<String>,
59
60 #[serde(default)]
62 pub completion_promise: Option<String>,
63
64 #[serde(default)]
66 pub max_iterations: Option<u32>,
67
68 #[serde(default)]
70 pub max_runtime: Option<u64>,
71
72 #[serde(default)]
74 pub max_cost: Option<f64>,
75
76 #[serde(default)]
81 pub verbose: bool,
82
83 #[serde(default)]
85 pub archive_prompts: bool,
86
87 #[serde(default)]
89 pub enable_metrics: bool,
90
91 #[serde(default)]
96 pub max_tokens: Option<u32>,
97
98 #[serde(default)]
100 pub retry_delay: Option<u32>,
101
102 #[serde(default)]
104 pub adapters: AdaptersConfig,
105
106 #[serde(default, rename = "_suppress_warnings")]
111 pub suppress_warnings: bool,
112
113 #[serde(default)]
115 pub tui: TuiConfig,
116
117 #[serde(default)]
119 pub memories: MemoriesConfig,
120
121 #[serde(default)]
123 pub tasks: TasksConfig,
124
125 #[serde(default)]
127 pub features: FeaturesConfig,
128}
129
130fn default_true() -> bool {
131 true
132}
133
134#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
136 fn default() -> Self {
137 Self {
138 event_loop: EventLoopConfig::default(),
139 cli: CliConfig::default(),
140 core: CoreConfig::default(),
141 hats: HashMap::new(),
142 events: HashMap::new(),
143 agent: None,
145 agent_priority: vec![],
146 prompt_file: None,
147 completion_promise: None,
148 max_iterations: None,
149 max_runtime: None,
150 max_cost: None,
151 verbose: false,
153 archive_prompts: false,
154 enable_metrics: false,
155 max_tokens: None,
157 retry_delay: None,
158 adapters: AdaptersConfig::default(),
159 suppress_warnings: false,
161 tui: TuiConfig::default(),
163 memories: MemoriesConfig::default(),
165 tasks: TasksConfig::default(),
167 features: FeaturesConfig::default(),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct AdaptersConfig {
176 #[serde(default)]
178 pub claude: AdapterSettings,
179
180 #[serde(default)]
182 pub gemini: AdapterSettings,
183
184 #[serde(default)]
186 pub kiro: AdapterSettings,
187
188 #[serde(default)]
190 pub codex: AdapterSettings,
191
192 #[serde(default)]
194 pub amp: AdapterSettings,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct AdapterSettings {
200 #[serde(default = "default_timeout")]
202 pub timeout: u64,
203
204 #[serde(default = "default_true")]
206 pub enabled: bool,
207
208 #[serde(default)]
210 pub tool_permissions: Option<Vec<String>>,
211}
212
213fn default_timeout() -> u64 {
214 300 }
216
217impl Default for AdapterSettings {
218 fn default() -> Self {
219 Self {
220 timeout: default_timeout(),
221 enabled: true,
222 tool_permissions: None,
223 }
224 }
225}
226
227impl RalphConfig {
228 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
230 let path_ref = path.as_ref();
231 debug!(path = %path_ref.display(), "Loading configuration from file");
232 let content = std::fs::read_to_string(path_ref)?;
233 Self::parse_yaml(&content)
234 }
235
236 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
238 let config: Self = serde_yaml::from_str(content)?;
239 debug!(
240 backend = %config.cli.backend,
241 has_v1_fields = config.agent.is_some(),
242 custom_hats = config.hats.len(),
243 "Configuration loaded"
244 );
245 Ok(config)
246 }
247
248 pub fn normalize(&mut self) {
253 let mut normalized_count = 0;
254
255 if let Some(ref agent) = self.agent {
257 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
258 self.cli.backend = agent.clone();
259 normalized_count += 1;
260 }
261
262 if let Some(ref pf) = self.prompt_file {
264 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
265 self.event_loop.prompt_file = pf.clone();
266 normalized_count += 1;
267 }
268
269 if let Some(ref cp) = self.completion_promise {
271 debug!(
272 from = "completion_promise",
273 to = "event_loop.completion_promise",
274 "Normalizing v1 field"
275 );
276 self.event_loop.completion_promise = cp.clone();
277 normalized_count += 1;
278 }
279
280 if let Some(mi) = self.max_iterations {
282 debug!(
283 from = "max_iterations",
284 to = "event_loop.max_iterations",
285 value = mi,
286 "Normalizing v1 field"
287 );
288 self.event_loop.max_iterations = mi;
289 normalized_count += 1;
290 }
291
292 if let Some(mr) = self.max_runtime {
294 debug!(
295 from = "max_runtime",
296 to = "event_loop.max_runtime_seconds",
297 value = mr,
298 "Normalizing v1 field"
299 );
300 self.event_loop.max_runtime_seconds = mr;
301 normalized_count += 1;
302 }
303
304 if self.max_cost.is_some() {
306 debug!(
307 from = "max_cost",
308 to = "event_loop.max_cost_usd",
309 "Normalizing v1 field"
310 );
311 self.event_loop.max_cost_usd = self.max_cost;
312 normalized_count += 1;
313 }
314
315 if normalized_count > 0 {
316 debug!(
317 fields_normalized = normalized_count,
318 "V1 to V2 config normalization complete"
319 );
320 }
321 }
322
323 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
333 let mut warnings = Vec::new();
334
335 if self.suppress_warnings {
337 return Ok(warnings);
338 }
339
340 if self.event_loop.prompt.is_some()
343 && !self.event_loop.prompt_file.is_empty()
344 && self.event_loop.prompt_file != default_prompt_file()
345 {
346 return Err(ConfigError::MutuallyExclusive {
347 field1: "event_loop.prompt".to_string(),
348 field2: "event_loop.prompt_file".to_string(),
349 });
350 }
351
352 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
354 return Err(ConfigError::CustomBackendRequiresCommand);
355 }
356
357 if self.archive_prompts {
359 warnings.push(ConfigWarning::DeferredFeature {
360 field: "archive_prompts".to_string(),
361 message: "Feature not yet available in v2".to_string(),
362 });
363 }
364
365 if self.enable_metrics {
366 warnings.push(ConfigWarning::DeferredFeature {
367 field: "enable_metrics".to_string(),
368 message: "Feature not yet available in v2".to_string(),
369 });
370 }
371
372 if self.max_tokens.is_some() {
374 warnings.push(ConfigWarning::DroppedField {
375 field: "max_tokens".to_string(),
376 reason: "Token limits are controlled by the CLI tool".to_string(),
377 });
378 }
379
380 if self.retry_delay.is_some() {
381 warnings.push(ConfigWarning::DroppedField {
382 field: "retry_delay".to_string(),
383 reason: "Retry logic handled differently in v2".to_string(),
384 });
385 }
386
387 if self.adapters.claude.tool_permissions.is_some()
389 || self.adapters.gemini.tool_permissions.is_some()
390 || self.adapters.codex.tool_permissions.is_some()
391 || self.adapters.amp.tool_permissions.is_some()
392 {
393 warnings.push(ConfigWarning::DroppedField {
394 field: "adapters.*.tool_permissions".to_string(),
395 reason: "CLI tool manages its own permissions".to_string(),
396 });
397 }
398
399 for (hat_id, hat_config) in &self.hats {
401 if hat_config
402 .description
403 .as_ref()
404 .is_none_or(|d| d.trim().is_empty())
405 {
406 return Err(ConfigError::MissingDescription {
407 hat: hat_id.clone(),
408 });
409 }
410 }
411
412 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
415 for (hat_id, hat_config) in &self.hats {
416 for trigger in &hat_config.triggers {
417 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
418 return Err(ConfigError::ReservedTrigger {
419 trigger: trigger.clone(),
420 hat: hat_id.clone(),
421 });
422 }
423 }
424 }
425
426 if !self.hats.is_empty() {
429 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
430 for (hat_id, hat_config) in &self.hats {
431 for trigger in &hat_config.triggers {
432 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
433 return Err(ConfigError::AmbiguousRouting {
434 trigger: trigger.clone(),
435 hat1: (*existing_hat).to_string(),
436 hat2: hat_id.clone(),
437 });
438 }
439 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
440 }
441 }
442 }
443
444 Ok(warnings)
445 }
446
447 pub fn effective_backend(&self) -> &str {
449 &self.cli.backend
450 }
451
452 pub fn get_agent_priority(&self) -> Vec<&str> {
455 if self.agent_priority.is_empty() {
456 vec!["claude", "kiro", "gemini", "codex", "amp"]
457 } else {
458 self.agent_priority.iter().map(String::as_str).collect()
459 }
460 }
461
462 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
465 match backend {
466 "claude" => &self.adapters.claude,
467 "gemini" => &self.adapters.gemini,
468 "kiro" => &self.adapters.kiro,
469 "codex" => &self.adapters.codex,
470 "amp" => &self.adapters.amp,
471 _ => &self.adapters.claude, }
473 }
474}
475
476#[derive(Debug, Clone)]
478pub enum ConfigWarning {
479 DeferredFeature { field: String, message: String },
481 DroppedField { field: String, reason: String },
483 InvalidValue { field: String, message: String },
485}
486
487impl std::fmt::Display for ConfigWarning {
488 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490 match self {
491 ConfigWarning::DeferredFeature { field, message }
492 | ConfigWarning::InvalidValue { field, message } => {
493 write!(f, "Warning [{field}]: {message}")
494 }
495 ConfigWarning::DroppedField { field, reason } => {
496 write!(f, "Warning [{field}]: Field ignored - {reason}")
497 }
498 }
499 }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct EventLoopConfig {
505 pub prompt: Option<String>,
507
508 #[serde(default = "default_prompt_file")]
510 pub prompt_file: String,
511
512 #[serde(default = "default_completion_promise")]
514 pub completion_promise: String,
515
516 #[serde(default = "default_max_iterations")]
518 pub max_iterations: u32,
519
520 #[serde(default = "default_max_runtime")]
522 pub max_runtime_seconds: u64,
523
524 pub max_cost_usd: Option<f64>,
526
527 #[serde(default = "default_max_failures")]
529 pub max_consecutive_failures: u32,
530
531 pub starting_hat: Option<String>,
533
534 pub starting_event: Option<String>,
544}
545
546fn default_prompt_file() -> String {
547 "PROMPT.md".to_string()
548}
549
550fn default_completion_promise() -> String {
551 "LOOP_COMPLETE".to_string()
552}
553
554fn default_max_iterations() -> u32 {
555 100
556}
557
558fn default_max_runtime() -> u64 {
559 14400 }
561
562fn default_max_failures() -> u32 {
563 5
564}
565
566impl Default for EventLoopConfig {
567 fn default() -> Self {
568 Self {
569 prompt: None,
570 prompt_file: default_prompt_file(),
571 completion_promise: default_completion_promise(),
572 max_iterations: default_max_iterations(),
573 max_runtime_seconds: default_max_runtime(),
574 max_cost_usd: None,
575 max_consecutive_failures: default_max_failures(),
576 starting_hat: None,
577 starting_event: None,
578 }
579 }
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct CoreConfig {
587 #[serde(default = "default_scratchpad")]
589 pub scratchpad: String,
590
591 #[serde(default = "default_specs_dir")]
593 pub specs_dir: String,
594
595 #[serde(default = "default_guardrails")]
599 pub guardrails: Vec<String>,
600
601 #[serde(skip)]
608 pub workspace_root: std::path::PathBuf,
609}
610
611fn default_scratchpad() -> String {
612 ".ralph/agent/scratchpad.md".to_string()
613}
614
615fn default_specs_dir() -> String {
616 ".ralph/specs/".to_string()
617}
618
619fn default_guardrails() -> Vec<String> {
620 vec![
621 "Fresh context each iteration - scratchpad is memory".to_string(),
622 "Don't assume 'not implemented' - search first".to_string(),
623 "Backpressure is law - tests/typecheck/lint must pass".to_string(),
624 "Commit atomically - one logical change per commit, capture the why".to_string(),
625 ]
626}
627
628impl Default for CoreConfig {
629 fn default() -> Self {
630 Self {
631 scratchpad: default_scratchpad(),
632 specs_dir: default_specs_dir(),
633 guardrails: default_guardrails(),
634 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
635 .map(std::path::PathBuf::from)
636 .unwrap_or_else(|_| {
637 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
638 }),
639 }
640 }
641}
642
643impl CoreConfig {
644 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
648 self.workspace_root = root.into();
649 self
650 }
651
652 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
657 let path = std::path::Path::new(relative);
658 if path.is_absolute() {
659 path.to_path_buf()
660 } else {
661 self.workspace_root.join(path)
662 }
663 }
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct CliConfig {
669 #[serde(default = "default_backend")]
671 pub backend: String,
672
673 pub command: Option<String>,
676
677 #[serde(default = "default_prompt_mode")]
679 pub prompt_mode: String,
680
681 #[serde(default = "default_mode")]
684 pub default_mode: String,
685
686 #[serde(default = "default_idle_timeout")]
690 pub idle_timeout_secs: u32,
691
692 #[serde(default)]
695 pub args: Vec<String>,
696
697 #[serde(default)]
700 pub prompt_flag: Option<String>,
701}
702
703fn default_backend() -> String {
704 "claude".to_string()
705}
706
707fn default_prompt_mode() -> String {
708 "arg".to_string()
709}
710
711fn default_mode() -> String {
712 "autonomous".to_string()
713}
714
715fn default_idle_timeout() -> u32 {
716 30 }
718
719impl Default for CliConfig {
720 fn default() -> Self {
721 Self {
722 backend: default_backend(),
723 command: None,
724 prompt_mode: default_prompt_mode(),
725 default_mode: default_mode(),
726 idle_timeout_secs: default_idle_timeout(),
727 args: Vec::new(),
728 prompt_flag: None,
729 }
730 }
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct TuiConfig {
736 #[serde(default = "default_prefix_key")]
738 pub prefix_key: String,
739}
740
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
745#[serde(rename_all = "lowercase")]
746pub enum InjectMode {
747 #[default]
749 Auto,
750 Manual,
752 None,
754}
755
756impl std::fmt::Display for InjectMode {
757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 match self {
759 Self::Auto => write!(f, "auto"),
760 Self::Manual => write!(f, "manual"),
761 Self::None => write!(f, "none"),
762 }
763 }
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct MemoriesConfig {
783 #[serde(default)]
787 pub enabled: bool,
788
789 #[serde(default)]
791 pub inject: InjectMode,
792
793 #[serde(default)]
797 pub budget: usize,
798
799 #[serde(default)]
801 pub filter: MemoriesFilter,
802}
803
804impl Default for MemoriesConfig {
805 fn default() -> Self {
806 Self {
807 enabled: true, inject: InjectMode::Auto,
809 budget: 0,
810 filter: MemoriesFilter::default(),
811 }
812 }
813}
814
815#[derive(Debug, Clone, Default, Serialize, Deserialize)]
819pub struct MemoriesFilter {
820 #[serde(default)]
822 pub types: Vec<String>,
823
824 #[serde(default)]
826 pub tags: Vec<String>,
827
828 #[serde(default)]
830 pub recent: u32,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct TasksConfig {
847 #[serde(default = "default_true")]
851 pub enabled: bool,
852}
853
854impl Default for TasksConfig {
855 fn default() -> Self {
856 Self {
857 enabled: true, }
859 }
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
883pub struct ChaosModeConfig {
884 #[serde(default)]
886 pub enabled: bool,
887
888 #[serde(default = "default_chaos_max_iterations")]
890 pub max_iterations: u32,
891
892 #[serde(default = "default_chaos_cooldown")]
894 pub cooldown_seconds: u64,
895
896 #[serde(default = "default_chaos_completion")]
898 pub completion_promise: String,
899
900 #[serde(default = "default_research_focus")]
902 pub research_focus: Vec<ResearchFocus>,
903
904 #[serde(default = "default_chaos_outputs")]
906 pub outputs: Vec<ChaosOutput>,
907}
908
909fn default_chaos_max_iterations() -> u32 {
910 5
911}
912
913fn default_chaos_cooldown() -> u64 {
914 30 }
916
917fn default_chaos_completion() -> String {
918 "CHAOS_COMPLETE".to_string()
919}
920
921fn default_research_focus() -> Vec<ResearchFocus> {
922 vec![
923 ResearchFocus::DomainBestPractices,
924 ResearchFocus::CodebasePatterns,
925 ResearchFocus::SelfImprovement,
926 ]
927}
928
929fn default_chaos_outputs() -> Vec<ChaosOutput> {
930 vec![ChaosOutput::Memories] }
932
933impl Default for ChaosModeConfig {
934 fn default() -> Self {
935 Self {
936 enabled: false,
937 max_iterations: default_chaos_max_iterations(),
938 cooldown_seconds: default_chaos_cooldown(),
939 completion_promise: default_chaos_completion(),
940 research_focus: default_research_focus(),
941 outputs: default_chaos_outputs(),
942 }
943 }
944}
945
946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948#[serde(rename_all = "snake_case")]
949pub enum ResearchFocus {
950 DomainBestPractices,
952 CodebasePatterns,
954 SelfImprovement,
956}
957
958#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[serde(rename_all = "snake_case")]
961pub enum ChaosOutput {
962 Memories,
964 Tasks,
966 Specs,
968}
969
970#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct FeaturesConfig {
986 #[serde(default = "default_true")]
991 pub parallel: bool,
992
993 #[serde(default)]
999 pub auto_merge: bool,
1000
1001 #[serde(default)]
1007 pub loop_naming: crate::loop_name::LoopNamingConfig,
1008
1009 #[serde(default)]
1014 pub chaos_mode: ChaosModeConfig,
1015}
1016
1017impl Default for FeaturesConfig {
1018 fn default() -> Self {
1019 Self {
1020 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1023 chaos_mode: ChaosModeConfig::default(),
1024 }
1025 }
1026}
1027
1028fn default_prefix_key() -> String {
1029 "ctrl-a".to_string()
1030}
1031
1032impl Default for TuiConfig {
1033 fn default() -> Self {
1034 Self {
1035 prefix_key: default_prefix_key(),
1036 }
1037 }
1038}
1039
1040impl TuiConfig {
1041 pub fn parse_prefix(
1044 &self,
1045 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1046 use crossterm::event::{KeyCode, KeyModifiers};
1047
1048 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1049 if parts.len() != 2 {
1050 return Err(format!(
1051 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1052 self.prefix_key
1053 ));
1054 }
1055
1056 let modifier = match parts[0].to_lowercase().as_str() {
1057 "ctrl" => KeyModifiers::CONTROL,
1058 _ => {
1059 return Err(format!(
1060 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1061 parts[0]
1062 ));
1063 }
1064 };
1065
1066 let key_str = parts[1];
1067 if key_str.len() != 1 {
1068 return Err(format!(
1069 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1070 key_str
1071 ));
1072 }
1073
1074 let key_char = key_str.chars().next().unwrap();
1075 let key_code = KeyCode::Char(key_char);
1076
1077 Ok((key_code, modifier))
1078 }
1079}
1080
1081#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1096pub struct EventMetadata {
1097 #[serde(default)]
1099 pub description: String,
1100
1101 #[serde(default)]
1104 pub on_trigger: String,
1105
1106 #[serde(default)]
1109 pub on_publish: String,
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1114#[serde(untagged)]
1115pub enum HatBackend {
1116 KiroAgent {
1119 #[serde(rename = "type")]
1120 backend_type: String,
1121 agent: String,
1122 #[serde(default)]
1123 args: Vec<String>,
1124 },
1125 NamedWithArgs {
1127 #[serde(rename = "type")]
1128 backend_type: String,
1129 #[serde(default)]
1130 args: Vec<String>,
1131 },
1132 Named(String),
1134 Custom {
1136 command: String,
1137 #[serde(default)]
1138 args: Vec<String>,
1139 },
1140}
1141
1142impl HatBackend {
1143 pub fn to_cli_backend(&self) -> String {
1145 match self {
1146 HatBackend::Named(name) => name.clone(),
1147 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1148 HatBackend::KiroAgent { .. } => "kiro".to_string(),
1149 HatBackend::Custom { .. } => "custom".to_string(),
1150 }
1151 }
1152}
1153
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub struct HatConfig {
1157 pub name: String,
1159
1160 pub description: Option<String>,
1163
1164 #[serde(default)]
1167 pub triggers: Vec<String>,
1168
1169 #[serde(default)]
1171 pub publishes: Vec<String>,
1172
1173 #[serde(default)]
1175 pub instructions: String,
1176
1177 #[serde(default)]
1179 pub backend: Option<HatBackend>,
1180
1181 #[serde(default)]
1183 pub default_publishes: Option<String>,
1184
1185 pub max_activations: Option<u32>,
1190}
1191
1192impl HatConfig {
1193 pub fn trigger_topics(&self) -> Vec<Topic> {
1195 self.triggers.iter().map(|s| Topic::new(s)).collect()
1196 }
1197
1198 pub fn publish_topics(&self) -> Vec<Topic> {
1200 self.publishes.iter().map(|s| Topic::new(s)).collect()
1201 }
1202}
1203
1204#[derive(Debug, thiserror::Error)]
1206pub enum ConfigError {
1207 #[error("IO error: {0}")]
1208 Io(#[from] std::io::Error),
1209
1210 #[error("YAML parse error: {0}")]
1211 Yaml(#[from] serde_yaml::Error),
1212
1213 #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
1214 AmbiguousRouting {
1215 trigger: String,
1216 hat1: String,
1217 hat2: String,
1218 },
1219
1220 #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
1221 MutuallyExclusive { field1: String, field2: String },
1222
1223 #[error("Custom backend requires a command - set 'cli.command' in config")]
1224 CustomBackendRequiresCommand,
1225
1226 #[error(
1227 "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."
1228 )]
1229 ReservedTrigger { trigger: String, hat: String },
1230
1231 #[error(
1232 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
1233 )]
1234 MissingDescription { hat: String },
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240
1241 #[test]
1242 fn test_default_config() {
1243 let config = RalphConfig::default();
1244 assert!(config.hats.is_empty());
1246 assert_eq!(config.event_loop.max_iterations, 100);
1247 assert!(!config.verbose);
1248 }
1249
1250 #[test]
1251 fn test_parse_yaml_with_custom_hats() {
1252 let yaml = r#"
1253event_loop:
1254 prompt_file: "TASK.md"
1255 completion_promise: "DONE"
1256 max_iterations: 50
1257cli:
1258 backend: "claude"
1259hats:
1260 implementer:
1261 name: "Implementer"
1262 triggers: ["task.*", "review.done"]
1263 publishes: ["impl.done"]
1264 instructions: "You are the implementation agent."
1265"#;
1266 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1267 assert_eq!(config.hats.len(), 1);
1269 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1270
1271 let hat = config.hats.get("implementer").unwrap();
1272 assert_eq!(hat.triggers.len(), 2);
1273 }
1274
1275 #[test]
1276 fn test_parse_yaml_v1_format() {
1277 let yaml = r#"
1279agent: gemini
1280prompt_file: "TASK.md"
1281completion_promise: "RALPH_DONE"
1282max_iterations: 75
1283max_runtime: 7200
1284max_cost: 10.0
1285verbose: true
1286"#;
1287 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1288
1289 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
1295
1296 assert_eq!(config.cli.backend, "gemini");
1298 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1299 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1300 assert_eq!(config.event_loop.max_iterations, 75);
1301 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1302 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1303 assert!(config.verbose);
1304 }
1305
1306 #[test]
1307 fn test_agent_priority() {
1308 let yaml = r"
1309agent: auto
1310agent_priority: [gemini, claude, codex]
1311";
1312 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1313 let priority = config.get_agent_priority();
1314 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
1315 }
1316
1317 #[test]
1318 fn test_default_agent_priority() {
1319 let config = RalphConfig::default();
1320 let priority = config.get_agent_priority();
1321 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
1322 }
1323
1324 #[test]
1325 fn test_validate_deferred_features() {
1326 let yaml = r"
1327archive_prompts: true
1328enable_metrics: true
1329";
1330 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1331 let warnings = config.validate().unwrap();
1332
1333 assert_eq!(warnings.len(), 2);
1334 assert!(warnings
1335 .iter()
1336 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
1337 assert!(warnings
1338 .iter()
1339 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
1340 }
1341
1342 #[test]
1343 fn test_validate_dropped_fields() {
1344 let yaml = r#"
1345max_tokens: 4096
1346retry_delay: 5
1347adapters:
1348 claude:
1349 tool_permissions: ["read", "write"]
1350"#;
1351 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1352 let warnings = config.validate().unwrap();
1353
1354 assert_eq!(warnings.len(), 3);
1355 assert!(warnings.iter().any(
1356 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
1357 ));
1358 assert!(warnings.iter().any(
1359 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
1360 ));
1361 assert!(warnings
1362 .iter()
1363 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1364 }
1365
1366 #[test]
1367 fn test_suppress_warnings() {
1368 let yaml = r"
1369_suppress_warnings: true
1370archive_prompts: true
1371max_tokens: 4096
1372";
1373 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1374 let warnings = config.validate().unwrap();
1375
1376 assert!(warnings.is_empty());
1378 }
1379
1380 #[test]
1381 fn test_adapter_settings() {
1382 let yaml = r"
1383adapters:
1384 claude:
1385 timeout: 600
1386 enabled: true
1387 gemini:
1388 timeout: 300
1389 enabled: false
1390";
1391 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1392
1393 let claude = config.adapter_settings("claude");
1394 assert_eq!(claude.timeout, 600);
1395 assert!(claude.enabled);
1396
1397 let gemini = config.adapter_settings("gemini");
1398 assert_eq!(gemini.timeout, 300);
1399 assert!(!gemini.enabled);
1400 }
1401
1402 #[test]
1403 fn test_unknown_fields_ignored() {
1404 let yaml = r#"
1406agent: claude
1407unknown_field: "some value"
1408future_feature: true
1409"#;
1410 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1411 assert!(result.is_ok());
1413 }
1414
1415 #[test]
1416 fn test_ambiguous_routing_rejected() {
1417 let yaml = r#"
1420hats:
1421 planner:
1422 name: "Planner"
1423 description: "Plans tasks"
1424 triggers: ["planning.start", "build.done"]
1425 builder:
1426 name: "Builder"
1427 description: "Builds code"
1428 triggers: ["build.task", "build.done"]
1429"#;
1430 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1431 let result = config.validate();
1432
1433 assert!(result.is_err());
1434 let err = result.unwrap_err();
1435 assert!(
1436 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1437 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1438 err
1439 );
1440 }
1441
1442 #[test]
1443 fn test_unique_triggers_accepted() {
1444 let yaml = r#"
1447hats:
1448 planner:
1449 name: "Planner"
1450 description: "Plans tasks"
1451 triggers: ["planning.start", "build.done", "build.blocked"]
1452 builder:
1453 name: "Builder"
1454 description: "Builds code"
1455 triggers: ["build.task"]
1456"#;
1457 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1458 let result = config.validate();
1459
1460 assert!(
1461 result.is_ok(),
1462 "Expected valid config, got: {:?}",
1463 result.unwrap_err()
1464 );
1465 }
1466
1467 #[test]
1468 fn test_reserved_trigger_task_start_rejected() {
1469 let yaml = r#"
1471hats:
1472 my_hat:
1473 name: "My Hat"
1474 description: "Test hat"
1475 triggers: ["task.start"]
1476"#;
1477 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1478 let result = config.validate();
1479
1480 assert!(result.is_err());
1481 let err = result.unwrap_err();
1482 assert!(
1483 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1484 if trigger == "task.start" && hat == "my_hat"),
1485 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1486 err
1487 );
1488 }
1489
1490 #[test]
1491 fn test_reserved_trigger_task_resume_rejected() {
1492 let yaml = r#"
1494hats:
1495 my_hat:
1496 name: "My Hat"
1497 description: "Test hat"
1498 triggers: ["task.resume", "other.event"]
1499"#;
1500 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1501 let result = config.validate();
1502
1503 assert!(result.is_err());
1504 let err = result.unwrap_err();
1505 assert!(
1506 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1507 if trigger == "task.resume" && hat == "my_hat"),
1508 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1509 err
1510 );
1511 }
1512
1513 #[test]
1514 fn test_missing_description_rejected() {
1515 let yaml = r#"
1517hats:
1518 my_hat:
1519 name: "My Hat"
1520 triggers: ["build.task"]
1521"#;
1522 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1523 let result = config.validate();
1524
1525 assert!(result.is_err());
1526 let err = result.unwrap_err();
1527 assert!(
1528 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1529 "Expected MissingDescription error, got: {:?}",
1530 err
1531 );
1532 }
1533
1534 #[test]
1535 fn test_empty_description_rejected() {
1536 let yaml = r#"
1538hats:
1539 my_hat:
1540 name: "My Hat"
1541 description: " "
1542 triggers: ["build.task"]
1543"#;
1544 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1545 let result = config.validate();
1546
1547 assert!(result.is_err());
1548 let err = result.unwrap_err();
1549 assert!(
1550 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1551 "Expected MissingDescription error for empty description, got: {:?}",
1552 err
1553 );
1554 }
1555
1556 #[test]
1557 fn test_core_config_defaults() {
1558 let config = RalphConfig::default();
1559 assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
1560 assert_eq!(config.core.specs_dir, ".ralph/specs/");
1561 assert_eq!(config.core.guardrails.len(), 4);
1563 assert!(config.core.guardrails[0].contains("Fresh context"));
1564 assert!(config.core.guardrails[1].contains("search first"));
1565 assert!(config.core.guardrails[2].contains("Backpressure"));
1566 assert!(config.core.guardrails[3].contains("Commit atomically"));
1567 }
1568
1569 #[test]
1570 fn test_core_config_customizable() {
1571 let yaml = r#"
1572core:
1573 scratchpad: ".workspace/plan.md"
1574 specs_dir: "./specifications/"
1575"#;
1576 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1577 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1578 assert_eq!(config.core.specs_dir, "./specifications/");
1579 assert_eq!(config.core.guardrails.len(), 4);
1581 }
1582
1583 #[test]
1584 fn test_core_config_custom_guardrails() {
1585 let yaml = r#"
1586core:
1587 scratchpad: ".ralph/agent/scratchpad.md"
1588 specs_dir: "./specs/"
1589 guardrails:
1590 - "Custom rule one"
1591 - "Custom rule two"
1592"#;
1593 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1594 assert_eq!(config.core.guardrails.len(), 2);
1595 assert_eq!(config.core.guardrails[0], "Custom rule one");
1596 assert_eq!(config.core.guardrails[1], "Custom rule two");
1597 }
1598
1599 #[test]
1600 fn test_prompt_and_prompt_file_mutually_exclusive() {
1601 let yaml = r#"
1603event_loop:
1604 prompt: "inline text"
1605 prompt_file: "custom.md"
1606"#;
1607 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1608 let result = config.validate();
1609
1610 assert!(result.is_err());
1611 let err = result.unwrap_err();
1612 assert!(
1613 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1614 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1615 "Expected MutuallyExclusive error, got: {:?}",
1616 err
1617 );
1618 }
1619
1620 #[test]
1621 fn test_prompt_with_default_prompt_file_allowed() {
1622 let yaml = r#"
1624event_loop:
1625 prompt: "inline text"
1626"#;
1627 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1628 let result = config.validate();
1629
1630 assert!(
1631 result.is_ok(),
1632 "Should allow inline prompt with default prompt_file"
1633 );
1634 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1635 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1636 }
1637
1638 #[test]
1639 fn test_custom_backend_requires_command() {
1640 let yaml = r#"
1642cli:
1643 backend: "custom"
1644"#;
1645 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1646 let result = config.validate();
1647
1648 assert!(result.is_err());
1649 let err = result.unwrap_err();
1650 assert!(
1651 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1652 "Expected CustomBackendRequiresCommand error, got: {:?}",
1653 err
1654 );
1655 }
1656
1657 #[test]
1658 fn test_custom_backend_with_empty_command_errors() {
1659 let yaml = r#"
1661cli:
1662 backend: "custom"
1663 command: ""
1664"#;
1665 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1666 let result = config.validate();
1667
1668 assert!(result.is_err());
1669 let err = result.unwrap_err();
1670 assert!(
1671 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1672 "Expected CustomBackendRequiresCommand error, got: {:?}",
1673 err
1674 );
1675 }
1676
1677 #[test]
1678 fn test_custom_backend_with_command_succeeds() {
1679 let yaml = r#"
1681cli:
1682 backend: "custom"
1683 command: "my-agent"
1684"#;
1685 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1686 let result = config.validate();
1687
1688 assert!(
1689 result.is_ok(),
1690 "Should allow custom backend with command: {:?}",
1691 result.unwrap_err()
1692 );
1693 }
1694
1695 #[test]
1696 fn test_prompt_file_with_no_inline_allowed() {
1697 let yaml = r#"
1699event_loop:
1700 prompt_file: "custom.md"
1701"#;
1702 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1703 let result = config.validate();
1704
1705 assert!(
1706 result.is_ok(),
1707 "Should allow prompt_file without inline prompt"
1708 );
1709 assert_eq!(config.event_loop.prompt, None);
1710 assert_eq!(config.event_loop.prompt_file, "custom.md");
1711 }
1712
1713 #[test]
1714 fn test_default_prompt_file_value() {
1715 let config = RalphConfig::default();
1716 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1717 assert_eq!(config.event_loop.prompt, None);
1718 }
1719
1720 #[test]
1721 fn test_tui_config_default() {
1722 let config = RalphConfig::default();
1723 assert_eq!(config.tui.prefix_key, "ctrl-a");
1724 }
1725
1726 #[test]
1727 fn test_tui_config_parse_ctrl_b() {
1728 let yaml = r#"
1729tui:
1730 prefix_key: "ctrl-b"
1731"#;
1732 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1733 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1734
1735 use crossterm::event::{KeyCode, KeyModifiers};
1736 assert_eq!(key_code, KeyCode::Char('b'));
1737 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1738 }
1739
1740 #[test]
1741 fn test_tui_config_parse_invalid_format() {
1742 let tui_config = TuiConfig {
1743 prefix_key: "invalid".to_string(),
1744 };
1745 let result = tui_config.parse_prefix();
1746 assert!(result.is_err());
1747 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1748 }
1749
1750 #[test]
1751 fn test_tui_config_parse_invalid_modifier() {
1752 let tui_config = TuiConfig {
1753 prefix_key: "alt-a".to_string(),
1754 };
1755 let result = tui_config.parse_prefix();
1756 assert!(result.is_err());
1757 assert!(result.unwrap_err().contains("Invalid modifier"));
1758 }
1759
1760 #[test]
1761 fn test_tui_config_parse_invalid_key() {
1762 let tui_config = TuiConfig {
1763 prefix_key: "ctrl-abc".to_string(),
1764 };
1765 let result = tui_config.parse_prefix();
1766 assert!(result.is_err());
1767 assert!(result.unwrap_err().contains("Invalid key"));
1768 }
1769
1770 #[test]
1771 fn test_hat_backend_named() {
1772 let yaml = r#""claude""#;
1773 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1774 assert_eq!(backend.to_cli_backend(), "claude");
1775 match backend {
1776 HatBackend::Named(name) => assert_eq!(name, "claude"),
1777 _ => panic!("Expected Named variant"),
1778 }
1779 }
1780
1781 #[test]
1782 fn test_hat_backend_kiro_agent() {
1783 let yaml = r#"
1784type: "kiro"
1785agent: "builder"
1786"#;
1787 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1788 assert_eq!(backend.to_cli_backend(), "kiro");
1789 match backend {
1790 HatBackend::KiroAgent {
1791 backend_type,
1792 agent,
1793 args,
1794 } => {
1795 assert_eq!(backend_type, "kiro");
1796 assert_eq!(agent, "builder");
1797 assert!(args.is_empty());
1798 }
1799 _ => panic!("Expected KiroAgent variant"),
1800 }
1801 }
1802
1803 #[test]
1804 fn test_hat_backend_kiro_agent_with_args() {
1805 let yaml = r#"
1806type: "kiro"
1807agent: "builder"
1808args: ["--verbose", "--debug"]
1809"#;
1810 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1811 assert_eq!(backend.to_cli_backend(), "kiro");
1812 match backend {
1813 HatBackend::KiroAgent {
1814 backend_type,
1815 agent,
1816 args,
1817 } => {
1818 assert_eq!(backend_type, "kiro");
1819 assert_eq!(agent, "builder");
1820 assert_eq!(args, vec!["--verbose", "--debug"]);
1821 }
1822 _ => panic!("Expected KiroAgent variant"),
1823 }
1824 }
1825
1826 #[test]
1827 fn test_hat_backend_named_with_args() {
1828 let yaml = r#"
1829type: "claude"
1830args: ["--model", "claude-sonnet-4"]
1831"#;
1832 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1833 assert_eq!(backend.to_cli_backend(), "claude");
1834 match backend {
1835 HatBackend::NamedWithArgs { backend_type, args } => {
1836 assert_eq!(backend_type, "claude");
1837 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
1838 }
1839 _ => panic!("Expected NamedWithArgs variant"),
1840 }
1841 }
1842
1843 #[test]
1844 fn test_hat_backend_named_with_args_empty() {
1845 let yaml = r#"
1847type: "gemini"
1848"#;
1849 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1850 assert_eq!(backend.to_cli_backend(), "gemini");
1851 match backend {
1852 HatBackend::NamedWithArgs { backend_type, args } => {
1853 assert_eq!(backend_type, "gemini");
1854 assert!(args.is_empty());
1855 }
1856 _ => panic!("Expected NamedWithArgs variant"),
1857 }
1858 }
1859
1860 #[test]
1861 fn test_hat_backend_custom() {
1862 let yaml = r#"
1863command: "/usr/bin/my-agent"
1864args: ["--flag", "value"]
1865"#;
1866 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1867 assert_eq!(backend.to_cli_backend(), "custom");
1868 match backend {
1869 HatBackend::Custom { command, args } => {
1870 assert_eq!(command, "/usr/bin/my-agent");
1871 assert_eq!(args, vec!["--flag", "value"]);
1872 }
1873 _ => panic!("Expected Custom variant"),
1874 }
1875 }
1876
1877 #[test]
1878 fn test_hat_config_with_backend() {
1879 let yaml = r#"
1880name: "Custom Builder"
1881triggers: ["build.task"]
1882publishes: ["build.done"]
1883instructions: "Build stuff"
1884backend: "gemini"
1885default_publishes: "task.done"
1886"#;
1887 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1888 assert_eq!(hat.name, "Custom Builder");
1889 assert!(hat.backend.is_some());
1890 match hat.backend.unwrap() {
1891 HatBackend::Named(name) => assert_eq!(name, "gemini"),
1892 _ => panic!("Expected Named backend"),
1893 }
1894 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1895 }
1896
1897 #[test]
1898 fn test_hat_config_without_backend() {
1899 let yaml = r#"
1900name: "Default Hat"
1901triggers: ["task.start"]
1902publishes: ["task.done"]
1903instructions: "Do work"
1904"#;
1905 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1906 assert_eq!(hat.name, "Default Hat");
1907 assert!(hat.backend.is_none());
1908 assert!(hat.default_publishes.is_none());
1909 }
1910
1911 #[test]
1912 fn test_mixed_backends_config() {
1913 let yaml = r#"
1914event_loop:
1915 prompt_file: "TASK.md"
1916 max_iterations: 50
1917
1918cli:
1919 backend: "claude"
1920
1921hats:
1922 planner:
1923 name: "Planner"
1924 triggers: ["task.start"]
1925 publishes: ["build.task"]
1926 instructions: "Plan the work"
1927 backend: "claude"
1928
1929 builder:
1930 name: "Builder"
1931 triggers: ["build.task"]
1932 publishes: ["build.done"]
1933 instructions: "Build the thing"
1934 backend:
1935 type: "kiro"
1936 agent: "builder"
1937
1938 reviewer:
1939 name: "Reviewer"
1940 triggers: ["build.done"]
1941 publishes: ["review.complete"]
1942 instructions: "Review the work"
1943 backend:
1944 command: "/usr/local/bin/custom-agent"
1945 args: ["--mode", "review"]
1946 default_publishes: "review.complete"
1947"#;
1948 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1949 assert_eq!(config.hats.len(), 3);
1950
1951 let planner = config.hats.get("planner").unwrap();
1953 assert!(planner.backend.is_some());
1954 match planner.backend.as_ref().unwrap() {
1955 HatBackend::Named(name) => assert_eq!(name, "claude"),
1956 _ => panic!("Expected Named backend for planner"),
1957 }
1958
1959 let builder = config.hats.get("builder").unwrap();
1961 assert!(builder.backend.is_some());
1962 match builder.backend.as_ref().unwrap() {
1963 HatBackend::KiroAgent {
1964 backend_type,
1965 agent,
1966 args,
1967 } => {
1968 assert_eq!(backend_type, "kiro");
1969 assert_eq!(agent, "builder");
1970 assert!(args.is_empty());
1971 }
1972 _ => panic!("Expected KiroAgent backend for builder"),
1973 }
1974
1975 let reviewer = config.hats.get("reviewer").unwrap();
1977 assert!(reviewer.backend.is_some());
1978 match reviewer.backend.as_ref().unwrap() {
1979 HatBackend::Custom { command, args } => {
1980 assert_eq!(command, "/usr/local/bin/custom-agent");
1981 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1982 }
1983 _ => panic!("Expected Custom backend for reviewer"),
1984 }
1985 assert_eq!(
1986 reviewer.default_publishes,
1987 Some("review.complete".to_string())
1988 );
1989 }
1990
1991 #[test]
1992 fn test_features_config_auto_merge_defaults_to_false() {
1993 let config = RalphConfig::default();
1996 assert!(
1997 !config.features.auto_merge,
1998 "auto_merge should default to false"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_features_config_auto_merge_from_yaml() {
2004 let yaml = r"
2006features:
2007 auto_merge: true
2008";
2009 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2010 assert!(
2011 config.features.auto_merge,
2012 "auto_merge should be true when configured"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_features_config_auto_merge_false_from_yaml() {
2018 let yaml = r"
2020features:
2021 auto_merge: false
2022";
2023 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2024 assert!(
2025 !config.features.auto_merge,
2026 "auto_merge should be false when explicitly configured"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2032 let yaml = r"
2034features:
2035 parallel: false
2036 auto_merge: true
2037";
2038 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2039 assert!(!config.features.parallel, "parallel should be false");
2040 assert!(config.features.auto_merge, "auto_merge should be true");
2041 }
2042}