1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
20 #[serde(default)]
22 pub event_loop: EventLoopConfig,
23
24 #[serde(default)]
26 pub cli: CliConfig,
27
28 #[serde(default)]
30 pub core: CoreConfig,
31
32 #[serde(default)]
35 pub hats: HashMap<String, HatConfig>,
36
37 #[serde(default)]
41 pub events: HashMap<String, EventMetadata>,
42
43 #[serde(default)]
50 pub agent: Option<String>,
51
52 #[serde(default)]
54 pub agent_priority: Vec<String>,
55
56 #[serde(default)]
58 pub prompt_file: Option<String>,
59
60 #[serde(default)]
62 pub completion_promise: Option<String>,
63
64 #[serde(default)]
66 pub max_iterations: Option<u32>,
67
68 #[serde(default)]
70 pub max_runtime: Option<u64>,
71
72 #[serde(default)]
74 pub max_cost: Option<f64>,
75
76 #[serde(default)]
81 pub verbose: bool,
82
83 #[serde(default)]
85 pub archive_prompts: bool,
86
87 #[serde(default)]
89 pub enable_metrics: bool,
90
91 #[serde(default)]
96 pub max_tokens: Option<u32>,
97
98 #[serde(default)]
100 pub retry_delay: Option<u32>,
101
102 #[serde(default)]
104 pub adapters: AdaptersConfig,
105
106 #[serde(default, rename = "_suppress_warnings")]
111 pub suppress_warnings: bool,
112
113 #[serde(default)]
115 pub tui: TuiConfig,
116
117 #[serde(default)]
119 pub memories: MemoriesConfig,
120
121 #[serde(default)]
123 pub tasks: TasksConfig,
124
125 #[serde(default)]
127 pub skills: SkillsConfig,
128
129 #[serde(default)]
131 pub features: FeaturesConfig,
132
133 #[serde(default, rename = "RObot")]
135 pub robot: RobotConfig,
136}
137
138fn default_true() -> bool {
139 true
140}
141
142#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
144 fn default() -> Self {
145 Self {
146 event_loop: EventLoopConfig::default(),
147 cli: CliConfig::default(),
148 core: CoreConfig::default(),
149 hats: HashMap::new(),
150 events: HashMap::new(),
151 agent: None,
153 agent_priority: vec![],
154 prompt_file: None,
155 completion_promise: None,
156 max_iterations: None,
157 max_runtime: None,
158 max_cost: None,
159 verbose: false,
161 archive_prompts: false,
162 enable_metrics: false,
163 max_tokens: None,
165 retry_delay: None,
166 adapters: AdaptersConfig::default(),
167 suppress_warnings: false,
169 tui: TuiConfig::default(),
171 memories: MemoriesConfig::default(),
173 tasks: TasksConfig::default(),
175 skills: SkillsConfig::default(),
177 features: FeaturesConfig::default(),
179 robot: RobotConfig::default(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct AdaptersConfig {
188 #[serde(default)]
190 pub claude: AdapterSettings,
191
192 #[serde(default)]
194 pub gemini: AdapterSettings,
195
196 #[serde(default)]
198 pub kiro: AdapterSettings,
199
200 #[serde(default)]
202 pub codex: AdapterSettings,
203
204 #[serde(default)]
206 pub amp: AdapterSettings,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct AdapterSettings {
212 #[serde(default = "default_timeout")]
214 pub timeout: u64,
215
216 #[serde(default = "default_true")]
218 pub enabled: bool,
219
220 #[serde(default)]
222 pub tool_permissions: Option<Vec<String>>,
223}
224
225fn default_timeout() -> u64 {
226 300 }
228
229impl Default for AdapterSettings {
230 fn default() -> Self {
231 Self {
232 timeout: default_timeout(),
233 enabled: true,
234 tool_permissions: None,
235 }
236 }
237}
238
239impl RalphConfig {
240 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
242 let path_ref = path.as_ref();
243 debug!(path = %path_ref.display(), "Loading configuration from file");
244 let content = std::fs::read_to_string(path_ref)?;
245 Self::parse_yaml(&content)
246 }
247
248 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
250 let config: Self = serde_yaml::from_str(content)?;
251 debug!(
252 backend = %config.cli.backend,
253 has_v1_fields = config.agent.is_some(),
254 custom_hats = config.hats.len(),
255 "Configuration loaded"
256 );
257 Ok(config)
258 }
259
260 pub fn normalize(&mut self) {
265 let mut normalized_count = 0;
266
267 if let Some(ref agent) = self.agent {
269 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
270 self.cli.backend = agent.clone();
271 normalized_count += 1;
272 }
273
274 if let Some(ref pf) = self.prompt_file {
276 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
277 self.event_loop.prompt_file = pf.clone();
278 normalized_count += 1;
279 }
280
281 if let Some(ref cp) = self.completion_promise {
283 debug!(
284 from = "completion_promise",
285 to = "event_loop.completion_promise",
286 "Normalizing v1 field"
287 );
288 self.event_loop.completion_promise = cp.clone();
289 normalized_count += 1;
290 }
291
292 if let Some(mi) = self.max_iterations {
294 debug!(
295 from = "max_iterations",
296 to = "event_loop.max_iterations",
297 value = mi,
298 "Normalizing v1 field"
299 );
300 self.event_loop.max_iterations = mi;
301 normalized_count += 1;
302 }
303
304 if let Some(mr) = self.max_runtime {
306 debug!(
307 from = "max_runtime",
308 to = "event_loop.max_runtime_seconds",
309 value = mr,
310 "Normalizing v1 field"
311 );
312 self.event_loop.max_runtime_seconds = mr;
313 normalized_count += 1;
314 }
315
316 if self.max_cost.is_some() {
318 debug!(
319 from = "max_cost",
320 to = "event_loop.max_cost_usd",
321 "Normalizing v1 field"
322 );
323 self.event_loop.max_cost_usd = self.max_cost;
324 normalized_count += 1;
325 }
326
327 if normalized_count > 0 {
328 debug!(
329 fields_normalized = normalized_count,
330 "V1 to V2 config normalization complete"
331 );
332 }
333 }
334
335 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
345 let mut warnings = Vec::new();
346
347 if self.suppress_warnings {
349 return Ok(warnings);
350 }
351
352 if self.event_loop.prompt.is_some()
355 && !self.event_loop.prompt_file.is_empty()
356 && self.event_loop.prompt_file != default_prompt_file()
357 {
358 return Err(ConfigError::MutuallyExclusive {
359 field1: "event_loop.prompt".to_string(),
360 field2: "event_loop.prompt_file".to_string(),
361 });
362 }
363
364 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
366 return Err(ConfigError::CustomBackendRequiresCommand);
367 }
368
369 if self.archive_prompts {
371 warnings.push(ConfigWarning::DeferredFeature {
372 field: "archive_prompts".to_string(),
373 message: "Feature not yet available in v2".to_string(),
374 });
375 }
376
377 if self.enable_metrics {
378 warnings.push(ConfigWarning::DeferredFeature {
379 field: "enable_metrics".to_string(),
380 message: "Feature not yet available in v2".to_string(),
381 });
382 }
383
384 if self.max_tokens.is_some() {
386 warnings.push(ConfigWarning::DroppedField {
387 field: "max_tokens".to_string(),
388 reason: "Token limits are controlled by the CLI tool".to_string(),
389 });
390 }
391
392 if self.retry_delay.is_some() {
393 warnings.push(ConfigWarning::DroppedField {
394 field: "retry_delay".to_string(),
395 reason: "Retry logic handled differently in v2".to_string(),
396 });
397 }
398
399 if self.adapters.claude.tool_permissions.is_some()
401 || self.adapters.gemini.tool_permissions.is_some()
402 || self.adapters.codex.tool_permissions.is_some()
403 || self.adapters.amp.tool_permissions.is_some()
404 {
405 warnings.push(ConfigWarning::DroppedField {
406 field: "adapters.*.tool_permissions".to_string(),
407 reason: "CLI tool manages its own permissions".to_string(),
408 });
409 }
410
411 self.robot.validate()?;
413
414 for (hat_id, hat_config) in &self.hats {
416 if hat_config
417 .description
418 .as_ref()
419 .is_none_or(|d| d.trim().is_empty())
420 {
421 return Err(ConfigError::MissingDescription {
422 hat: hat_id.clone(),
423 });
424 }
425 }
426
427 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
430 for (hat_id, hat_config) in &self.hats {
431 for trigger in &hat_config.triggers {
432 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
433 return Err(ConfigError::ReservedTrigger {
434 trigger: trigger.clone(),
435 hat: hat_id.clone(),
436 });
437 }
438 }
439 }
440
441 if !self.hats.is_empty() {
444 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
445 for (hat_id, hat_config) in &self.hats {
446 for trigger in &hat_config.triggers {
447 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
448 return Err(ConfigError::AmbiguousRouting {
449 trigger: trigger.clone(),
450 hat1: (*existing_hat).to_string(),
451 hat2: hat_id.clone(),
452 });
453 }
454 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
455 }
456 }
457 }
458
459 Ok(warnings)
460 }
461
462 pub fn effective_backend(&self) -> &str {
464 &self.cli.backend
465 }
466
467 pub fn get_agent_priority(&self) -> Vec<&str> {
470 if self.agent_priority.is_empty() {
471 vec!["claude", "kiro", "gemini", "codex", "amp"]
472 } else {
473 self.agent_priority.iter().map(String::as_str).collect()
474 }
475 }
476
477 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
480 match backend {
481 "claude" => &self.adapters.claude,
482 "gemini" => &self.adapters.gemini,
483 "kiro" => &self.adapters.kiro,
484 "codex" => &self.adapters.codex,
485 "amp" => &self.adapters.amp,
486 _ => &self.adapters.claude, }
488 }
489}
490
491#[derive(Debug, Clone)]
493pub enum ConfigWarning {
494 DeferredFeature { field: String, message: String },
496 DroppedField { field: String, reason: String },
498 InvalidValue { field: String, message: String },
500}
501
502impl std::fmt::Display for ConfigWarning {
503 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505 match self {
506 ConfigWarning::DeferredFeature { field, message }
507 | ConfigWarning::InvalidValue { field, message } => {
508 write!(f, "Warning [{field}]: {message}")
509 }
510 ConfigWarning::DroppedField { field, reason } => {
511 write!(f, "Warning [{field}]: Field ignored - {reason}")
512 }
513 }
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct EventLoopConfig {
520 pub prompt: Option<String>,
522
523 #[serde(default = "default_prompt_file")]
525 pub prompt_file: String,
526
527 #[serde(default = "default_completion_promise")]
529 pub completion_promise: String,
530
531 #[serde(default = "default_max_iterations")]
533 pub max_iterations: u32,
534
535 #[serde(default = "default_max_runtime")]
537 pub max_runtime_seconds: u64,
538
539 pub max_cost_usd: Option<f64>,
541
542 #[serde(default = "default_max_failures")]
544 pub max_consecutive_failures: u32,
545
546 #[serde(default)]
549 pub cooldown_delay_seconds: u64,
550
551 pub starting_hat: Option<String>,
553
554 pub starting_event: Option<String>,
564}
565
566fn default_prompt_file() -> String {
567 "PROMPT.md".to_string()
568}
569
570fn default_completion_promise() -> String {
571 "LOOP_COMPLETE".to_string()
572}
573
574fn default_max_iterations() -> u32 {
575 100
576}
577
578fn default_max_runtime() -> u64 {
579 14400 }
581
582fn default_max_failures() -> u32 {
583 5
584}
585
586impl Default for EventLoopConfig {
587 fn default() -> Self {
588 Self {
589 prompt: None,
590 prompt_file: default_prompt_file(),
591 completion_promise: default_completion_promise(),
592 max_iterations: default_max_iterations(),
593 max_runtime_seconds: default_max_runtime(),
594 max_cost_usd: None,
595 max_consecutive_failures: default_max_failures(),
596 cooldown_delay_seconds: 0,
597 starting_hat: None,
598 starting_event: None,
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CoreConfig {
608 #[serde(default = "default_scratchpad")]
610 pub scratchpad: String,
611
612 #[serde(default = "default_specs_dir")]
614 pub specs_dir: String,
615
616 #[serde(default = "default_guardrails")]
620 pub guardrails: Vec<String>,
621
622 #[serde(skip)]
629 pub workspace_root: std::path::PathBuf,
630}
631
632fn default_scratchpad() -> String {
633 ".ralph/agent/scratchpad.md".to_string()
634}
635
636fn default_specs_dir() -> String {
637 ".ralph/specs/".to_string()
638}
639
640fn default_guardrails() -> Vec<String> {
641 vec![
642 "Fresh context each iteration - scratchpad is memory".to_string(),
643 "Don't assume 'not implemented' - search first".to_string(),
644 "Backpressure is law - tests/typecheck/lint must pass".to_string(),
645 "Commit atomically - one logical change per commit, capture the why".to_string(),
646 ]
647}
648
649impl Default for CoreConfig {
650 fn default() -> Self {
651 Self {
652 scratchpad: default_scratchpad(),
653 specs_dir: default_specs_dir(),
654 guardrails: default_guardrails(),
655 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
656 .map(std::path::PathBuf::from)
657 .unwrap_or_else(|_| {
658 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
659 }),
660 }
661 }
662}
663
664impl CoreConfig {
665 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
669 self.workspace_root = root.into();
670 self
671 }
672
673 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
678 let path = std::path::Path::new(relative);
679 if path.is_absolute() {
680 path.to_path_buf()
681 } else {
682 self.workspace_root.join(path)
683 }
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct CliConfig {
690 #[serde(default = "default_backend")]
692 pub backend: String,
693
694 pub command: Option<String>,
697
698 #[serde(default = "default_prompt_mode")]
700 pub prompt_mode: String,
701
702 #[serde(default = "default_mode")]
705 pub default_mode: String,
706
707 #[serde(default = "default_idle_timeout")]
711 pub idle_timeout_secs: u32,
712
713 #[serde(default)]
716 pub args: Vec<String>,
717
718 #[serde(default)]
721 pub prompt_flag: Option<String>,
722}
723
724fn default_backend() -> String {
725 "claude".to_string()
726}
727
728fn default_prompt_mode() -> String {
729 "arg".to_string()
730}
731
732fn default_mode() -> String {
733 "autonomous".to_string()
734}
735
736fn default_idle_timeout() -> u32 {
737 30 }
739
740impl Default for CliConfig {
741 fn default() -> Self {
742 Self {
743 backend: default_backend(),
744 command: None,
745 prompt_mode: default_prompt_mode(),
746 default_mode: default_mode(),
747 idle_timeout_secs: default_idle_timeout(),
748 args: Vec::new(),
749 prompt_flag: None,
750 }
751 }
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct TuiConfig {
757 #[serde(default = "default_prefix_key")]
759 pub prefix_key: String,
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
766#[serde(rename_all = "lowercase")]
767pub enum InjectMode {
768 #[default]
770 Auto,
771 Manual,
773 None,
775}
776
777impl std::fmt::Display for InjectMode {
778 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779 match self {
780 Self::Auto => write!(f, "auto"),
781 Self::Manual => write!(f, "manual"),
782 Self::None => write!(f, "none"),
783 }
784 }
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct MemoriesConfig {
804 #[serde(default)]
808 pub enabled: bool,
809
810 #[serde(default)]
812 pub inject: InjectMode,
813
814 #[serde(default)]
818 pub budget: usize,
819
820 #[serde(default)]
822 pub filter: MemoriesFilter,
823}
824
825impl Default for MemoriesConfig {
826 fn default() -> Self {
827 Self {
828 enabled: true, inject: InjectMode::Auto,
830 budget: 0,
831 filter: MemoriesFilter::default(),
832 }
833 }
834}
835
836#[derive(Debug, Clone, Default, Serialize, Deserialize)]
840pub struct MemoriesFilter {
841 #[serde(default)]
843 pub types: Vec<String>,
844
845 #[serde(default)]
847 pub tags: Vec<String>,
848
849 #[serde(default)]
851 pub recent: u32,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct TasksConfig {
868 #[serde(default = "default_true")]
872 pub enabled: bool,
873}
874
875impl Default for TasksConfig {
876 fn default() -> Self {
877 Self {
878 enabled: true, }
880 }
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct SkillsConfig {
907 #[serde(default = "default_true")]
909 pub enabled: bool,
910
911 #[serde(default)]
914 pub dirs: Vec<PathBuf>,
915
916 #[serde(default)]
918 pub overrides: HashMap<String, SkillOverride>,
919}
920
921impl Default for SkillsConfig {
922 fn default() -> Self {
923 Self {
924 enabled: true, dirs: vec![],
926 overrides: HashMap::new(),
927 }
928 }
929}
930
931#[derive(Debug, Clone, Default, Serialize, Deserialize)]
936pub struct SkillOverride {
937 #[serde(default)]
939 pub enabled: Option<bool>,
940
941 #[serde(default)]
943 pub hats: Vec<String>,
944
945 #[serde(default)]
947 pub backends: Vec<String>,
948
949 #[serde(default)]
951 pub tags: Vec<String>,
952
953 #[serde(default)]
955 pub auto_inject: Option<bool>,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize)]
979pub struct ChaosModeConfig {
980 #[serde(default)]
982 pub enabled: bool,
983
984 #[serde(default = "default_chaos_max_iterations")]
986 pub max_iterations: u32,
987
988 #[serde(default = "default_chaos_cooldown")]
990 pub cooldown_seconds: u64,
991
992 #[serde(default = "default_chaos_completion")]
994 pub completion_promise: String,
995
996 #[serde(default = "default_research_focus")]
998 pub research_focus: Vec<ResearchFocus>,
999
1000 #[serde(default = "default_chaos_outputs")]
1002 pub outputs: Vec<ChaosOutput>,
1003}
1004
1005fn default_chaos_max_iterations() -> u32 {
1006 5
1007}
1008
1009fn default_chaos_cooldown() -> u64 {
1010 30 }
1012
1013fn default_chaos_completion() -> String {
1014 "CHAOS_COMPLETE".to_string()
1015}
1016
1017fn default_research_focus() -> Vec<ResearchFocus> {
1018 vec![
1019 ResearchFocus::DomainBestPractices,
1020 ResearchFocus::CodebasePatterns,
1021 ResearchFocus::SelfImprovement,
1022 ]
1023}
1024
1025fn default_chaos_outputs() -> Vec<ChaosOutput> {
1026 vec![ChaosOutput::Memories] }
1028
1029impl Default for ChaosModeConfig {
1030 fn default() -> Self {
1031 Self {
1032 enabled: false,
1033 max_iterations: default_chaos_max_iterations(),
1034 cooldown_seconds: default_chaos_cooldown(),
1035 completion_promise: default_chaos_completion(),
1036 research_focus: default_research_focus(),
1037 outputs: default_chaos_outputs(),
1038 }
1039 }
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1044#[serde(rename_all = "snake_case")]
1045pub enum ResearchFocus {
1046 DomainBestPractices,
1048 CodebasePatterns,
1050 SelfImprovement,
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1056#[serde(rename_all = "snake_case")]
1057pub enum ChaosOutput {
1058 Memories,
1060 Tasks,
1062 Specs,
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct FeaturesConfig {
1082 #[serde(default = "default_true")]
1087 pub parallel: bool,
1088
1089 #[serde(default)]
1095 pub auto_merge: bool,
1096
1097 #[serde(default)]
1103 pub loop_naming: crate::loop_name::LoopNamingConfig,
1104
1105 #[serde(default)]
1110 pub chaos_mode: ChaosModeConfig,
1111}
1112
1113impl Default for FeaturesConfig {
1114 fn default() -> Self {
1115 Self {
1116 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1119 chaos_mode: ChaosModeConfig::default(),
1120 }
1121 }
1122}
1123
1124fn default_prefix_key() -> String {
1125 "ctrl-a".to_string()
1126}
1127
1128impl Default for TuiConfig {
1129 fn default() -> Self {
1130 Self {
1131 prefix_key: default_prefix_key(),
1132 }
1133 }
1134}
1135
1136impl TuiConfig {
1137 pub fn parse_prefix(
1140 &self,
1141 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1142 use crossterm::event::{KeyCode, KeyModifiers};
1143
1144 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1145 if parts.len() != 2 {
1146 return Err(format!(
1147 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1148 self.prefix_key
1149 ));
1150 }
1151
1152 let modifier = match parts[0].to_lowercase().as_str() {
1153 "ctrl" => KeyModifiers::CONTROL,
1154 _ => {
1155 return Err(format!(
1156 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1157 parts[0]
1158 ));
1159 }
1160 };
1161
1162 let key_str = parts[1];
1163 if key_str.len() != 1 {
1164 return Err(format!(
1165 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1166 key_str
1167 ));
1168 }
1169
1170 let key_char = key_str.chars().next().unwrap();
1171 let key_code = KeyCode::Char(key_char);
1172
1173 Ok((key_code, modifier))
1174 }
1175}
1176
1177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1192pub struct EventMetadata {
1193 #[serde(default)]
1195 pub description: String,
1196
1197 #[serde(default)]
1200 pub on_trigger: String,
1201
1202 #[serde(default)]
1205 pub on_publish: String,
1206}
1207
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1210#[serde(untagged)]
1211pub enum HatBackend {
1212 KiroAgent {
1215 #[serde(rename = "type")]
1216 backend_type: String,
1217 agent: String,
1218 #[serde(default)]
1219 args: Vec<String>,
1220 },
1221 NamedWithArgs {
1223 #[serde(rename = "type")]
1224 backend_type: String,
1225 #[serde(default)]
1226 args: Vec<String>,
1227 },
1228 Named(String),
1230 Custom {
1232 command: String,
1233 #[serde(default)]
1234 args: Vec<String>,
1235 },
1236}
1237
1238impl HatBackend {
1239 pub fn to_cli_backend(&self) -> String {
1241 match self {
1242 HatBackend::Named(name) => name.clone(),
1243 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1244 HatBackend::KiroAgent { .. } => "kiro".to_string(),
1245 HatBackend::Custom { .. } => "custom".to_string(),
1246 }
1247 }
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize)]
1252pub struct HatConfig {
1253 pub name: String,
1255
1256 pub description: Option<String>,
1259
1260 #[serde(default)]
1263 pub triggers: Vec<String>,
1264
1265 #[serde(default)]
1267 pub publishes: Vec<String>,
1268
1269 #[serde(default)]
1271 pub instructions: String,
1272
1273 #[serde(default)]
1275 pub backend: Option<HatBackend>,
1276
1277 #[serde(default)]
1279 pub default_publishes: Option<String>,
1280
1281 pub max_activations: Option<u32>,
1286}
1287
1288impl HatConfig {
1289 pub fn trigger_topics(&self) -> Vec<Topic> {
1291 self.triggers.iter().map(|s| Topic::new(s)).collect()
1292 }
1293
1294 pub fn publish_topics(&self) -> Vec<Topic> {
1296 self.publishes.iter().map(|s| Topic::new(s)).collect()
1297 }
1298}
1299
1300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1317pub struct RobotConfig {
1318 #[serde(default)]
1320 pub enabled: bool,
1321
1322 pub timeout_seconds: Option<u64>,
1325
1326 pub checkin_interval_seconds: Option<u64>,
1330
1331 #[serde(default)]
1333 pub telegram: Option<TelegramBotConfig>,
1334}
1335
1336impl RobotConfig {
1337 pub fn validate(&self) -> Result<(), ConfigError> {
1339 if !self.enabled {
1340 return Ok(());
1341 }
1342
1343 if self.timeout_seconds.is_none() {
1344 return Err(ConfigError::RobotMissingField {
1345 field: "RObot.timeout_seconds".to_string(),
1346 hint: "timeout_seconds is required when RObot is enabled".to_string(),
1347 });
1348 }
1349
1350 if self.resolve_bot_token().is_none() {
1352 return Err(ConfigError::RobotMissingField {
1353 field: "RObot.telegram.bot_token".to_string(),
1354 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1355 .to_string(),
1356 });
1357 }
1358
1359 Ok(())
1360 }
1361
1362 pub fn resolve_bot_token(&self) -> Option<String> {
1369 std::env::var("RALPH_TELEGRAM_BOT_TOKEN")
1371 .ok()
1372 .or_else(|| {
1374 keyring::Entry::new("ralph", "telegram-bot-token")
1375 .ok()
1376 .and_then(|e| e.get_password().ok())
1377 })
1378 .or_else(|| self.telegram.as_ref()?.bot_token.clone())
1380 }
1381}
1382
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1385pub struct TelegramBotConfig {
1386 pub bot_token: Option<String>,
1388}
1389
1390#[derive(Debug, thiserror::Error)]
1392pub enum ConfigError {
1393 #[error("IO error: {0}")]
1394 Io(#[from] std::io::Error),
1395
1396 #[error("YAML parse error: {0}")]
1397 Yaml(#[from] serde_yaml::Error),
1398
1399 #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
1400 AmbiguousRouting {
1401 trigger: String,
1402 hat1: String,
1403 hat2: String,
1404 },
1405
1406 #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
1407 MutuallyExclusive { field1: String, field2: String },
1408
1409 #[error("Custom backend requires a command - set 'cli.command' in config")]
1410 CustomBackendRequiresCommand,
1411
1412 #[error(
1413 "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."
1414 )]
1415 ReservedTrigger { trigger: String, hat: String },
1416
1417 #[error(
1418 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
1419 )]
1420 MissingDescription { hat: String },
1421
1422 #[error("RObot config error: {field} - {hint}")]
1423 RobotMissingField { field: String, hint: String },
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::*;
1429
1430 #[test]
1431 fn test_default_config() {
1432 let config = RalphConfig::default();
1433 assert!(config.hats.is_empty());
1435 assert_eq!(config.event_loop.max_iterations, 100);
1436 assert!(!config.verbose);
1437 }
1438
1439 #[test]
1440 fn test_parse_yaml_with_custom_hats() {
1441 let yaml = r#"
1442event_loop:
1443 prompt_file: "TASK.md"
1444 completion_promise: "DONE"
1445 max_iterations: 50
1446cli:
1447 backend: "claude"
1448hats:
1449 implementer:
1450 name: "Implementer"
1451 triggers: ["task.*", "review.done"]
1452 publishes: ["impl.done"]
1453 instructions: "You are the implementation agent."
1454"#;
1455 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1456 assert_eq!(config.hats.len(), 1);
1458 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1459
1460 let hat = config.hats.get("implementer").unwrap();
1461 assert_eq!(hat.triggers.len(), 2);
1462 }
1463
1464 #[test]
1465 fn test_parse_yaml_v1_format() {
1466 let yaml = r#"
1468agent: gemini
1469prompt_file: "TASK.md"
1470completion_promise: "RALPH_DONE"
1471max_iterations: 75
1472max_runtime: 7200
1473max_cost: 10.0
1474verbose: true
1475"#;
1476 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1477
1478 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
1484
1485 assert_eq!(config.cli.backend, "gemini");
1487 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1488 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1489 assert_eq!(config.event_loop.max_iterations, 75);
1490 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1491 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1492 assert!(config.verbose);
1493 }
1494
1495 #[test]
1496 fn test_agent_priority() {
1497 let yaml = r"
1498agent: auto
1499agent_priority: [gemini, claude, codex]
1500";
1501 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1502 let priority = config.get_agent_priority();
1503 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
1504 }
1505
1506 #[test]
1507 fn test_default_agent_priority() {
1508 let config = RalphConfig::default();
1509 let priority = config.get_agent_priority();
1510 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
1511 }
1512
1513 #[test]
1514 fn test_validate_deferred_features() {
1515 let yaml = r"
1516archive_prompts: true
1517enable_metrics: true
1518";
1519 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1520 let warnings = config.validate().unwrap();
1521
1522 assert_eq!(warnings.len(), 2);
1523 assert!(warnings
1524 .iter()
1525 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
1526 assert!(warnings
1527 .iter()
1528 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
1529 }
1530
1531 #[test]
1532 fn test_validate_dropped_fields() {
1533 let yaml = r#"
1534max_tokens: 4096
1535retry_delay: 5
1536adapters:
1537 claude:
1538 tool_permissions: ["read", "write"]
1539"#;
1540 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1541 let warnings = config.validate().unwrap();
1542
1543 assert_eq!(warnings.len(), 3);
1544 assert!(warnings.iter().any(
1545 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
1546 ));
1547 assert!(warnings.iter().any(
1548 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
1549 ));
1550 assert!(warnings
1551 .iter()
1552 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1553 }
1554
1555 #[test]
1556 fn test_suppress_warnings() {
1557 let yaml = r"
1558_suppress_warnings: true
1559archive_prompts: true
1560max_tokens: 4096
1561";
1562 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1563 let warnings = config.validate().unwrap();
1564
1565 assert!(warnings.is_empty());
1567 }
1568
1569 #[test]
1570 fn test_adapter_settings() {
1571 let yaml = r"
1572adapters:
1573 claude:
1574 timeout: 600
1575 enabled: true
1576 gemini:
1577 timeout: 300
1578 enabled: false
1579";
1580 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1581
1582 let claude = config.adapter_settings("claude");
1583 assert_eq!(claude.timeout, 600);
1584 assert!(claude.enabled);
1585
1586 let gemini = config.adapter_settings("gemini");
1587 assert_eq!(gemini.timeout, 300);
1588 assert!(!gemini.enabled);
1589 }
1590
1591 #[test]
1592 fn test_unknown_fields_ignored() {
1593 let yaml = r#"
1595agent: claude
1596unknown_field: "some value"
1597future_feature: true
1598"#;
1599 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1600 assert!(result.is_ok());
1602 }
1603
1604 #[test]
1605 fn test_ambiguous_routing_rejected() {
1606 let yaml = r#"
1609hats:
1610 planner:
1611 name: "Planner"
1612 description: "Plans tasks"
1613 triggers: ["planning.start", "build.done"]
1614 builder:
1615 name: "Builder"
1616 description: "Builds code"
1617 triggers: ["build.task", "build.done"]
1618"#;
1619 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1620 let result = config.validate();
1621
1622 assert!(result.is_err());
1623 let err = result.unwrap_err();
1624 assert!(
1625 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1626 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1627 err
1628 );
1629 }
1630
1631 #[test]
1632 fn test_unique_triggers_accepted() {
1633 let yaml = r#"
1636hats:
1637 planner:
1638 name: "Planner"
1639 description: "Plans tasks"
1640 triggers: ["planning.start", "build.done", "build.blocked"]
1641 builder:
1642 name: "Builder"
1643 description: "Builds code"
1644 triggers: ["build.task"]
1645"#;
1646 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1647 let result = config.validate();
1648
1649 assert!(
1650 result.is_ok(),
1651 "Expected valid config, got: {:?}",
1652 result.unwrap_err()
1653 );
1654 }
1655
1656 #[test]
1657 fn test_reserved_trigger_task_start_rejected() {
1658 let yaml = r#"
1660hats:
1661 my_hat:
1662 name: "My Hat"
1663 description: "Test hat"
1664 triggers: ["task.start"]
1665"#;
1666 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1667 let result = config.validate();
1668
1669 assert!(result.is_err());
1670 let err = result.unwrap_err();
1671 assert!(
1672 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1673 if trigger == "task.start" && hat == "my_hat"),
1674 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1675 err
1676 );
1677 }
1678
1679 #[test]
1680 fn test_reserved_trigger_task_resume_rejected() {
1681 let yaml = r#"
1683hats:
1684 my_hat:
1685 name: "My Hat"
1686 description: "Test hat"
1687 triggers: ["task.resume", "other.event"]
1688"#;
1689 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1690 let result = config.validate();
1691
1692 assert!(result.is_err());
1693 let err = result.unwrap_err();
1694 assert!(
1695 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1696 if trigger == "task.resume" && hat == "my_hat"),
1697 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1698 err
1699 );
1700 }
1701
1702 #[test]
1703 fn test_missing_description_rejected() {
1704 let yaml = r#"
1706hats:
1707 my_hat:
1708 name: "My Hat"
1709 triggers: ["build.task"]
1710"#;
1711 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1712 let result = config.validate();
1713
1714 assert!(result.is_err());
1715 let err = result.unwrap_err();
1716 assert!(
1717 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1718 "Expected MissingDescription error, got: {:?}",
1719 err
1720 );
1721 }
1722
1723 #[test]
1724 fn test_empty_description_rejected() {
1725 let yaml = r#"
1727hats:
1728 my_hat:
1729 name: "My Hat"
1730 description: " "
1731 triggers: ["build.task"]
1732"#;
1733 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1734 let result = config.validate();
1735
1736 assert!(result.is_err());
1737 let err = result.unwrap_err();
1738 assert!(
1739 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1740 "Expected MissingDescription error for empty description, got: {:?}",
1741 err
1742 );
1743 }
1744
1745 #[test]
1746 fn test_core_config_defaults() {
1747 let config = RalphConfig::default();
1748 assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
1749 assert_eq!(config.core.specs_dir, ".ralph/specs/");
1750 assert_eq!(config.core.guardrails.len(), 4);
1752 assert!(config.core.guardrails[0].contains("Fresh context"));
1753 assert!(config.core.guardrails[1].contains("search first"));
1754 assert!(config.core.guardrails[2].contains("Backpressure"));
1755 assert!(config.core.guardrails[3].contains("Commit atomically"));
1756 }
1757
1758 #[test]
1759 fn test_core_config_customizable() {
1760 let yaml = r#"
1761core:
1762 scratchpad: ".workspace/plan.md"
1763 specs_dir: "./specifications/"
1764"#;
1765 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1766 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1767 assert_eq!(config.core.specs_dir, "./specifications/");
1768 assert_eq!(config.core.guardrails.len(), 4);
1770 }
1771
1772 #[test]
1773 fn test_core_config_custom_guardrails() {
1774 let yaml = r#"
1775core:
1776 scratchpad: ".ralph/agent/scratchpad.md"
1777 specs_dir: "./specs/"
1778 guardrails:
1779 - "Custom rule one"
1780 - "Custom rule two"
1781"#;
1782 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1783 assert_eq!(config.core.guardrails.len(), 2);
1784 assert_eq!(config.core.guardrails[0], "Custom rule one");
1785 assert_eq!(config.core.guardrails[1], "Custom rule two");
1786 }
1787
1788 #[test]
1789 fn test_prompt_and_prompt_file_mutually_exclusive() {
1790 let yaml = r#"
1792event_loop:
1793 prompt: "inline text"
1794 prompt_file: "custom.md"
1795"#;
1796 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1797 let result = config.validate();
1798
1799 assert!(result.is_err());
1800 let err = result.unwrap_err();
1801 assert!(
1802 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1803 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1804 "Expected MutuallyExclusive error, got: {:?}",
1805 err
1806 );
1807 }
1808
1809 #[test]
1810 fn test_prompt_with_default_prompt_file_allowed() {
1811 let yaml = r#"
1813event_loop:
1814 prompt: "inline text"
1815"#;
1816 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1817 let result = config.validate();
1818
1819 assert!(
1820 result.is_ok(),
1821 "Should allow inline prompt with default prompt_file"
1822 );
1823 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1824 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1825 }
1826
1827 #[test]
1828 fn test_custom_backend_requires_command() {
1829 let yaml = r#"
1831cli:
1832 backend: "custom"
1833"#;
1834 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1835 let result = config.validate();
1836
1837 assert!(result.is_err());
1838 let err = result.unwrap_err();
1839 assert!(
1840 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1841 "Expected CustomBackendRequiresCommand error, got: {:?}",
1842 err
1843 );
1844 }
1845
1846 #[test]
1847 fn test_custom_backend_with_empty_command_errors() {
1848 let yaml = r#"
1850cli:
1851 backend: "custom"
1852 command: ""
1853"#;
1854 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1855 let result = config.validate();
1856
1857 assert!(result.is_err());
1858 let err = result.unwrap_err();
1859 assert!(
1860 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1861 "Expected CustomBackendRequiresCommand error, got: {:?}",
1862 err
1863 );
1864 }
1865
1866 #[test]
1867 fn test_custom_backend_with_command_succeeds() {
1868 let yaml = r#"
1870cli:
1871 backend: "custom"
1872 command: "my-agent"
1873"#;
1874 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1875 let result = config.validate();
1876
1877 assert!(
1878 result.is_ok(),
1879 "Should allow custom backend with command: {:?}",
1880 result.unwrap_err()
1881 );
1882 }
1883
1884 #[test]
1885 fn test_prompt_file_with_no_inline_allowed() {
1886 let yaml = r#"
1888event_loop:
1889 prompt_file: "custom.md"
1890"#;
1891 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1892 let result = config.validate();
1893
1894 assert!(
1895 result.is_ok(),
1896 "Should allow prompt_file without inline prompt"
1897 );
1898 assert_eq!(config.event_loop.prompt, None);
1899 assert_eq!(config.event_loop.prompt_file, "custom.md");
1900 }
1901
1902 #[test]
1903 fn test_default_prompt_file_value() {
1904 let config = RalphConfig::default();
1905 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1906 assert_eq!(config.event_loop.prompt, None);
1907 }
1908
1909 #[test]
1910 fn test_tui_config_default() {
1911 let config = RalphConfig::default();
1912 assert_eq!(config.tui.prefix_key, "ctrl-a");
1913 }
1914
1915 #[test]
1916 fn test_tui_config_parse_ctrl_b() {
1917 let yaml = r#"
1918tui:
1919 prefix_key: "ctrl-b"
1920"#;
1921 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1922 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1923
1924 use crossterm::event::{KeyCode, KeyModifiers};
1925 assert_eq!(key_code, KeyCode::Char('b'));
1926 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1927 }
1928
1929 #[test]
1930 fn test_tui_config_parse_invalid_format() {
1931 let tui_config = TuiConfig {
1932 prefix_key: "invalid".to_string(),
1933 };
1934 let result = tui_config.parse_prefix();
1935 assert!(result.is_err());
1936 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1937 }
1938
1939 #[test]
1940 fn test_tui_config_parse_invalid_modifier() {
1941 let tui_config = TuiConfig {
1942 prefix_key: "alt-a".to_string(),
1943 };
1944 let result = tui_config.parse_prefix();
1945 assert!(result.is_err());
1946 assert!(result.unwrap_err().contains("Invalid modifier"));
1947 }
1948
1949 #[test]
1950 fn test_tui_config_parse_invalid_key() {
1951 let tui_config = TuiConfig {
1952 prefix_key: "ctrl-abc".to_string(),
1953 };
1954 let result = tui_config.parse_prefix();
1955 assert!(result.is_err());
1956 assert!(result.unwrap_err().contains("Invalid key"));
1957 }
1958
1959 #[test]
1960 fn test_hat_backend_named() {
1961 let yaml = r#""claude""#;
1962 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1963 assert_eq!(backend.to_cli_backend(), "claude");
1964 match backend {
1965 HatBackend::Named(name) => assert_eq!(name, "claude"),
1966 _ => panic!("Expected Named variant"),
1967 }
1968 }
1969
1970 #[test]
1971 fn test_hat_backend_kiro_agent() {
1972 let yaml = r#"
1973type: "kiro"
1974agent: "builder"
1975"#;
1976 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1977 assert_eq!(backend.to_cli_backend(), "kiro");
1978 match backend {
1979 HatBackend::KiroAgent {
1980 backend_type,
1981 agent,
1982 args,
1983 } => {
1984 assert_eq!(backend_type, "kiro");
1985 assert_eq!(agent, "builder");
1986 assert!(args.is_empty());
1987 }
1988 _ => panic!("Expected KiroAgent variant"),
1989 }
1990 }
1991
1992 #[test]
1993 fn test_hat_backend_kiro_agent_with_args() {
1994 let yaml = r#"
1995type: "kiro"
1996agent: "builder"
1997args: ["--verbose", "--debug"]
1998"#;
1999 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2000 assert_eq!(backend.to_cli_backend(), "kiro");
2001 match backend {
2002 HatBackend::KiroAgent {
2003 backend_type,
2004 agent,
2005 args,
2006 } => {
2007 assert_eq!(backend_type, "kiro");
2008 assert_eq!(agent, "builder");
2009 assert_eq!(args, vec!["--verbose", "--debug"]);
2010 }
2011 _ => panic!("Expected KiroAgent variant"),
2012 }
2013 }
2014
2015 #[test]
2016 fn test_hat_backend_named_with_args() {
2017 let yaml = r#"
2018type: "claude"
2019args: ["--model", "claude-sonnet-4"]
2020"#;
2021 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2022 assert_eq!(backend.to_cli_backend(), "claude");
2023 match backend {
2024 HatBackend::NamedWithArgs { backend_type, args } => {
2025 assert_eq!(backend_type, "claude");
2026 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2027 }
2028 _ => panic!("Expected NamedWithArgs variant"),
2029 }
2030 }
2031
2032 #[test]
2033 fn test_hat_backend_named_with_args_empty() {
2034 let yaml = r#"
2036type: "gemini"
2037"#;
2038 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2039 assert_eq!(backend.to_cli_backend(), "gemini");
2040 match backend {
2041 HatBackend::NamedWithArgs { backend_type, args } => {
2042 assert_eq!(backend_type, "gemini");
2043 assert!(args.is_empty());
2044 }
2045 _ => panic!("Expected NamedWithArgs variant"),
2046 }
2047 }
2048
2049 #[test]
2050 fn test_hat_backend_custom() {
2051 let yaml = r#"
2052command: "/usr/bin/my-agent"
2053args: ["--flag", "value"]
2054"#;
2055 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2056 assert_eq!(backend.to_cli_backend(), "custom");
2057 match backend {
2058 HatBackend::Custom { command, args } => {
2059 assert_eq!(command, "/usr/bin/my-agent");
2060 assert_eq!(args, vec!["--flag", "value"]);
2061 }
2062 _ => panic!("Expected Custom variant"),
2063 }
2064 }
2065
2066 #[test]
2067 fn test_hat_config_with_backend() {
2068 let yaml = r#"
2069name: "Custom Builder"
2070triggers: ["build.task"]
2071publishes: ["build.done"]
2072instructions: "Build stuff"
2073backend: "gemini"
2074default_publishes: "task.done"
2075"#;
2076 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2077 assert_eq!(hat.name, "Custom Builder");
2078 assert!(hat.backend.is_some());
2079 match hat.backend.unwrap() {
2080 HatBackend::Named(name) => assert_eq!(name, "gemini"),
2081 _ => panic!("Expected Named backend"),
2082 }
2083 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2084 }
2085
2086 #[test]
2087 fn test_hat_config_without_backend() {
2088 let yaml = r#"
2089name: "Default Hat"
2090triggers: ["task.start"]
2091publishes: ["task.done"]
2092instructions: "Do work"
2093"#;
2094 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2095 assert_eq!(hat.name, "Default Hat");
2096 assert!(hat.backend.is_none());
2097 assert!(hat.default_publishes.is_none());
2098 }
2099
2100 #[test]
2101 fn test_mixed_backends_config() {
2102 let yaml = r#"
2103event_loop:
2104 prompt_file: "TASK.md"
2105 max_iterations: 50
2106
2107cli:
2108 backend: "claude"
2109
2110hats:
2111 planner:
2112 name: "Planner"
2113 triggers: ["task.start"]
2114 publishes: ["build.task"]
2115 instructions: "Plan the work"
2116 backend: "claude"
2117
2118 builder:
2119 name: "Builder"
2120 triggers: ["build.task"]
2121 publishes: ["build.done"]
2122 instructions: "Build the thing"
2123 backend:
2124 type: "kiro"
2125 agent: "builder"
2126
2127 reviewer:
2128 name: "Reviewer"
2129 triggers: ["build.done"]
2130 publishes: ["review.complete"]
2131 instructions: "Review the work"
2132 backend:
2133 command: "/usr/local/bin/custom-agent"
2134 args: ["--mode", "review"]
2135 default_publishes: "review.complete"
2136"#;
2137 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2138 assert_eq!(config.hats.len(), 3);
2139
2140 let planner = config.hats.get("planner").unwrap();
2142 assert!(planner.backend.is_some());
2143 match planner.backend.as_ref().unwrap() {
2144 HatBackend::Named(name) => assert_eq!(name, "claude"),
2145 _ => panic!("Expected Named backend for planner"),
2146 }
2147
2148 let builder = config.hats.get("builder").unwrap();
2150 assert!(builder.backend.is_some());
2151 match builder.backend.as_ref().unwrap() {
2152 HatBackend::KiroAgent {
2153 backend_type,
2154 agent,
2155 args,
2156 } => {
2157 assert_eq!(backend_type, "kiro");
2158 assert_eq!(agent, "builder");
2159 assert!(args.is_empty());
2160 }
2161 _ => panic!("Expected KiroAgent backend for builder"),
2162 }
2163
2164 let reviewer = config.hats.get("reviewer").unwrap();
2166 assert!(reviewer.backend.is_some());
2167 match reviewer.backend.as_ref().unwrap() {
2168 HatBackend::Custom { command, args } => {
2169 assert_eq!(command, "/usr/local/bin/custom-agent");
2170 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2171 }
2172 _ => panic!("Expected Custom backend for reviewer"),
2173 }
2174 assert_eq!(
2175 reviewer.default_publishes,
2176 Some("review.complete".to_string())
2177 );
2178 }
2179
2180 #[test]
2181 fn test_features_config_auto_merge_defaults_to_false() {
2182 let config = RalphConfig::default();
2185 assert!(
2186 !config.features.auto_merge,
2187 "auto_merge should default to false"
2188 );
2189 }
2190
2191 #[test]
2192 fn test_features_config_auto_merge_from_yaml() {
2193 let yaml = r"
2195features:
2196 auto_merge: true
2197";
2198 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2199 assert!(
2200 config.features.auto_merge,
2201 "auto_merge should be true when configured"
2202 );
2203 }
2204
2205 #[test]
2206 fn test_features_config_auto_merge_false_from_yaml() {
2207 let yaml = r"
2209features:
2210 auto_merge: false
2211";
2212 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2213 assert!(
2214 !config.features.auto_merge,
2215 "auto_merge should be false when explicitly configured"
2216 );
2217 }
2218
2219 #[test]
2220 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2221 let yaml = r"
2223features:
2224 parallel: false
2225 auto_merge: true
2226";
2227 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2228 assert!(!config.features.parallel, "parallel should be false");
2229 assert!(config.features.auto_merge, "auto_merge should be true");
2230 }
2231
2232 #[test]
2233 fn test_skills_config_defaults_when_absent() {
2234 let yaml = r"
2236agent: claude
2237";
2238 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2239 assert!(config.skills.enabled);
2240 assert!(config.skills.dirs.is_empty());
2241 assert!(config.skills.overrides.is_empty());
2242 }
2243
2244 #[test]
2245 fn test_skills_config_deserializes_all_fields() {
2246 let yaml = r#"
2247skills:
2248 enabled: true
2249 dirs:
2250 - ".claude/skills"
2251 - "/shared/skills"
2252 overrides:
2253 pdd:
2254 enabled: false
2255 memories:
2256 auto_inject: true
2257 hats: ["ralph"]
2258 backends: ["claude"]
2259 tags: ["core"]
2260"#;
2261 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2262 assert!(config.skills.enabled);
2263 assert_eq!(config.skills.dirs.len(), 2);
2264 assert_eq!(
2265 config.skills.dirs[0],
2266 std::path::PathBuf::from(".claude/skills")
2267 );
2268 assert_eq!(config.skills.overrides.len(), 2);
2269
2270 let pdd = config.skills.overrides.get("pdd").unwrap();
2271 assert_eq!(pdd.enabled, Some(false));
2272
2273 let memories = config.skills.overrides.get("memories").unwrap();
2274 assert_eq!(memories.auto_inject, Some(true));
2275 assert_eq!(memories.hats, vec!["ralph"]);
2276 assert_eq!(memories.backends, vec!["claude"]);
2277 assert_eq!(memories.tags, vec!["core"]);
2278 }
2279
2280 #[test]
2281 fn test_skills_config_disabled() {
2282 let yaml = r"
2283skills:
2284 enabled: false
2285";
2286 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2287 assert!(!config.skills.enabled);
2288 assert!(config.skills.dirs.is_empty());
2289 }
2290
2291 #[test]
2292 fn test_skill_override_partial_fields() {
2293 let yaml = r#"
2294skills:
2295 overrides:
2296 my-skill:
2297 hats: ["builder", "reviewer"]
2298"#;
2299 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2300 let override_ = config.skills.overrides.get("my-skill").unwrap();
2301 assert_eq!(override_.enabled, None);
2302 assert_eq!(override_.auto_inject, None);
2303 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2304 assert!(override_.backends.is_empty());
2305 assert!(override_.tags.is_empty());
2306 }
2307
2308 #[test]
2313 fn test_robot_config_defaults_disabled() {
2314 let config = RalphConfig::default();
2315 assert!(!config.robot.enabled);
2316 assert!(config.robot.timeout_seconds.is_none());
2317 assert!(config.robot.telegram.is_none());
2318 }
2319
2320 #[test]
2321 fn test_robot_config_absent_parses_as_default() {
2322 let yaml = r"
2324agent: claude
2325";
2326 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2327 assert!(!config.robot.enabled);
2328 assert!(config.robot.timeout_seconds.is_none());
2329 }
2330
2331 #[test]
2332 fn test_robot_config_valid_full() {
2333 let yaml = r#"
2334RObot:
2335 enabled: true
2336 timeout_seconds: 300
2337 telegram:
2338 bot_token: "123456:ABC-DEF"
2339"#;
2340 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2341 assert!(config.robot.enabled);
2342 assert_eq!(config.robot.timeout_seconds, Some(300));
2343 let telegram = config.robot.telegram.as_ref().unwrap();
2344 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
2345
2346 assert!(config.validate().is_ok());
2348 }
2349
2350 #[test]
2351 fn test_robot_config_disabled_skips_validation() {
2352 let yaml = r"
2354RObot:
2355 enabled: false
2356";
2357 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2358 assert!(!config.robot.enabled);
2359 assert!(config.validate().is_ok());
2360 }
2361
2362 #[test]
2363 fn test_robot_config_enabled_missing_timeout_fails() {
2364 let yaml = r#"
2365RObot:
2366 enabled: true
2367 telegram:
2368 bot_token: "123456:ABC-DEF"
2369"#;
2370 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2371 let result = config.validate();
2372 assert!(result.is_err());
2373 let err = result.unwrap_err();
2374 assert!(
2375 matches!(&err, ConfigError::RobotMissingField { field, .. }
2376 if field == "RObot.timeout_seconds"),
2377 "Expected RobotMissingField for timeout_seconds, got: {:?}",
2378 err
2379 );
2380 }
2381
2382 #[test]
2383 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
2384 let robot = RobotConfig {
2386 enabled: true,
2387 timeout_seconds: None,
2388 checkin_interval_seconds: None,
2389 telegram: None,
2390 };
2391 let result = robot.validate();
2392 assert!(result.is_err());
2393 let err = result.unwrap_err();
2394 assert!(
2395 matches!(&err, ConfigError::RobotMissingField { field, .. }
2396 if field == "RObot.timeout_seconds"),
2397 "Expected timeout validation failure first, got: {:?}",
2398 err
2399 );
2400 }
2401
2402 #[test]
2403 fn test_robot_config_resolve_bot_token_from_config() {
2404 let config = RobotConfig {
2408 enabled: true,
2409 timeout_seconds: Some(300),
2410 checkin_interval_seconds: None,
2411 telegram: Some(TelegramBotConfig {
2412 bot_token: Some("config-token".to_string()),
2413 }),
2414 };
2415
2416 let resolved = config.resolve_bot_token();
2419 assert!(resolved.is_some());
2422 }
2423
2424 #[test]
2425 fn test_robot_config_resolve_bot_token_none_without_config() {
2426 let config = RobotConfig {
2428 enabled: true,
2429 timeout_seconds: Some(300),
2430 checkin_interval_seconds: None,
2431 telegram: None,
2432 };
2433
2434 let resolved = config.resolve_bot_token();
2437 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
2438 assert!(resolved.is_none());
2439 }
2440 }
2441
2442 #[test]
2443 fn test_robot_config_validate_with_config_token() {
2444 let robot = RobotConfig {
2446 enabled: true,
2447 timeout_seconds: Some(300),
2448 checkin_interval_seconds: None,
2449 telegram: Some(TelegramBotConfig {
2450 bot_token: Some("test-token".to_string()),
2451 }),
2452 };
2453 assert!(robot.validate().is_ok());
2454 }
2455
2456 #[test]
2457 fn test_robot_config_validate_missing_telegram_section() {
2458 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2461 return;
2462 }
2463
2464 let robot = RobotConfig {
2465 enabled: true,
2466 timeout_seconds: Some(300),
2467 checkin_interval_seconds: None,
2468 telegram: None,
2469 };
2470 let result = robot.validate();
2471 assert!(result.is_err());
2472 let err = result.unwrap_err();
2473 assert!(
2474 matches!(&err, ConfigError::RobotMissingField { field, .. }
2475 if field == "RObot.telegram.bot_token"),
2476 "Expected bot_token validation failure, got: {:?}",
2477 err
2478 );
2479 }
2480
2481 #[test]
2482 fn test_robot_config_validate_empty_bot_token() {
2483 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2486 return;
2487 }
2488
2489 let robot = RobotConfig {
2490 enabled: true,
2491 timeout_seconds: Some(300),
2492 checkin_interval_seconds: None,
2493 telegram: Some(TelegramBotConfig { bot_token: None }),
2494 };
2495 let result = robot.validate();
2496 assert!(result.is_err());
2497 let err = result.unwrap_err();
2498 assert!(
2499 matches!(&err, ConfigError::RobotMissingField { field, .. }
2500 if field == "RObot.telegram.bot_token"),
2501 "Expected bot_token validation failure, got: {:?}",
2502 err
2503 );
2504 }
2505}