1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
20 #[serde(default)]
22 pub event_loop: EventLoopConfig,
23
24 #[serde(default)]
26 pub cli: CliConfig,
27
28 #[serde(default)]
30 pub core: CoreConfig,
31
32 #[serde(default)]
35 pub hats: HashMap<String, HatConfig>,
36
37 #[serde(default)]
41 pub events: HashMap<String, EventMetadata>,
42
43 #[serde(default)]
50 pub agent: Option<String>,
51
52 #[serde(default)]
54 pub agent_priority: Vec<String>,
55
56 #[serde(default)]
58 pub prompt_file: Option<String>,
59
60 #[serde(default)]
62 pub completion_promise: Option<String>,
63
64 #[serde(default)]
66 pub max_iterations: Option<u32>,
67
68 #[serde(default)]
70 pub max_runtime: Option<u64>,
71
72 #[serde(default)]
74 pub max_cost: Option<f64>,
75
76 #[serde(default)]
81 pub verbose: bool,
82
83 #[serde(default)]
85 pub archive_prompts: bool,
86
87 #[serde(default)]
89 pub enable_metrics: bool,
90
91 #[serde(default)]
96 pub max_tokens: Option<u32>,
97
98 #[serde(default)]
100 pub retry_delay: Option<u32>,
101
102 #[serde(default)]
104 pub adapters: AdaptersConfig,
105
106 #[serde(default, rename = "_suppress_warnings")]
111 pub suppress_warnings: bool,
112
113 #[serde(default)]
115 pub tui: TuiConfig,
116}
117
118fn default_true() -> bool {
119 true
120}
121
122#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
124 fn default() -> Self {
125 Self {
126 event_loop: EventLoopConfig::default(),
127 cli: CliConfig::default(),
128 core: CoreConfig::default(),
129 hats: HashMap::new(),
130 events: HashMap::new(),
131 agent: None,
133 agent_priority: vec![],
134 prompt_file: None,
135 completion_promise: None,
136 max_iterations: None,
137 max_runtime: None,
138 max_cost: None,
139 verbose: false,
141 archive_prompts: false,
142 enable_metrics: false,
143 max_tokens: None,
145 retry_delay: None,
146 adapters: AdaptersConfig::default(),
147 suppress_warnings: false,
149 tui: TuiConfig::default(),
151 }
152 }
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct AdaptersConfig {
158 #[serde(default)]
160 pub claude: AdapterSettings,
161
162 #[serde(default)]
164 pub gemini: AdapterSettings,
165
166 #[serde(default)]
168 pub kiro: AdapterSettings,
169
170 #[serde(default)]
172 pub codex: AdapterSettings,
173
174 #[serde(default)]
176 pub amp: AdapterSettings,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct AdapterSettings {
182 #[serde(default = "default_timeout")]
184 pub timeout: u64,
185
186 #[serde(default = "default_true")]
188 pub enabled: bool,
189
190 #[serde(default)]
192 pub tool_permissions: Option<Vec<String>>,
193}
194
195fn default_timeout() -> u64 {
196 300 }
198
199impl Default for AdapterSettings {
200 fn default() -> Self {
201 Self {
202 timeout: default_timeout(),
203 enabled: true,
204 tool_permissions: None,
205 }
206 }
207}
208
209impl RalphConfig {
210 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
212 let path_ref = path.as_ref();
213 debug!(path = %path_ref.display(), "Loading configuration from file");
214 let content = std::fs::read_to_string(path_ref)?;
215 let config: Self = serde_yaml::from_str(&content)?;
216 debug!(
217 backend = %config.cli.backend,
218 has_v1_fields = config.agent.is_some(),
219 custom_hats = config.hats.len(),
220 "Configuration loaded"
221 );
222 Ok(config)
223 }
224
225 pub fn normalize(&mut self) {
230 let mut normalized_count = 0;
231
232 if let Some(ref agent) = self.agent {
234 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
235 self.cli.backend = agent.clone();
236 normalized_count += 1;
237 }
238
239 if let Some(ref pf) = self.prompt_file {
241 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
242 self.event_loop.prompt_file = pf.clone();
243 normalized_count += 1;
244 }
245
246 if let Some(ref cp) = self.completion_promise {
248 debug!(
249 from = "completion_promise",
250 to = "event_loop.completion_promise",
251 "Normalizing v1 field"
252 );
253 self.event_loop.completion_promise = cp.clone();
254 normalized_count += 1;
255 }
256
257 if let Some(mi) = self.max_iterations {
259 debug!(
260 from = "max_iterations",
261 to = "event_loop.max_iterations",
262 value = mi,
263 "Normalizing v1 field"
264 );
265 self.event_loop.max_iterations = mi;
266 normalized_count += 1;
267 }
268
269 if let Some(mr) = self.max_runtime {
271 debug!(
272 from = "max_runtime",
273 to = "event_loop.max_runtime_seconds",
274 value = mr,
275 "Normalizing v1 field"
276 );
277 self.event_loop.max_runtime_seconds = mr;
278 normalized_count += 1;
279 }
280
281 if self.max_cost.is_some() {
283 debug!(
284 from = "max_cost",
285 to = "event_loop.max_cost_usd",
286 "Normalizing v1 field"
287 );
288 self.event_loop.max_cost_usd = self.max_cost;
289 normalized_count += 1;
290 }
291
292 if normalized_count > 0 {
293 debug!(
294 fields_normalized = normalized_count,
295 "V1 to V2 config normalization complete"
296 );
297 }
298 }
299
300 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
310 let mut warnings = Vec::new();
311
312 if self.suppress_warnings {
314 return Ok(warnings);
315 }
316
317 if self.event_loop.prompt.is_some()
320 && !self.event_loop.prompt_file.is_empty()
321 && self.event_loop.prompt_file != default_prompt_file()
322 {
323 return Err(ConfigError::MutuallyExclusive {
324 field1: "event_loop.prompt".to_string(),
325 field2: "event_loop.prompt_file".to_string(),
326 });
327 }
328
329 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
331 return Err(ConfigError::CustomBackendRequiresCommand);
332 }
333
334 if self.archive_prompts {
336 warnings.push(ConfigWarning::DeferredFeature {
337 field: "archive_prompts".to_string(),
338 message: "Feature not yet available in v2".to_string(),
339 });
340 }
341
342 if self.enable_metrics {
343 warnings.push(ConfigWarning::DeferredFeature {
344 field: "enable_metrics".to_string(),
345 message: "Feature not yet available in v2".to_string(),
346 });
347 }
348
349 if self.max_tokens.is_some() {
351 warnings.push(ConfigWarning::DroppedField {
352 field: "max_tokens".to_string(),
353 reason: "Token limits are controlled by the CLI tool".to_string(),
354 });
355 }
356
357 if self.retry_delay.is_some() {
358 warnings.push(ConfigWarning::DroppedField {
359 field: "retry_delay".to_string(),
360 reason: "Retry logic handled differently in v2".to_string(),
361 });
362 }
363
364 if self.adapters.claude.tool_permissions.is_some()
366 || self.adapters.gemini.tool_permissions.is_some()
367 || self.adapters.codex.tool_permissions.is_some()
368 || self.adapters.amp.tool_permissions.is_some()
369 {
370 warnings.push(ConfigWarning::DroppedField {
371 field: "adapters.*.tool_permissions".to_string(),
372 reason: "CLI tool manages its own permissions".to_string(),
373 });
374 }
375
376 for (hat_id, hat_config) in &self.hats {
378 if hat_config
379 .description
380 .as_ref()
381 .is_none_or(|d| d.trim().is_empty())
382 {
383 return Err(ConfigError::MissingDescription {
384 hat: hat_id.clone(),
385 });
386 }
387 }
388
389 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
392 for (hat_id, hat_config) in &self.hats {
393 for trigger in &hat_config.triggers {
394 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
395 return Err(ConfigError::ReservedTrigger {
396 trigger: trigger.clone(),
397 hat: hat_id.clone(),
398 });
399 }
400 }
401 }
402
403 if !self.hats.is_empty() {
406 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
407 for (hat_id, hat_config) in &self.hats {
408 for trigger in &hat_config.triggers {
409 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
410 return Err(ConfigError::AmbiguousRouting {
411 trigger: trigger.clone(),
412 hat1: (*existing_hat).to_string(),
413 hat2: hat_id.clone(),
414 });
415 }
416 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
417 }
418 }
419 }
420
421 Ok(warnings)
422 }
423
424 pub fn effective_backend(&self) -> &str {
426 &self.cli.backend
427 }
428
429 pub fn get_agent_priority(&self) -> Vec<&str> {
432 if self.agent_priority.is_empty() {
433 vec!["claude", "kiro", "gemini", "codex", "amp"]
434 } else {
435 self.agent_priority.iter().map(String::as_str).collect()
436 }
437 }
438
439 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
442 match backend {
443 "claude" => &self.adapters.claude,
444 "gemini" => &self.adapters.gemini,
445 "kiro" => &self.adapters.kiro,
446 "codex" => &self.adapters.codex,
447 "amp" => &self.adapters.amp,
448 _ => &self.adapters.claude, }
450 }
451}
452
453#[derive(Debug, Clone)]
455pub enum ConfigWarning {
456 DeferredFeature { field: String, message: String },
458 DroppedField { field: String, reason: String },
460 InvalidValue { field: String, message: String },
462}
463
464impl std::fmt::Display for ConfigWarning {
465 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 match self {
468 ConfigWarning::DeferredFeature { field, message }
469 | ConfigWarning::InvalidValue { field, message } => {
470 write!(f, "Warning [{field}]: {message}")
471 }
472 ConfigWarning::DroppedField { field, reason } => {
473 write!(f, "Warning [{field}]: Field ignored - {reason}")
474 }
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct EventLoopConfig {
482 pub prompt: Option<String>,
484
485 #[serde(default = "default_prompt_file")]
487 pub prompt_file: String,
488
489 #[serde(default = "default_completion_promise")]
491 pub completion_promise: String,
492
493 #[serde(default = "default_max_iterations")]
495 pub max_iterations: u32,
496
497 #[serde(default = "default_max_runtime")]
499 pub max_runtime_seconds: u64,
500
501 pub max_cost_usd: Option<f64>,
503
504 #[serde(default = "default_max_failures")]
506 pub max_consecutive_failures: u32,
507
508 pub starting_hat: Option<String>,
510
511 pub starting_event: Option<String>,
521}
522
523fn default_prompt_file() -> String {
524 "PROMPT.md".to_string()
525}
526
527fn default_completion_promise() -> String {
528 "LOOP_COMPLETE".to_string()
529}
530
531fn default_max_iterations() -> u32 {
532 100
533}
534
535fn default_max_runtime() -> u64 {
536 14400 }
538
539fn default_max_failures() -> u32 {
540 5
541}
542
543impl Default for EventLoopConfig {
544 fn default() -> Self {
545 Self {
546 prompt: None,
547 prompt_file: default_prompt_file(),
548 completion_promise: default_completion_promise(),
549 max_iterations: default_max_iterations(),
550 max_runtime_seconds: default_max_runtime(),
551 max_cost_usd: None,
552 max_consecutive_failures: default_max_failures(),
553 starting_hat: None,
554 starting_event: None,
555 }
556 }
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct CoreConfig {
564 #[serde(default = "default_scratchpad")]
566 pub scratchpad: String,
567
568 #[serde(default = "default_specs_dir")]
570 pub specs_dir: String,
571
572 #[serde(default = "default_guardrails")]
576 pub guardrails: Vec<String>,
577}
578
579fn default_scratchpad() -> String {
580 ".agent/scratchpad.md".to_string()
581}
582
583fn default_specs_dir() -> String {
584 "./specs/".to_string()
585}
586
587fn default_guardrails() -> Vec<String> {
588 vec![
589 "Fresh context each iteration - scratchpad is memory".to_string(),
590 "Don't assume 'not implemented' - search first".to_string(),
591 "Backpressure is law - tests/typecheck/lint must pass".to_string(),
592 ]
593}
594
595impl Default for CoreConfig {
596 fn default() -> Self {
597 Self {
598 scratchpad: default_scratchpad(),
599 specs_dir: default_specs_dir(),
600 guardrails: default_guardrails(),
601 }
602 }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CliConfig {
608 #[serde(default = "default_backend")]
610 pub backend: String,
611
612 pub command: Option<String>,
614
615 #[serde(default = "default_prompt_mode")]
617 pub prompt_mode: String,
618
619 #[serde(default = "default_mode")]
622 pub default_mode: String,
623
624 #[serde(default = "default_idle_timeout")]
628 pub idle_timeout_secs: u32,
629
630 #[serde(default)]
633 pub args: Vec<String>,
634
635 #[serde(default)]
638 pub prompt_flag: Option<String>,
639
640 #[serde(default)]
644 pub experimental_tui: bool,
645}
646
647fn default_backend() -> String {
648 "claude".to_string()
649}
650
651fn default_prompt_mode() -> String {
652 "arg".to_string()
653}
654
655fn default_mode() -> String {
656 "autonomous".to_string()
657}
658
659fn default_idle_timeout() -> u32 {
660 30 }
662
663impl Default for CliConfig {
664 fn default() -> Self {
665 Self {
666 backend: default_backend(),
667 command: None,
668 prompt_mode: default_prompt_mode(),
669 default_mode: default_mode(),
670 idle_timeout_secs: default_idle_timeout(),
671 args: Vec::new(),
672 prompt_flag: None,
673 experimental_tui: false,
674 }
675 }
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct TuiConfig {
681 #[serde(default = "default_prefix_key")]
683 pub prefix_key: String,
684}
685
686fn default_prefix_key() -> String {
687 "ctrl-a".to_string()
688}
689
690impl Default for TuiConfig {
691 fn default() -> Self {
692 Self {
693 prefix_key: default_prefix_key(),
694 }
695 }
696}
697
698impl TuiConfig {
699 pub fn parse_prefix(
702 &self,
703 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
704 use crossterm::event::{KeyCode, KeyModifiers};
705
706 let parts: Vec<&str> = self.prefix_key.split('-').collect();
707 if parts.len() != 2 {
708 return Err(format!(
709 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
710 self.prefix_key
711 ));
712 }
713
714 let modifier = match parts[0].to_lowercase().as_str() {
715 "ctrl" => KeyModifiers::CONTROL,
716 _ => {
717 return Err(format!(
718 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
719 parts[0]
720 ));
721 }
722 };
723
724 let key_str = parts[1];
725 if key_str.len() != 1 {
726 return Err(format!(
727 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
728 key_str
729 ));
730 }
731
732 let key_char = key_str.chars().next().unwrap();
733 let key_code = KeyCode::Char(key_char);
734
735 Ok((key_code, modifier))
736 }
737}
738
739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
754pub struct EventMetadata {
755 #[serde(default)]
757 pub description: String,
758
759 #[serde(default)]
762 pub on_trigger: String,
763
764 #[serde(default)]
767 pub on_publish: String,
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize)]
772#[serde(untagged)]
773pub enum HatBackend {
774 Named(String),
776 KiroAgent {
778 #[serde(rename = "type")]
779 backend_type: String,
780 agent: String,
781 },
782 Custom { command: String, args: Vec<String> },
784}
785
786impl HatBackend {
787 pub fn to_cli_backend(&self) -> String {
789 match self {
790 HatBackend::Named(name) => name.clone(),
791 HatBackend::KiroAgent { .. } => "kiro".to_string(),
792 HatBackend::Custom { .. } => "custom".to_string(),
793 }
794 }
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct HatConfig {
800 pub name: String,
802
803 pub description: Option<String>,
806
807 #[serde(default)]
810 pub triggers: Vec<String>,
811
812 #[serde(default)]
814 pub publishes: Vec<String>,
815
816 #[serde(default)]
818 pub instructions: String,
819
820 #[serde(default)]
822 pub backend: Option<HatBackend>,
823
824 #[serde(default)]
826 pub default_publishes: Option<String>,
827}
828
829impl HatConfig {
830 pub fn trigger_topics(&self) -> Vec<Topic> {
832 self.triggers.iter().map(|s| Topic::new(s)).collect()
833 }
834
835 pub fn publish_topics(&self) -> Vec<Topic> {
837 self.publishes.iter().map(|s| Topic::new(s)).collect()
838 }
839}
840
841#[derive(Debug, thiserror::Error)]
843pub enum ConfigError {
844 #[error("IO error: {0}")]
845 Io(#[from] std::io::Error),
846
847 #[error("YAML parse error: {0}")]
848 Yaml(#[from] serde_yaml::Error),
849
850 #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
851 AmbiguousRouting {
852 trigger: String,
853 hat1: String,
854 hat2: String,
855 },
856
857 #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
858 MutuallyExclusive { field1: String, field2: String },
859
860 #[error("Custom backend requires a command - set 'cli.command' in config")]
861 CustomBackendRequiresCommand,
862
863 #[error(
864 "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."
865 )]
866 ReservedTrigger { trigger: String, hat: String },
867
868 #[error(
869 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
870 )]
871 MissingDescription { hat: String },
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn test_default_config() {
880 let config = RalphConfig::default();
881 assert!(config.hats.is_empty());
883 assert_eq!(config.event_loop.max_iterations, 100);
884 assert!(!config.verbose);
885 }
886
887 #[test]
888 fn test_parse_yaml_with_custom_hats() {
889 let yaml = r#"
890event_loop:
891 prompt_file: "TASK.md"
892 completion_promise: "DONE"
893 max_iterations: 50
894cli:
895 backend: "claude"
896hats:
897 implementer:
898 name: "Implementer"
899 triggers: ["task.*", "review.done"]
900 publishes: ["impl.done"]
901 instructions: "You are the implementation agent."
902"#;
903 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
904 assert_eq!(config.hats.len(), 1);
906 assert_eq!(config.event_loop.prompt_file, "TASK.md");
907
908 let hat = config.hats.get("implementer").unwrap();
909 assert_eq!(hat.triggers.len(), 2);
910 }
911
912 #[test]
913 fn test_parse_yaml_v1_format() {
914 let yaml = r#"
916agent: gemini
917prompt_file: "TASK.md"
918completion_promise: "RALPH_DONE"
919max_iterations: 75
920max_runtime: 7200
921max_cost: 10.0
922verbose: true
923"#;
924 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
925
926 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
932
933 assert_eq!(config.cli.backend, "gemini");
935 assert_eq!(config.event_loop.prompt_file, "TASK.md");
936 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
937 assert_eq!(config.event_loop.max_iterations, 75);
938 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
939 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
940 assert!(config.verbose);
941 }
942
943 #[test]
944 fn test_agent_priority() {
945 let yaml = r"
946agent: auto
947agent_priority: [gemini, claude, codex]
948";
949 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
950 let priority = config.get_agent_priority();
951 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
952 }
953
954 #[test]
955 fn test_default_agent_priority() {
956 let config = RalphConfig::default();
957 let priority = config.get_agent_priority();
958 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
959 }
960
961 #[test]
962 fn test_validate_deferred_features() {
963 let yaml = r"
964archive_prompts: true
965enable_metrics: true
966";
967 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
968 let warnings = config.validate().unwrap();
969
970 assert_eq!(warnings.len(), 2);
971 assert!(warnings
972 .iter()
973 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
974 assert!(warnings
975 .iter()
976 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
977 }
978
979 #[test]
980 fn test_validate_dropped_fields() {
981 let yaml = r#"
982max_tokens: 4096
983retry_delay: 5
984adapters:
985 claude:
986 tool_permissions: ["read", "write"]
987"#;
988 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
989 let warnings = config.validate().unwrap();
990
991 assert_eq!(warnings.len(), 3);
992 assert!(warnings.iter().any(
993 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
994 ));
995 assert!(warnings.iter().any(
996 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
997 ));
998 assert!(warnings
999 .iter()
1000 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1001 }
1002
1003 #[test]
1004 fn test_suppress_warnings() {
1005 let yaml = r"
1006_suppress_warnings: true
1007archive_prompts: true
1008max_tokens: 4096
1009";
1010 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1011 let warnings = config.validate().unwrap();
1012
1013 assert!(warnings.is_empty());
1015 }
1016
1017 #[test]
1018 fn test_adapter_settings() {
1019 let yaml = r"
1020adapters:
1021 claude:
1022 timeout: 600
1023 enabled: true
1024 gemini:
1025 timeout: 300
1026 enabled: false
1027";
1028 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1029
1030 let claude = config.adapter_settings("claude");
1031 assert_eq!(claude.timeout, 600);
1032 assert!(claude.enabled);
1033
1034 let gemini = config.adapter_settings("gemini");
1035 assert_eq!(gemini.timeout, 300);
1036 assert!(!gemini.enabled);
1037 }
1038
1039 #[test]
1040 fn test_unknown_fields_ignored() {
1041 let yaml = r#"
1043agent: claude
1044unknown_field: "some value"
1045future_feature: true
1046"#;
1047 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1048 assert!(result.is_ok());
1050 }
1051
1052 #[test]
1053 fn test_ambiguous_routing_rejected() {
1054 let yaml = r#"
1057hats:
1058 planner:
1059 name: "Planner"
1060 description: "Plans tasks"
1061 triggers: ["planning.start", "build.done"]
1062 builder:
1063 name: "Builder"
1064 description: "Builds code"
1065 triggers: ["build.task", "build.done"]
1066"#;
1067 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1068 let result = config.validate();
1069
1070 assert!(result.is_err());
1071 let err = result.unwrap_err();
1072 assert!(
1073 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1074 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1075 err
1076 );
1077 }
1078
1079 #[test]
1080 fn test_unique_triggers_accepted() {
1081 let yaml = r#"
1084hats:
1085 planner:
1086 name: "Planner"
1087 description: "Plans tasks"
1088 triggers: ["planning.start", "build.done", "build.blocked"]
1089 builder:
1090 name: "Builder"
1091 description: "Builds code"
1092 triggers: ["build.task"]
1093"#;
1094 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1095 let result = config.validate();
1096
1097 assert!(
1098 result.is_ok(),
1099 "Expected valid config, got: {:?}",
1100 result.unwrap_err()
1101 );
1102 }
1103
1104 #[test]
1105 fn test_reserved_trigger_task_start_rejected() {
1106 let yaml = r#"
1108hats:
1109 my_hat:
1110 name: "My Hat"
1111 description: "Test hat"
1112 triggers: ["task.start"]
1113"#;
1114 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1115 let result = config.validate();
1116
1117 assert!(result.is_err());
1118 let err = result.unwrap_err();
1119 assert!(
1120 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1121 if trigger == "task.start" && hat == "my_hat"),
1122 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1123 err
1124 );
1125 }
1126
1127 #[test]
1128 fn test_reserved_trigger_task_resume_rejected() {
1129 let yaml = r#"
1131hats:
1132 my_hat:
1133 name: "My Hat"
1134 description: "Test hat"
1135 triggers: ["task.resume", "other.event"]
1136"#;
1137 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1138 let result = config.validate();
1139
1140 assert!(result.is_err());
1141 let err = result.unwrap_err();
1142 assert!(
1143 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1144 if trigger == "task.resume" && hat == "my_hat"),
1145 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1146 err
1147 );
1148 }
1149
1150 #[test]
1151 fn test_missing_description_rejected() {
1152 let yaml = r#"
1154hats:
1155 my_hat:
1156 name: "My Hat"
1157 triggers: ["build.task"]
1158"#;
1159 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1160 let result = config.validate();
1161
1162 assert!(result.is_err());
1163 let err = result.unwrap_err();
1164 assert!(
1165 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1166 "Expected MissingDescription error, got: {:?}",
1167 err
1168 );
1169 }
1170
1171 #[test]
1172 fn test_empty_description_rejected() {
1173 let yaml = r#"
1175hats:
1176 my_hat:
1177 name: "My Hat"
1178 description: " "
1179 triggers: ["build.task"]
1180"#;
1181 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1182 let result = config.validate();
1183
1184 assert!(result.is_err());
1185 let err = result.unwrap_err();
1186 assert!(
1187 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1188 "Expected MissingDescription error for empty description, got: {:?}",
1189 err
1190 );
1191 }
1192
1193 #[test]
1194 fn test_core_config_defaults() {
1195 let config = RalphConfig::default();
1196 assert_eq!(config.core.scratchpad, ".agent/scratchpad.md");
1197 assert_eq!(config.core.specs_dir, "./specs/");
1198 assert_eq!(config.core.guardrails.len(), 3);
1200 assert!(config.core.guardrails[0].contains("Fresh context"));
1201 assert!(config.core.guardrails[1].contains("search first"));
1202 assert!(config.core.guardrails[2].contains("Backpressure"));
1203 }
1204
1205 #[test]
1206 fn test_core_config_customizable() {
1207 let yaml = r#"
1208core:
1209 scratchpad: ".workspace/plan.md"
1210 specs_dir: "./specifications/"
1211"#;
1212 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1213 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1214 assert_eq!(config.core.specs_dir, "./specifications/");
1215 assert_eq!(config.core.guardrails.len(), 3);
1217 }
1218
1219 #[test]
1220 fn test_core_config_custom_guardrails() {
1221 let yaml = r#"
1222core:
1223 scratchpad: ".agent/scratchpad.md"
1224 specs_dir: "./specs/"
1225 guardrails:
1226 - "Custom rule one"
1227 - "Custom rule two"
1228"#;
1229 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1230 assert_eq!(config.core.guardrails.len(), 2);
1231 assert_eq!(config.core.guardrails[0], "Custom rule one");
1232 assert_eq!(config.core.guardrails[1], "Custom rule two");
1233 }
1234
1235 #[test]
1236 fn test_prompt_and_prompt_file_mutually_exclusive() {
1237 let yaml = r#"
1239event_loop:
1240 prompt: "inline text"
1241 prompt_file: "custom.md"
1242"#;
1243 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1244 let result = config.validate();
1245
1246 assert!(result.is_err());
1247 let err = result.unwrap_err();
1248 assert!(
1249 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1250 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1251 "Expected MutuallyExclusive error, got: {:?}",
1252 err
1253 );
1254 }
1255
1256 #[test]
1257 fn test_prompt_with_default_prompt_file_allowed() {
1258 let yaml = r#"
1260event_loop:
1261 prompt: "inline text"
1262"#;
1263 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1264 let result = config.validate();
1265
1266 assert!(
1267 result.is_ok(),
1268 "Should allow inline prompt with default prompt_file"
1269 );
1270 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1271 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1272 }
1273
1274 #[test]
1275 fn test_custom_backend_requires_command() {
1276 let yaml = r#"
1278cli:
1279 backend: "custom"
1280"#;
1281 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1282 let result = config.validate();
1283
1284 assert!(result.is_err());
1285 let err = result.unwrap_err();
1286 assert!(
1287 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1288 "Expected CustomBackendRequiresCommand error, got: {:?}",
1289 err
1290 );
1291 }
1292
1293 #[test]
1294 fn test_custom_backend_with_empty_command_errors() {
1295 let yaml = r#"
1297cli:
1298 backend: "custom"
1299 command: ""
1300"#;
1301 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1302 let result = config.validate();
1303
1304 assert!(result.is_err());
1305 let err = result.unwrap_err();
1306 assert!(
1307 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1308 "Expected CustomBackendRequiresCommand error, got: {:?}",
1309 err
1310 );
1311 }
1312
1313 #[test]
1314 fn test_custom_backend_with_command_succeeds() {
1315 let yaml = r#"
1317cli:
1318 backend: "custom"
1319 command: "my-agent"
1320"#;
1321 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1322 let result = config.validate();
1323
1324 assert!(
1325 result.is_ok(),
1326 "Should allow custom backend with command: {:?}",
1327 result.unwrap_err()
1328 );
1329 }
1330
1331 #[test]
1332 fn test_prompt_file_with_no_inline_allowed() {
1333 let yaml = r#"
1335event_loop:
1336 prompt_file: "custom.md"
1337"#;
1338 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1339 let result = config.validate();
1340
1341 assert!(
1342 result.is_ok(),
1343 "Should allow prompt_file without inline prompt"
1344 );
1345 assert_eq!(config.event_loop.prompt, None);
1346 assert_eq!(config.event_loop.prompt_file, "custom.md");
1347 }
1348
1349 #[test]
1350 fn test_default_prompt_file_value() {
1351 let config = RalphConfig::default();
1352 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1353 assert_eq!(config.event_loop.prompt, None);
1354 }
1355
1356 #[test]
1357 fn test_tui_config_default() {
1358 let config = RalphConfig::default();
1359 assert_eq!(config.tui.prefix_key, "ctrl-a");
1360 }
1361
1362 #[test]
1363 fn test_tui_config_parse_ctrl_b() {
1364 let yaml = r#"
1365tui:
1366 prefix_key: "ctrl-b"
1367"#;
1368 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1369 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1370
1371 use crossterm::event::{KeyCode, KeyModifiers};
1372 assert_eq!(key_code, KeyCode::Char('b'));
1373 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1374 }
1375
1376 #[test]
1377 fn test_tui_config_parse_invalid_format() {
1378 let tui_config = TuiConfig {
1379 prefix_key: "invalid".to_string(),
1380 };
1381 let result = tui_config.parse_prefix();
1382 assert!(result.is_err());
1383 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1384 }
1385
1386 #[test]
1387 fn test_tui_config_parse_invalid_modifier() {
1388 let tui_config = TuiConfig {
1389 prefix_key: "alt-a".to_string(),
1390 };
1391 let result = tui_config.parse_prefix();
1392 assert!(result.is_err());
1393 assert!(result.unwrap_err().contains("Invalid modifier"));
1394 }
1395
1396 #[test]
1397 fn test_tui_config_parse_invalid_key() {
1398 let tui_config = TuiConfig {
1399 prefix_key: "ctrl-abc".to_string(),
1400 };
1401 let result = tui_config.parse_prefix();
1402 assert!(result.is_err());
1403 assert!(result.unwrap_err().contains("Invalid key"));
1404 }
1405
1406 #[test]
1407 fn test_hat_backend_named() {
1408 let yaml = r#""claude""#;
1409 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1410 assert_eq!(backend.to_cli_backend(), "claude");
1411 match backend {
1412 HatBackend::Named(name) => assert_eq!(name, "claude"),
1413 _ => panic!("Expected Named variant"),
1414 }
1415 }
1416
1417 #[test]
1418 fn test_hat_backend_kiro_agent() {
1419 let yaml = r#"
1420type: "kiro"
1421agent: "builder"
1422"#;
1423 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1424 assert_eq!(backend.to_cli_backend(), "kiro");
1425 match backend {
1426 HatBackend::KiroAgent {
1427 backend_type,
1428 agent,
1429 } => {
1430 assert_eq!(backend_type, "kiro");
1431 assert_eq!(agent, "builder");
1432 }
1433 _ => panic!("Expected KiroAgent variant"),
1434 }
1435 }
1436
1437 #[test]
1438 fn test_hat_backend_custom() {
1439 let yaml = r#"
1440command: "/usr/bin/my-agent"
1441args: ["--flag", "value"]
1442"#;
1443 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1444 assert_eq!(backend.to_cli_backend(), "custom");
1445 match backend {
1446 HatBackend::Custom { command, args } => {
1447 assert_eq!(command, "/usr/bin/my-agent");
1448 assert_eq!(args, vec!["--flag", "value"]);
1449 }
1450 _ => panic!("Expected Custom variant"),
1451 }
1452 }
1453
1454 #[test]
1455 fn test_hat_config_with_backend() {
1456 let yaml = r#"
1457name: "Custom Builder"
1458triggers: ["build.task"]
1459publishes: ["build.done"]
1460instructions: "Build stuff"
1461backend: "gemini"
1462default_publishes: "task.done"
1463"#;
1464 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1465 assert_eq!(hat.name, "Custom Builder");
1466 assert!(hat.backend.is_some());
1467 match hat.backend.unwrap() {
1468 HatBackend::Named(name) => assert_eq!(name, "gemini"),
1469 _ => panic!("Expected Named backend"),
1470 }
1471 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1472 }
1473
1474 #[test]
1475 fn test_hat_config_without_backend() {
1476 let yaml = r#"
1477name: "Default Hat"
1478triggers: ["task.start"]
1479publishes: ["task.done"]
1480instructions: "Do work"
1481"#;
1482 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1483 assert_eq!(hat.name, "Default Hat");
1484 assert!(hat.backend.is_none());
1485 assert!(hat.default_publishes.is_none());
1486 }
1487
1488 #[test]
1489 fn test_mixed_backends_config() {
1490 let yaml = r#"
1491event_loop:
1492 prompt_file: "TASK.md"
1493 max_iterations: 50
1494
1495cli:
1496 backend: "claude"
1497
1498hats:
1499 planner:
1500 name: "Planner"
1501 triggers: ["task.start"]
1502 publishes: ["build.task"]
1503 instructions: "Plan the work"
1504 backend: "claude"
1505
1506 builder:
1507 name: "Builder"
1508 triggers: ["build.task"]
1509 publishes: ["build.done"]
1510 instructions: "Build the thing"
1511 backend:
1512 type: "kiro"
1513 agent: "builder"
1514
1515 reviewer:
1516 name: "Reviewer"
1517 triggers: ["build.done"]
1518 publishes: ["review.complete"]
1519 instructions: "Review the work"
1520 backend:
1521 command: "/usr/local/bin/custom-agent"
1522 args: ["--mode", "review"]
1523 default_publishes: "review.complete"
1524"#;
1525 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1526 assert_eq!(config.hats.len(), 3);
1527
1528 let planner = config.hats.get("planner").unwrap();
1530 assert!(planner.backend.is_some());
1531 match planner.backend.as_ref().unwrap() {
1532 HatBackend::Named(name) => assert_eq!(name, "claude"),
1533 _ => panic!("Expected Named backend for planner"),
1534 }
1535
1536 let builder = config.hats.get("builder").unwrap();
1538 assert!(builder.backend.is_some());
1539 match builder.backend.as_ref().unwrap() {
1540 HatBackend::KiroAgent {
1541 backend_type,
1542 agent,
1543 } => {
1544 assert_eq!(backend_type, "kiro");
1545 assert_eq!(agent, "builder");
1546 }
1547 _ => panic!("Expected KiroAgent backend for builder"),
1548 }
1549
1550 let reviewer = config.hats.get("reviewer").unwrap();
1552 assert!(reviewer.backend.is_some());
1553 match reviewer.backend.as_ref().unwrap() {
1554 HatBackend::Custom { command, args } => {
1555 assert_eq!(command, "/usr/local/bin/custom-agent");
1556 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1557 }
1558 _ => panic!("Expected Custom backend for reviewer"),
1559 }
1560 assert_eq!(
1561 reviewer.default_publishes,
1562 Some("review.complete".to_string())
1563 );
1564 }
1565}