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 for (hat_id, hat) in &mut self.hats {
329 if !hat.extra_instructions.is_empty() {
330 for fragment in hat.extra_instructions.drain(..) {
331 if !hat.instructions.ends_with('\n') {
332 hat.instructions.push('\n');
333 }
334 hat.instructions.push_str(&fragment);
335 }
336 debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
337 normalized_count += 1;
338 }
339 }
340
341 if normalized_count > 0 {
342 debug!(
343 fields_normalized = normalized_count,
344 "V1 to V2 config normalization complete"
345 );
346 }
347 }
348
349 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
359 let mut warnings = Vec::new();
360
361 if self.suppress_warnings {
363 return Ok(warnings);
364 }
365
366 if self.event_loop.prompt.is_some()
369 && !self.event_loop.prompt_file.is_empty()
370 && self.event_loop.prompt_file != default_prompt_file()
371 {
372 return Err(ConfigError::MutuallyExclusive {
373 field1: "event_loop.prompt".to_string(),
374 field2: "event_loop.prompt_file".to_string(),
375 });
376 }
377 if self.event_loop.completion_promise.trim().is_empty() {
378 return Err(ConfigError::InvalidCompletionPromise);
379 }
380
381 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
383 return Err(ConfigError::CustomBackendRequiresCommand);
384 }
385
386 if self.archive_prompts {
388 warnings.push(ConfigWarning::DeferredFeature {
389 field: "archive_prompts".to_string(),
390 message: "Feature not yet available in v2".to_string(),
391 });
392 }
393
394 if self.enable_metrics {
395 warnings.push(ConfigWarning::DeferredFeature {
396 field: "enable_metrics".to_string(),
397 message: "Feature not yet available in v2".to_string(),
398 });
399 }
400
401 if self.max_tokens.is_some() {
403 warnings.push(ConfigWarning::DroppedField {
404 field: "max_tokens".to_string(),
405 reason: "Token limits are controlled by the CLI tool".to_string(),
406 });
407 }
408
409 if self.retry_delay.is_some() {
410 warnings.push(ConfigWarning::DroppedField {
411 field: "retry_delay".to_string(),
412 reason: "Retry logic handled differently in v2".to_string(),
413 });
414 }
415
416 if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
417 && !(0.0..=100.0).contains(&threshold)
418 {
419 warnings.push(ConfigWarning::InvalidValue {
420 field: "event_loop.mutation_score_warn_threshold".to_string(),
421 message: "Value must be between 0 and 100".to_string(),
422 });
423 }
424
425 if self.adapters.claude.tool_permissions.is_some()
427 || self.adapters.gemini.tool_permissions.is_some()
428 || self.adapters.codex.tool_permissions.is_some()
429 || self.adapters.amp.tool_permissions.is_some()
430 {
431 warnings.push(ConfigWarning::DroppedField {
432 field: "adapters.*.tool_permissions".to_string(),
433 reason: "CLI tool manages its own permissions".to_string(),
434 });
435 }
436
437 self.robot.validate()?;
439
440 for (hat_id, hat_config) in &self.hats {
442 if hat_config
443 .description
444 .as_ref()
445 .is_none_or(|d| d.trim().is_empty())
446 {
447 return Err(ConfigError::MissingDescription {
448 hat: hat_id.clone(),
449 });
450 }
451 }
452
453 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
456 for (hat_id, hat_config) in &self.hats {
457 for trigger in &hat_config.triggers {
458 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
459 return Err(ConfigError::ReservedTrigger {
460 trigger: trigger.clone(),
461 hat: hat_id.clone(),
462 });
463 }
464 }
465 }
466
467 if !self.hats.is_empty() {
470 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
471 for (hat_id, hat_config) in &self.hats {
472 for trigger in &hat_config.triggers {
473 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
474 return Err(ConfigError::AmbiguousRouting {
475 trigger: trigger.clone(),
476 hat1: (*existing_hat).to_string(),
477 hat2: hat_id.clone(),
478 });
479 }
480 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
481 }
482 }
483 }
484
485 Ok(warnings)
486 }
487
488 pub fn effective_backend(&self) -> &str {
490 &self.cli.backend
491 }
492
493 pub fn get_agent_priority(&self) -> Vec<&str> {
496 if self.agent_priority.is_empty() {
497 vec!["claude", "kiro", "gemini", "codex", "amp"]
498 } else {
499 self.agent_priority.iter().map(String::as_str).collect()
500 }
501 }
502
503 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
506 match backend {
507 "claude" => &self.adapters.claude,
508 "gemini" => &self.adapters.gemini,
509 "kiro" => &self.adapters.kiro,
510 "codex" => &self.adapters.codex,
511 "amp" => &self.adapters.amp,
512 _ => &self.adapters.claude, }
514 }
515}
516
517#[derive(Debug, Clone)]
519pub enum ConfigWarning {
520 DeferredFeature { field: String, message: String },
522 DroppedField { field: String, reason: String },
524 InvalidValue { field: String, message: String },
526}
527
528impl std::fmt::Display for ConfigWarning {
529 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 match self {
532 ConfigWarning::DeferredFeature { field, message }
533 | ConfigWarning::InvalidValue { field, message } => {
534 write!(f, "Warning [{field}]: {message}")
535 }
536 ConfigWarning::DroppedField { field, reason } => {
537 write!(f, "Warning [{field}]: Field ignored - {reason}")
538 }
539 }
540 }
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
545pub struct EventLoopConfig {
546 pub prompt: Option<String>,
548
549 #[serde(default = "default_prompt_file")]
551 pub prompt_file: String,
552
553 #[serde(default = "default_completion_promise")]
555 pub completion_promise: String,
556
557 #[serde(default = "default_max_iterations")]
559 pub max_iterations: u32,
560
561 #[serde(default = "default_max_runtime")]
563 pub max_runtime_seconds: u64,
564
565 pub max_cost_usd: Option<f64>,
567
568 #[serde(default = "default_max_failures")]
570 pub max_consecutive_failures: u32,
571
572 #[serde(default)]
575 pub cooldown_delay_seconds: u64,
576
577 pub starting_hat: Option<String>,
579
580 pub starting_event: Option<String>,
590
591 #[serde(default)]
595 pub mutation_score_warn_threshold: Option<f64>,
596
597 #[serde(default)]
604 pub persistent: bool,
605}
606
607fn default_prompt_file() -> String {
608 "PROMPT.md".to_string()
609}
610
611fn default_completion_promise() -> String {
612 "LOOP_COMPLETE".to_string()
613}
614
615fn default_max_iterations() -> u32 {
616 100
617}
618
619fn default_max_runtime() -> u64 {
620 14400 }
622
623fn default_max_failures() -> u32 {
624 5
625}
626
627impl Default for EventLoopConfig {
628 fn default() -> Self {
629 Self {
630 prompt: None,
631 prompt_file: default_prompt_file(),
632 completion_promise: default_completion_promise(),
633 max_iterations: default_max_iterations(),
634 max_runtime_seconds: default_max_runtime(),
635 max_cost_usd: None,
636 max_consecutive_failures: default_max_failures(),
637 cooldown_delay_seconds: 0,
638 starting_hat: None,
639 starting_event: None,
640 mutation_score_warn_threshold: None,
641 persistent: false,
642 }
643 }
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct CoreConfig {
651 #[serde(default = "default_scratchpad")]
653 pub scratchpad: String,
654
655 #[serde(default = "default_specs_dir")]
657 pub specs_dir: String,
658
659 #[serde(default = "default_guardrails")]
663 pub guardrails: Vec<String>,
664
665 #[serde(skip)]
672 pub workspace_root: std::path::PathBuf,
673}
674
675fn default_scratchpad() -> String {
676 ".ralph/agent/scratchpad.md".to_string()
677}
678
679fn default_specs_dir() -> String {
680 ".ralph/specs/".to_string()
681}
682
683fn default_guardrails() -> Vec<String> {
684 vec![
685 "Fresh context each iteration - scratchpad is memory".to_string(),
686 "Don't assume 'not implemented' - search first".to_string(),
687 "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
688 "Confidence protocol: score decisions 0-100. >80 proceed autonomously; 50-80 proceed + document in .ralph/agent/decisions.md; <50 choose safe default + document".to_string(),
689 "Commit atomically - one logical change per commit, capture the why".to_string(),
690 ]
691}
692
693impl Default for CoreConfig {
694 fn default() -> Self {
695 Self {
696 scratchpad: default_scratchpad(),
697 specs_dir: default_specs_dir(),
698 guardrails: default_guardrails(),
699 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
700 .map(std::path::PathBuf::from)
701 .unwrap_or_else(|_| {
702 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
703 }),
704 }
705 }
706}
707
708impl CoreConfig {
709 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
713 self.workspace_root = root.into();
714 self
715 }
716
717 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
722 let path = std::path::Path::new(relative);
723 if path.is_absolute() {
724 path.to_path_buf()
725 } else {
726 self.workspace_root.join(path)
727 }
728 }
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize)]
733pub struct CliConfig {
734 #[serde(default = "default_backend")]
736 pub backend: String,
737
738 pub command: Option<String>,
741
742 #[serde(default = "default_prompt_mode")]
744 pub prompt_mode: String,
745
746 #[serde(default = "default_mode")]
749 pub default_mode: String,
750
751 #[serde(default = "default_idle_timeout")]
755 pub idle_timeout_secs: u32,
756
757 #[serde(default)]
760 pub args: Vec<String>,
761
762 #[serde(default)]
765 pub prompt_flag: Option<String>,
766}
767
768fn default_backend() -> String {
769 "claude".to_string()
770}
771
772fn default_prompt_mode() -> String {
773 "arg".to_string()
774}
775
776fn default_mode() -> String {
777 "autonomous".to_string()
778}
779
780fn default_idle_timeout() -> u32 {
781 30 }
783
784impl Default for CliConfig {
785 fn default() -> Self {
786 Self {
787 backend: default_backend(),
788 command: None,
789 prompt_mode: default_prompt_mode(),
790 default_mode: default_mode(),
791 idle_timeout_secs: default_idle_timeout(),
792 args: Vec::new(),
793 prompt_flag: None,
794 }
795 }
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
800pub struct TuiConfig {
801 #[serde(default = "default_prefix_key")]
803 pub prefix_key: String,
804}
805
806#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
810#[serde(rename_all = "lowercase")]
811pub enum InjectMode {
812 #[default]
814 Auto,
815 Manual,
817 None,
819}
820
821impl std::fmt::Display for InjectMode {
822 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823 match self {
824 Self::Auto => write!(f, "auto"),
825 Self::Manual => write!(f, "manual"),
826 Self::None => write!(f, "none"),
827 }
828 }
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct MemoriesConfig {
848 #[serde(default)]
852 pub enabled: bool,
853
854 #[serde(default)]
856 pub inject: InjectMode,
857
858 #[serde(default)]
862 pub budget: usize,
863
864 #[serde(default)]
866 pub filter: MemoriesFilter,
867}
868
869impl Default for MemoriesConfig {
870 fn default() -> Self {
871 Self {
872 enabled: true, inject: InjectMode::Auto,
874 budget: 0,
875 filter: MemoriesFilter::default(),
876 }
877 }
878}
879
880#[derive(Debug, Clone, Default, Serialize, Deserialize)]
884pub struct MemoriesFilter {
885 #[serde(default)]
887 pub types: Vec<String>,
888
889 #[serde(default)]
891 pub tags: Vec<String>,
892
893 #[serde(default)]
895 pub recent: u32,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize)]
911pub struct TasksConfig {
912 #[serde(default = "default_true")]
916 pub enabled: bool,
917}
918
919impl Default for TasksConfig {
920 fn default() -> Self {
921 Self {
922 enabled: true, }
924 }
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
950pub struct SkillsConfig {
951 #[serde(default = "default_true")]
953 pub enabled: bool,
954
955 #[serde(default)]
958 pub dirs: Vec<PathBuf>,
959
960 #[serde(default)]
962 pub overrides: HashMap<String, SkillOverride>,
963}
964
965impl Default for SkillsConfig {
966 fn default() -> Self {
967 Self {
968 enabled: true, dirs: vec![],
970 overrides: HashMap::new(),
971 }
972 }
973}
974
975#[derive(Debug, Clone, Default, Serialize, Deserialize)]
980pub struct SkillOverride {
981 #[serde(default)]
983 pub enabled: Option<bool>,
984
985 #[serde(default)]
987 pub hats: Vec<String>,
988
989 #[serde(default)]
991 pub backends: Vec<String>,
992
993 #[serde(default)]
995 pub tags: Vec<String>,
996
997 #[serde(default)]
999 pub auto_inject: Option<bool>,
1000}
1001
1002#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1004pub struct PreflightConfig {
1005 #[serde(default)]
1007 pub enabled: bool,
1008
1009 #[serde(default)]
1011 pub strict: bool,
1012
1013 #[serde(default)]
1015 pub skip: Vec<String>,
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1034pub struct FeaturesConfig {
1035 #[serde(default = "default_true")]
1040 pub parallel: bool,
1041
1042 #[serde(default)]
1048 pub auto_merge: bool,
1049
1050 #[serde(default)]
1056 pub loop_naming: crate::loop_name::LoopNamingConfig,
1057
1058 #[serde(default)]
1060 pub preflight: PreflightConfig,
1061}
1062
1063impl Default for FeaturesConfig {
1064 fn default() -> Self {
1065 Self {
1066 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1069 preflight: PreflightConfig::default(),
1070 }
1071 }
1072}
1073
1074fn default_prefix_key() -> String {
1075 "ctrl-a".to_string()
1076}
1077
1078impl Default for TuiConfig {
1079 fn default() -> Self {
1080 Self {
1081 prefix_key: default_prefix_key(),
1082 }
1083 }
1084}
1085
1086impl TuiConfig {
1087 pub fn parse_prefix(
1090 &self,
1091 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1092 use crossterm::event::{KeyCode, KeyModifiers};
1093
1094 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1095 if parts.len() != 2 {
1096 return Err(format!(
1097 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1098 self.prefix_key
1099 ));
1100 }
1101
1102 let modifier = match parts[0].to_lowercase().as_str() {
1103 "ctrl" => KeyModifiers::CONTROL,
1104 _ => {
1105 return Err(format!(
1106 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1107 parts[0]
1108 ));
1109 }
1110 };
1111
1112 let key_str = parts[1];
1113 if key_str.len() != 1 {
1114 return Err(format!(
1115 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1116 key_str
1117 ));
1118 }
1119
1120 let key_char = key_str.chars().next().unwrap();
1121 let key_code = KeyCode::Char(key_char);
1122
1123 Ok((key_code, modifier))
1124 }
1125}
1126
1127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1142pub struct EventMetadata {
1143 #[serde(default)]
1145 pub description: String,
1146
1147 #[serde(default)]
1150 pub on_trigger: String,
1151
1152 #[serde(default)]
1155 pub on_publish: String,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize)]
1160#[serde(untagged)]
1161pub enum HatBackend {
1162 KiroAgent {
1165 #[serde(rename = "type")]
1166 backend_type: String,
1167 agent: String,
1168 #[serde(default)]
1169 args: Vec<String>,
1170 },
1171 NamedWithArgs {
1173 #[serde(rename = "type")]
1174 backend_type: String,
1175 #[serde(default)]
1176 args: Vec<String>,
1177 },
1178 Named(String),
1180 Custom {
1182 command: String,
1183 #[serde(default)]
1184 args: Vec<String>,
1185 },
1186}
1187
1188impl HatBackend {
1189 pub fn to_cli_backend(&self) -> String {
1191 match self {
1192 HatBackend::Named(name) => name.clone(),
1193 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1194 HatBackend::KiroAgent { .. } => "kiro".to_string(),
1195 HatBackend::Custom { .. } => "custom".to_string(),
1196 }
1197 }
1198}
1199
1200#[derive(Debug, Clone, Serialize, Deserialize)]
1202pub struct HatConfig {
1203 pub name: String,
1205
1206 pub description: Option<String>,
1209
1210 #[serde(default)]
1213 pub triggers: Vec<String>,
1214
1215 #[serde(default)]
1217 pub publishes: Vec<String>,
1218
1219 #[serde(default)]
1221 pub instructions: String,
1222
1223 #[serde(default)]
1240 pub extra_instructions: Vec<String>,
1241
1242 #[serde(default)]
1244 pub backend: Option<HatBackend>,
1245
1246 #[serde(default)]
1248 pub default_publishes: Option<String>,
1249
1250 pub max_activations: Option<u32>,
1255}
1256
1257impl HatConfig {
1258 pub fn trigger_topics(&self) -> Vec<Topic> {
1260 self.triggers.iter().map(|s| Topic::new(s)).collect()
1261 }
1262
1263 pub fn publish_topics(&self) -> Vec<Topic> {
1265 self.publishes.iter().map(|s| Topic::new(s)).collect()
1266 }
1267}
1268
1269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1286pub struct RobotConfig {
1287 #[serde(default)]
1289 pub enabled: bool,
1290
1291 pub timeout_seconds: Option<u64>,
1294
1295 pub checkin_interval_seconds: Option<u64>,
1299
1300 #[serde(default)]
1302 pub telegram: Option<TelegramBotConfig>,
1303}
1304
1305impl RobotConfig {
1306 pub fn validate(&self) -> Result<(), ConfigError> {
1308 if !self.enabled {
1309 return Ok(());
1310 }
1311
1312 if self.timeout_seconds.is_none() {
1313 return Err(ConfigError::RobotMissingField {
1314 field: "RObot.timeout_seconds".to_string(),
1315 hint: "timeout_seconds is required when RObot is enabled".to_string(),
1316 });
1317 }
1318
1319 if self.resolve_bot_token().is_none() {
1321 return Err(ConfigError::RobotMissingField {
1322 field: "RObot.telegram.bot_token".to_string(),
1323 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1324 .to_string(),
1325 });
1326 }
1327
1328 Ok(())
1329 }
1330
1331 pub fn resolve_bot_token(&self) -> Option<String> {
1338 let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
1340 let config_token = self
1341 .telegram
1342 .as_ref()
1343 .and_then(|telegram| telegram.bot_token.clone());
1344
1345 if cfg!(test) {
1346 return env_token.or(config_token);
1347 }
1348
1349 env_token
1350 .or(config_token)
1352 .or_else(|| {
1354 std::panic::catch_unwind(|| {
1355 keyring::Entry::new("ralph", "telegram-bot-token")
1356 .ok()
1357 .and_then(|e| e.get_password().ok())
1358 })
1359 .ok()
1360 .flatten()
1361 })
1362 }
1363}
1364
1365#[derive(Debug, Clone, Serialize, Deserialize)]
1367pub struct TelegramBotConfig {
1368 pub bot_token: Option<String>,
1370}
1371
1372#[derive(Debug, thiserror::Error)]
1374pub enum ConfigError {
1375 #[error("IO error: {0}")]
1376 Io(#[from] std::io::Error),
1377
1378 #[error("YAML parse error: {0}")]
1379 Yaml(#[from] serde_yaml::Error),
1380
1381 #[error(
1382 "Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'.\nFix: ensure only one hat claims this trigger or delegate with a new event.\nSee: docs/reference/troubleshooting.md#ambiguous-routing"
1383 )]
1384 AmbiguousRouting {
1385 trigger: String,
1386 hat1: String,
1387 hat2: String,
1388 },
1389
1390 #[error(
1391 "Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified.\nFix: remove one field or split into separate configs.\nSee: docs/reference/troubleshooting.md#mutually-exclusive-fields"
1392 )]
1393 MutuallyExclusive { field1: String, field2: String },
1394
1395 #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
1396 InvalidCompletionPromise,
1397
1398 #[error(
1399 "Custom backend requires a command.\nFix: set 'cli.command' in your config (or run `ralph init --backend custom`).\nSee: docs/reference/troubleshooting.md#custom-backend-command"
1400 )]
1401 CustomBackendRequiresCommand,
1402
1403 #[error(
1404 "Reserved trigger '{trigger}' used by hat '{hat}' - task.start and task.resume are reserved for Ralph (the coordinator). Use a delegated event like 'work.start' instead.\nSee: docs/reference/troubleshooting.md#reserved-trigger"
1405 )]
1406 ReservedTrigger { trigger: String, hat: String },
1407
1408 #[error(
1409 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
1410 )]
1411 MissingDescription { hat: String },
1412
1413 #[error(
1414 "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
1415 )]
1416 RobotMissingField { field: String, hint: String },
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422
1423 #[test]
1424 fn test_default_config() {
1425 let config = RalphConfig::default();
1426 assert!(config.hats.is_empty());
1428 assert_eq!(config.event_loop.max_iterations, 100);
1429 assert!(!config.verbose);
1430 assert!(!config.features.preflight.enabled);
1431 assert!(!config.features.preflight.strict);
1432 assert!(config.features.preflight.skip.is_empty());
1433 }
1434
1435 #[test]
1436 fn test_parse_yaml_with_custom_hats() {
1437 let yaml = r#"
1438event_loop:
1439 prompt_file: "TASK.md"
1440 completion_promise: "DONE"
1441 max_iterations: 50
1442cli:
1443 backend: "claude"
1444hats:
1445 implementer:
1446 name: "Implementer"
1447 triggers: ["task.*", "review.done"]
1448 publishes: ["impl.done"]
1449 instructions: "You are the implementation agent."
1450"#;
1451 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1452 assert_eq!(config.hats.len(), 1);
1454 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1455
1456 let hat = config.hats.get("implementer").unwrap();
1457 assert_eq!(hat.triggers.len(), 2);
1458 }
1459
1460 #[test]
1461 fn test_preflight_config_deserialize() {
1462 let yaml = r#"
1463features:
1464 preflight:
1465 enabled: true
1466 strict: true
1467 skip: ["telegram", "git"]
1468"#;
1469 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1470 assert!(config.features.preflight.enabled);
1471 assert!(config.features.preflight.strict);
1472 assert_eq!(
1473 config.features.preflight.skip,
1474 vec!["telegram".to_string(), "git".to_string()]
1475 );
1476 }
1477
1478 #[test]
1479 fn test_parse_yaml_v1_format() {
1480 let yaml = r#"
1482agent: gemini
1483prompt_file: "TASK.md"
1484completion_promise: "RALPH_DONE"
1485max_iterations: 75
1486max_runtime: 7200
1487max_cost: 10.0
1488verbose: true
1489"#;
1490 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1491
1492 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
1498
1499 assert_eq!(config.cli.backend, "gemini");
1501 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1502 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1503 assert_eq!(config.event_loop.max_iterations, 75);
1504 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1505 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1506 assert!(config.verbose);
1507 }
1508
1509 #[test]
1510 fn test_agent_priority() {
1511 let yaml = r"
1512agent: auto
1513agent_priority: [gemini, claude, codex]
1514";
1515 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1516 let priority = config.get_agent_priority();
1517 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
1518 }
1519
1520 #[test]
1521 fn test_default_agent_priority() {
1522 let config = RalphConfig::default();
1523 let priority = config.get_agent_priority();
1524 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
1525 }
1526
1527 #[test]
1528 fn test_validate_deferred_features() {
1529 let yaml = r"
1530archive_prompts: true
1531enable_metrics: true
1532";
1533 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1534 let warnings = config.validate().unwrap();
1535
1536 assert_eq!(warnings.len(), 2);
1537 assert!(warnings
1538 .iter()
1539 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
1540 assert!(warnings
1541 .iter()
1542 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
1543 }
1544
1545 #[test]
1546 fn test_validate_dropped_fields() {
1547 let yaml = r#"
1548max_tokens: 4096
1549retry_delay: 5
1550adapters:
1551 claude:
1552 tool_permissions: ["read", "write"]
1553"#;
1554 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1555 let warnings = config.validate().unwrap();
1556
1557 assert_eq!(warnings.len(), 3);
1558 assert!(warnings.iter().any(
1559 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
1560 ));
1561 assert!(warnings.iter().any(
1562 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
1563 ));
1564 assert!(warnings
1565 .iter()
1566 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1567 }
1568
1569 #[test]
1570 fn test_suppress_warnings() {
1571 let yaml = r"
1572_suppress_warnings: true
1573archive_prompts: true
1574max_tokens: 4096
1575";
1576 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1577 let warnings = config.validate().unwrap();
1578
1579 assert!(warnings.is_empty());
1581 }
1582
1583 #[test]
1584 fn test_adapter_settings() {
1585 let yaml = r"
1586adapters:
1587 claude:
1588 timeout: 600
1589 enabled: true
1590 gemini:
1591 timeout: 300
1592 enabled: false
1593";
1594 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1595
1596 let claude = config.adapter_settings("claude");
1597 assert_eq!(claude.timeout, 600);
1598 assert!(claude.enabled);
1599
1600 let gemini = config.adapter_settings("gemini");
1601 assert_eq!(gemini.timeout, 300);
1602 assert!(!gemini.enabled);
1603 }
1604
1605 #[test]
1606 fn test_unknown_fields_ignored() {
1607 let yaml = r#"
1609agent: claude
1610unknown_field: "some value"
1611future_feature: true
1612"#;
1613 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1614 assert!(result.is_ok());
1616 }
1617
1618 #[test]
1619 fn test_ambiguous_routing_rejected() {
1620 let yaml = r#"
1623hats:
1624 planner:
1625 name: "Planner"
1626 description: "Plans tasks"
1627 triggers: ["planning.start", "build.done"]
1628 builder:
1629 name: "Builder"
1630 description: "Builds code"
1631 triggers: ["build.task", "build.done"]
1632"#;
1633 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1634 let result = config.validate();
1635
1636 assert!(result.is_err());
1637 let err = result.unwrap_err();
1638 assert!(
1639 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1640 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1641 err
1642 );
1643 }
1644
1645 #[test]
1646 fn test_unique_triggers_accepted() {
1647 let yaml = r#"
1650hats:
1651 planner:
1652 name: "Planner"
1653 description: "Plans tasks"
1654 triggers: ["planning.start", "build.done", "build.blocked"]
1655 builder:
1656 name: "Builder"
1657 description: "Builds code"
1658 triggers: ["build.task"]
1659"#;
1660 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1661 let result = config.validate();
1662
1663 assert!(
1664 result.is_ok(),
1665 "Expected valid config, got: {:?}",
1666 result.unwrap_err()
1667 );
1668 }
1669
1670 #[test]
1671 fn test_reserved_trigger_task_start_rejected() {
1672 let yaml = r#"
1674hats:
1675 my_hat:
1676 name: "My Hat"
1677 description: "Test hat"
1678 triggers: ["task.start"]
1679"#;
1680 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1681 let result = config.validate();
1682
1683 assert!(result.is_err());
1684 let err = result.unwrap_err();
1685 assert!(
1686 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1687 if trigger == "task.start" && hat == "my_hat"),
1688 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1689 err
1690 );
1691 }
1692
1693 #[test]
1694 fn test_reserved_trigger_task_resume_rejected() {
1695 let yaml = r#"
1697hats:
1698 my_hat:
1699 name: "My Hat"
1700 description: "Test hat"
1701 triggers: ["task.resume", "other.event"]
1702"#;
1703 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1704 let result = config.validate();
1705
1706 assert!(result.is_err());
1707 let err = result.unwrap_err();
1708 assert!(
1709 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1710 if trigger == "task.resume" && hat == "my_hat"),
1711 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1712 err
1713 );
1714 }
1715
1716 #[test]
1717 fn test_missing_description_rejected() {
1718 let yaml = r#"
1720hats:
1721 my_hat:
1722 name: "My Hat"
1723 triggers: ["build.task"]
1724"#;
1725 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1726 let result = config.validate();
1727
1728 assert!(result.is_err());
1729 let err = result.unwrap_err();
1730 assert!(
1731 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1732 "Expected MissingDescription error, got: {:?}",
1733 err
1734 );
1735 }
1736
1737 #[test]
1738 fn test_empty_description_rejected() {
1739 let yaml = r#"
1741hats:
1742 my_hat:
1743 name: "My Hat"
1744 description: " "
1745 triggers: ["build.task"]
1746"#;
1747 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1748 let result = config.validate();
1749
1750 assert!(result.is_err());
1751 let err = result.unwrap_err();
1752 assert!(
1753 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1754 "Expected MissingDescription error for empty description, got: {:?}",
1755 err
1756 );
1757 }
1758
1759 #[test]
1760 fn test_core_config_defaults() {
1761 let config = RalphConfig::default();
1762 assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
1763 assert_eq!(config.core.specs_dir, ".ralph/specs/");
1764 assert_eq!(config.core.guardrails.len(), 5);
1766 assert!(config.core.guardrails[0].contains("Fresh context"));
1767 assert!(config.core.guardrails[1].contains("search first"));
1768 assert!(config.core.guardrails[2].contains("Backpressure"));
1769 assert!(config.core.guardrails[3].contains("Confidence protocol"));
1770 assert!(config.core.guardrails[4].contains("Commit atomically"));
1771 }
1772
1773 #[test]
1774 fn test_core_config_customizable() {
1775 let yaml = r#"
1776core:
1777 scratchpad: ".workspace/plan.md"
1778 specs_dir: "./specifications/"
1779"#;
1780 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1781 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1782 assert_eq!(config.core.specs_dir, "./specifications/");
1783 assert_eq!(config.core.guardrails.len(), 5);
1785 }
1786
1787 #[test]
1788 fn test_core_config_custom_guardrails() {
1789 let yaml = r#"
1790core:
1791 scratchpad: ".ralph/agent/scratchpad.md"
1792 specs_dir: "./specs/"
1793 guardrails:
1794 - "Custom rule one"
1795 - "Custom rule two"
1796"#;
1797 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1798 assert_eq!(config.core.guardrails.len(), 2);
1799 assert_eq!(config.core.guardrails[0], "Custom rule one");
1800 assert_eq!(config.core.guardrails[1], "Custom rule two");
1801 }
1802
1803 #[test]
1804 fn test_prompt_and_prompt_file_mutually_exclusive() {
1805 let yaml = r#"
1807event_loop:
1808 prompt: "inline text"
1809 prompt_file: "custom.md"
1810"#;
1811 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1812 let result = config.validate();
1813
1814 assert!(result.is_err());
1815 let err = result.unwrap_err();
1816 assert!(
1817 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1818 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1819 "Expected MutuallyExclusive error, got: {:?}",
1820 err
1821 );
1822 }
1823
1824 #[test]
1825 fn test_prompt_with_default_prompt_file_allowed() {
1826 let yaml = r#"
1828event_loop:
1829 prompt: "inline text"
1830"#;
1831 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1832 let result = config.validate();
1833
1834 assert!(
1835 result.is_ok(),
1836 "Should allow inline prompt with default prompt_file"
1837 );
1838 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1839 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1840 }
1841
1842 #[test]
1843 fn test_custom_backend_requires_command() {
1844 let yaml = r#"
1846cli:
1847 backend: "custom"
1848"#;
1849 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1850 let result = config.validate();
1851
1852 assert!(result.is_err());
1853 let err = result.unwrap_err();
1854 assert!(
1855 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1856 "Expected CustomBackendRequiresCommand error, got: {:?}",
1857 err
1858 );
1859 }
1860
1861 #[test]
1862 fn test_empty_completion_promise_rejected() {
1863 let yaml = r#"
1864event_loop:
1865 completion_promise: " "
1866"#;
1867 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1868 let result = config.validate();
1869
1870 assert!(result.is_err());
1871 let err = result.unwrap_err();
1872 assert!(
1873 matches!(&err, ConfigError::InvalidCompletionPromise),
1874 "Expected InvalidCompletionPromise error, got: {:?}",
1875 err
1876 );
1877 }
1878
1879 #[test]
1880 fn test_custom_backend_with_empty_command_errors() {
1881 let yaml = r#"
1883cli:
1884 backend: "custom"
1885 command: ""
1886"#;
1887 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1888 let result = config.validate();
1889
1890 assert!(result.is_err());
1891 let err = result.unwrap_err();
1892 assert!(
1893 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1894 "Expected CustomBackendRequiresCommand error, got: {:?}",
1895 err
1896 );
1897 }
1898
1899 #[test]
1900 fn test_custom_backend_with_command_succeeds() {
1901 let yaml = r#"
1903cli:
1904 backend: "custom"
1905 command: "my-agent"
1906"#;
1907 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1908 let result = config.validate();
1909
1910 assert!(
1911 result.is_ok(),
1912 "Should allow custom backend with command: {:?}",
1913 result.unwrap_err()
1914 );
1915 }
1916
1917 #[test]
1918 fn test_custom_backend_requires_command_message_actionable() {
1919 let err = ConfigError::CustomBackendRequiresCommand;
1920 let msg = err.to_string();
1921 assert!(msg.contains("cli.command"));
1922 assert!(msg.contains("ralph init --backend custom"));
1923 assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
1924 }
1925
1926 #[test]
1927 fn test_reserved_trigger_message_actionable() {
1928 let err = ConfigError::ReservedTrigger {
1929 trigger: "task.start".to_string(),
1930 hat: "builder".to_string(),
1931 };
1932 let msg = err.to_string();
1933 assert!(msg.contains("Reserved trigger"));
1934 assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
1935 }
1936
1937 #[test]
1938 fn test_prompt_file_with_no_inline_allowed() {
1939 let yaml = r#"
1941event_loop:
1942 prompt_file: "custom.md"
1943"#;
1944 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1945 let result = config.validate();
1946
1947 assert!(
1948 result.is_ok(),
1949 "Should allow prompt_file without inline prompt"
1950 );
1951 assert_eq!(config.event_loop.prompt, None);
1952 assert_eq!(config.event_loop.prompt_file, "custom.md");
1953 }
1954
1955 #[test]
1956 fn test_default_prompt_file_value() {
1957 let config = RalphConfig::default();
1958 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1959 assert_eq!(config.event_loop.prompt, None);
1960 }
1961
1962 #[test]
1963 fn test_tui_config_default() {
1964 let config = RalphConfig::default();
1965 assert_eq!(config.tui.prefix_key, "ctrl-a");
1966 }
1967
1968 #[test]
1969 fn test_tui_config_parse_ctrl_b() {
1970 let yaml = r#"
1971tui:
1972 prefix_key: "ctrl-b"
1973"#;
1974 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1975 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1976
1977 use crossterm::event::{KeyCode, KeyModifiers};
1978 assert_eq!(key_code, KeyCode::Char('b'));
1979 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1980 }
1981
1982 #[test]
1983 fn test_tui_config_parse_invalid_format() {
1984 let tui_config = TuiConfig {
1985 prefix_key: "invalid".to_string(),
1986 };
1987 let result = tui_config.parse_prefix();
1988 assert!(result.is_err());
1989 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1990 }
1991
1992 #[test]
1993 fn test_tui_config_parse_invalid_modifier() {
1994 let tui_config = TuiConfig {
1995 prefix_key: "alt-a".to_string(),
1996 };
1997 let result = tui_config.parse_prefix();
1998 assert!(result.is_err());
1999 assert!(result.unwrap_err().contains("Invalid modifier"));
2000 }
2001
2002 #[test]
2003 fn test_tui_config_parse_invalid_key() {
2004 let tui_config = TuiConfig {
2005 prefix_key: "ctrl-abc".to_string(),
2006 };
2007 let result = tui_config.parse_prefix();
2008 assert!(result.is_err());
2009 assert!(result.unwrap_err().contains("Invalid key"));
2010 }
2011
2012 #[test]
2013 fn test_hat_backend_named() {
2014 let yaml = r#""claude""#;
2015 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2016 assert_eq!(backend.to_cli_backend(), "claude");
2017 match backend {
2018 HatBackend::Named(name) => assert_eq!(name, "claude"),
2019 _ => panic!("Expected Named variant"),
2020 }
2021 }
2022
2023 #[test]
2024 fn test_hat_backend_kiro_agent() {
2025 let yaml = r#"
2026type: "kiro"
2027agent: "builder"
2028"#;
2029 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2030 assert_eq!(backend.to_cli_backend(), "kiro");
2031 match backend {
2032 HatBackend::KiroAgent {
2033 backend_type,
2034 agent,
2035 args,
2036 } => {
2037 assert_eq!(backend_type, "kiro");
2038 assert_eq!(agent, "builder");
2039 assert!(args.is_empty());
2040 }
2041 _ => panic!("Expected KiroAgent variant"),
2042 }
2043 }
2044
2045 #[test]
2046 fn test_hat_backend_kiro_agent_with_args() {
2047 let yaml = r#"
2048type: "kiro"
2049agent: "builder"
2050args: ["--verbose", "--debug"]
2051"#;
2052 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2053 assert_eq!(backend.to_cli_backend(), "kiro");
2054 match backend {
2055 HatBackend::KiroAgent {
2056 backend_type,
2057 agent,
2058 args,
2059 } => {
2060 assert_eq!(backend_type, "kiro");
2061 assert_eq!(agent, "builder");
2062 assert_eq!(args, vec!["--verbose", "--debug"]);
2063 }
2064 _ => panic!("Expected KiroAgent variant"),
2065 }
2066 }
2067
2068 #[test]
2069 fn test_hat_backend_named_with_args() {
2070 let yaml = r#"
2071type: "claude"
2072args: ["--model", "claude-sonnet-4"]
2073"#;
2074 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2075 assert_eq!(backend.to_cli_backend(), "claude");
2076 match backend {
2077 HatBackend::NamedWithArgs { backend_type, args } => {
2078 assert_eq!(backend_type, "claude");
2079 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2080 }
2081 _ => panic!("Expected NamedWithArgs variant"),
2082 }
2083 }
2084
2085 #[test]
2086 fn test_hat_backend_named_with_args_empty() {
2087 let yaml = r#"
2089type: "gemini"
2090"#;
2091 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2092 assert_eq!(backend.to_cli_backend(), "gemini");
2093 match backend {
2094 HatBackend::NamedWithArgs { backend_type, args } => {
2095 assert_eq!(backend_type, "gemini");
2096 assert!(args.is_empty());
2097 }
2098 _ => panic!("Expected NamedWithArgs variant"),
2099 }
2100 }
2101
2102 #[test]
2103 fn test_hat_backend_custom() {
2104 let yaml = r#"
2105command: "/usr/bin/my-agent"
2106args: ["--flag", "value"]
2107"#;
2108 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2109 assert_eq!(backend.to_cli_backend(), "custom");
2110 match backend {
2111 HatBackend::Custom { command, args } => {
2112 assert_eq!(command, "/usr/bin/my-agent");
2113 assert_eq!(args, vec!["--flag", "value"]);
2114 }
2115 _ => panic!("Expected Custom variant"),
2116 }
2117 }
2118
2119 #[test]
2120 fn test_hat_config_with_backend() {
2121 let yaml = r#"
2122name: "Custom Builder"
2123triggers: ["build.task"]
2124publishes: ["build.done"]
2125instructions: "Build stuff"
2126backend: "gemini"
2127default_publishes: "task.done"
2128"#;
2129 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2130 assert_eq!(hat.name, "Custom Builder");
2131 assert!(hat.backend.is_some());
2132 match hat.backend.unwrap() {
2133 HatBackend::Named(name) => assert_eq!(name, "gemini"),
2134 _ => panic!("Expected Named backend"),
2135 }
2136 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2137 }
2138
2139 #[test]
2140 fn test_hat_config_without_backend() {
2141 let yaml = r#"
2142name: "Default Hat"
2143triggers: ["task.start"]
2144publishes: ["task.done"]
2145instructions: "Do work"
2146"#;
2147 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2148 assert_eq!(hat.name, "Default Hat");
2149 assert!(hat.backend.is_none());
2150 assert!(hat.default_publishes.is_none());
2151 }
2152
2153 #[test]
2154 fn test_mixed_backends_config() {
2155 let yaml = r#"
2156event_loop:
2157 prompt_file: "TASK.md"
2158 max_iterations: 50
2159
2160cli:
2161 backend: "claude"
2162
2163hats:
2164 planner:
2165 name: "Planner"
2166 triggers: ["task.start"]
2167 publishes: ["build.task"]
2168 instructions: "Plan the work"
2169 backend: "claude"
2170
2171 builder:
2172 name: "Builder"
2173 triggers: ["build.task"]
2174 publishes: ["build.done"]
2175 instructions: "Build the thing"
2176 backend:
2177 type: "kiro"
2178 agent: "builder"
2179
2180 reviewer:
2181 name: "Reviewer"
2182 triggers: ["build.done"]
2183 publishes: ["review.complete"]
2184 instructions: "Review the work"
2185 backend:
2186 command: "/usr/local/bin/custom-agent"
2187 args: ["--mode", "review"]
2188 default_publishes: "review.complete"
2189"#;
2190 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2191 assert_eq!(config.hats.len(), 3);
2192
2193 let planner = config.hats.get("planner").unwrap();
2195 assert!(planner.backend.is_some());
2196 match planner.backend.as_ref().unwrap() {
2197 HatBackend::Named(name) => assert_eq!(name, "claude"),
2198 _ => panic!("Expected Named backend for planner"),
2199 }
2200
2201 let builder = config.hats.get("builder").unwrap();
2203 assert!(builder.backend.is_some());
2204 match builder.backend.as_ref().unwrap() {
2205 HatBackend::KiroAgent {
2206 backend_type,
2207 agent,
2208 args,
2209 } => {
2210 assert_eq!(backend_type, "kiro");
2211 assert_eq!(agent, "builder");
2212 assert!(args.is_empty());
2213 }
2214 _ => panic!("Expected KiroAgent backend for builder"),
2215 }
2216
2217 let reviewer = config.hats.get("reviewer").unwrap();
2219 assert!(reviewer.backend.is_some());
2220 match reviewer.backend.as_ref().unwrap() {
2221 HatBackend::Custom { command, args } => {
2222 assert_eq!(command, "/usr/local/bin/custom-agent");
2223 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2224 }
2225 _ => panic!("Expected Custom backend for reviewer"),
2226 }
2227 assert_eq!(
2228 reviewer.default_publishes,
2229 Some("review.complete".to_string())
2230 );
2231 }
2232
2233 #[test]
2234 fn test_features_config_auto_merge_defaults_to_false() {
2235 let config = RalphConfig::default();
2238 assert!(
2239 !config.features.auto_merge,
2240 "auto_merge should default to false"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_features_config_auto_merge_from_yaml() {
2246 let yaml = r"
2248features:
2249 auto_merge: true
2250";
2251 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2252 assert!(
2253 config.features.auto_merge,
2254 "auto_merge should be true when configured"
2255 );
2256 }
2257
2258 #[test]
2259 fn test_features_config_auto_merge_false_from_yaml() {
2260 let yaml = r"
2262features:
2263 auto_merge: false
2264";
2265 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2266 assert!(
2267 !config.features.auto_merge,
2268 "auto_merge should be false when explicitly configured"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2274 let yaml = r"
2276features:
2277 parallel: false
2278 auto_merge: true
2279";
2280 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2281 assert!(!config.features.parallel, "parallel should be false");
2282 assert!(config.features.auto_merge, "auto_merge should be true");
2283 }
2284
2285 #[test]
2286 fn test_skills_config_defaults_when_absent() {
2287 let yaml = r"
2289agent: claude
2290";
2291 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2292 assert!(config.skills.enabled);
2293 assert!(config.skills.dirs.is_empty());
2294 assert!(config.skills.overrides.is_empty());
2295 }
2296
2297 #[test]
2298 fn test_skills_config_deserializes_all_fields() {
2299 let yaml = r#"
2300skills:
2301 enabled: true
2302 dirs:
2303 - ".claude/skills"
2304 - "/shared/skills"
2305 overrides:
2306 pdd:
2307 enabled: false
2308 memories:
2309 auto_inject: true
2310 hats: ["ralph"]
2311 backends: ["claude"]
2312 tags: ["core"]
2313"#;
2314 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2315 assert!(config.skills.enabled);
2316 assert_eq!(config.skills.dirs.len(), 2);
2317 assert_eq!(
2318 config.skills.dirs[0],
2319 std::path::PathBuf::from(".claude/skills")
2320 );
2321 assert_eq!(config.skills.overrides.len(), 2);
2322
2323 let pdd = config.skills.overrides.get("pdd").unwrap();
2324 assert_eq!(pdd.enabled, Some(false));
2325
2326 let memories = config.skills.overrides.get("memories").unwrap();
2327 assert_eq!(memories.auto_inject, Some(true));
2328 assert_eq!(memories.hats, vec!["ralph"]);
2329 assert_eq!(memories.backends, vec!["claude"]);
2330 assert_eq!(memories.tags, vec!["core"]);
2331 }
2332
2333 #[test]
2334 fn test_skills_config_disabled() {
2335 let yaml = r"
2336skills:
2337 enabled: false
2338";
2339 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2340 assert!(!config.skills.enabled);
2341 assert!(config.skills.dirs.is_empty());
2342 }
2343
2344 #[test]
2345 fn test_skill_override_partial_fields() {
2346 let yaml = r#"
2347skills:
2348 overrides:
2349 my-skill:
2350 hats: ["builder", "reviewer"]
2351"#;
2352 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2353 let override_ = config.skills.overrides.get("my-skill").unwrap();
2354 assert_eq!(override_.enabled, None);
2355 assert_eq!(override_.auto_inject, None);
2356 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2357 assert!(override_.backends.is_empty());
2358 assert!(override_.tags.is_empty());
2359 }
2360
2361 #[test]
2366 fn test_robot_config_defaults_disabled() {
2367 let config = RalphConfig::default();
2368 assert!(!config.robot.enabled);
2369 assert!(config.robot.timeout_seconds.is_none());
2370 assert!(config.robot.telegram.is_none());
2371 }
2372
2373 #[test]
2374 fn test_robot_config_absent_parses_as_default() {
2375 let yaml = r"
2377agent: claude
2378";
2379 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2380 assert!(!config.robot.enabled);
2381 assert!(config.robot.timeout_seconds.is_none());
2382 }
2383
2384 #[test]
2385 fn test_robot_config_valid_full() {
2386 let yaml = r#"
2387RObot:
2388 enabled: true
2389 timeout_seconds: 300
2390 telegram:
2391 bot_token: "123456:ABC-DEF"
2392"#;
2393 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2394 assert!(config.robot.enabled);
2395 assert_eq!(config.robot.timeout_seconds, Some(300));
2396 let telegram = config.robot.telegram.as_ref().unwrap();
2397 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
2398
2399 assert!(config.validate().is_ok());
2401 }
2402
2403 #[test]
2404 fn test_robot_config_disabled_skips_validation() {
2405 let yaml = r"
2407RObot:
2408 enabled: false
2409";
2410 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2411 assert!(!config.robot.enabled);
2412 assert!(config.validate().is_ok());
2413 }
2414
2415 #[test]
2416 fn test_robot_config_enabled_missing_timeout_fails() {
2417 let yaml = r#"
2418RObot:
2419 enabled: true
2420 telegram:
2421 bot_token: "123456:ABC-DEF"
2422"#;
2423 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2424 let result = config.validate();
2425 assert!(result.is_err());
2426 let err = result.unwrap_err();
2427 assert!(
2428 matches!(&err, ConfigError::RobotMissingField { field, .. }
2429 if field == "RObot.timeout_seconds"),
2430 "Expected RobotMissingField for timeout_seconds, got: {:?}",
2431 err
2432 );
2433 }
2434
2435 #[test]
2436 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
2437 let robot = RobotConfig {
2439 enabled: true,
2440 timeout_seconds: None,
2441 checkin_interval_seconds: None,
2442 telegram: None,
2443 };
2444 let result = robot.validate();
2445 assert!(result.is_err());
2446 let err = result.unwrap_err();
2447 assert!(
2448 matches!(&err, ConfigError::RobotMissingField { field, .. }
2449 if field == "RObot.timeout_seconds"),
2450 "Expected timeout validation failure first, got: {:?}",
2451 err
2452 );
2453 }
2454
2455 #[test]
2456 fn test_robot_config_resolve_bot_token_from_config() {
2457 let config = RobotConfig {
2461 enabled: true,
2462 timeout_seconds: Some(300),
2463 checkin_interval_seconds: None,
2464 telegram: Some(TelegramBotConfig {
2465 bot_token: Some("config-token".to_string()),
2466 }),
2467 };
2468
2469 let resolved = config.resolve_bot_token();
2472 assert!(resolved.is_some());
2475 }
2476
2477 #[test]
2478 fn test_robot_config_resolve_bot_token_none_without_config() {
2479 let config = RobotConfig {
2481 enabled: true,
2482 timeout_seconds: Some(300),
2483 checkin_interval_seconds: None,
2484 telegram: None,
2485 };
2486
2487 let resolved = config.resolve_bot_token();
2490 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
2491 assert!(resolved.is_none());
2492 }
2493 }
2494
2495 #[test]
2496 fn test_robot_config_validate_with_config_token() {
2497 let robot = RobotConfig {
2499 enabled: true,
2500 timeout_seconds: Some(300),
2501 checkin_interval_seconds: None,
2502 telegram: Some(TelegramBotConfig {
2503 bot_token: Some("test-token".to_string()),
2504 }),
2505 };
2506 assert!(robot.validate().is_ok());
2507 }
2508
2509 #[test]
2510 fn test_robot_config_validate_missing_telegram_section() {
2511 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2514 return;
2515 }
2516
2517 let robot = RobotConfig {
2518 enabled: true,
2519 timeout_seconds: Some(300),
2520 checkin_interval_seconds: None,
2521 telegram: None,
2522 };
2523 let result = robot.validate();
2524 assert!(result.is_err());
2525 let err = result.unwrap_err();
2526 assert!(
2527 matches!(&err, ConfigError::RobotMissingField { field, .. }
2528 if field == "RObot.telegram.bot_token"),
2529 "Expected bot_token validation failure, got: {:?}",
2530 err
2531 );
2532 }
2533
2534 #[test]
2535 fn test_robot_config_validate_empty_bot_token() {
2536 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2539 return;
2540 }
2541
2542 let robot = RobotConfig {
2543 enabled: true,
2544 timeout_seconds: Some(300),
2545 checkin_interval_seconds: None,
2546 telegram: Some(TelegramBotConfig { bot_token: None }),
2547 };
2548 let result = robot.validate();
2549 assert!(result.is_err());
2550 let err = result.unwrap_err();
2551 assert!(
2552 matches!(&err, ConfigError::RobotMissingField { field, .. }
2553 if field == "RObot.telegram.bot_token"),
2554 "Expected bot_token validation failure, got: {:?}",
2555 err
2556 );
2557 }
2558
2559 #[test]
2560 fn test_extra_instructions_merged_during_normalize() {
2561 let yaml = r#"
2562_fragments:
2563 shared_protocol: &shared_protocol |
2564 ### Shared Protocol
2565 Follow this protocol.
2566
2567hats:
2568 builder:
2569 name: "Builder"
2570 triggers: ["build.start"]
2571 instructions: |
2572 ## BUILDER MODE
2573 Build things.
2574 extra_instructions:
2575 - *shared_protocol
2576"#;
2577 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2578 let hat = config.hats.get("builder").unwrap();
2579
2580 assert_eq!(hat.extra_instructions.len(), 1);
2582 assert!(!hat.instructions.contains("Shared Protocol"));
2583
2584 config.normalize();
2585
2586 let hat = config.hats.get("builder").unwrap();
2587 assert!(hat.extra_instructions.is_empty());
2589 assert!(hat.instructions.contains("## BUILDER MODE"));
2590 assert!(hat.instructions.contains("### Shared Protocol"));
2591 assert!(hat.instructions.contains("Follow this protocol."));
2592 }
2593
2594 #[test]
2595 fn test_extra_instructions_empty_by_default() {
2596 let yaml = r#"
2597hats:
2598 simple:
2599 name: "Simple"
2600 triggers: ["start"]
2601 instructions: "Do the thing."
2602"#;
2603 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2604 let hat = config.hats.get("simple").unwrap();
2605 assert!(hat.extra_instructions.is_empty());
2606 }
2607}