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
641fn default_backend() -> String {
642 "claude".to_string()
643}
644
645fn default_prompt_mode() -> String {
646 "arg".to_string()
647}
648
649fn default_mode() -> String {
650 "autonomous".to_string()
651}
652
653fn default_idle_timeout() -> u32 {
654 30 }
656
657impl Default for CliConfig {
658 fn default() -> Self {
659 Self {
660 backend: default_backend(),
661 command: None,
662 prompt_mode: default_prompt_mode(),
663 default_mode: default_mode(),
664 idle_timeout_secs: default_idle_timeout(),
665 args: Vec::new(),
666 prompt_flag: None,
667 }
668 }
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct TuiConfig {
674 #[serde(default = "default_prefix_key")]
676 pub prefix_key: String,
677}
678
679fn default_prefix_key() -> String {
680 "ctrl-a".to_string()
681}
682
683impl Default for TuiConfig {
684 fn default() -> Self {
685 Self {
686 prefix_key: default_prefix_key(),
687 }
688 }
689}
690
691impl TuiConfig {
692 pub fn parse_prefix(
695 &self,
696 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
697 use crossterm::event::{KeyCode, KeyModifiers};
698
699 let parts: Vec<&str> = self.prefix_key.split('-').collect();
700 if parts.len() != 2 {
701 return Err(format!(
702 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
703 self.prefix_key
704 ));
705 }
706
707 let modifier = match parts[0].to_lowercase().as_str() {
708 "ctrl" => KeyModifiers::CONTROL,
709 _ => {
710 return Err(format!(
711 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
712 parts[0]
713 ));
714 }
715 };
716
717 let key_str = parts[1];
718 if key_str.len() != 1 {
719 return Err(format!(
720 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
721 key_str
722 ));
723 }
724
725 let key_char = key_str.chars().next().unwrap();
726 let key_code = KeyCode::Char(key_char);
727
728 Ok((key_code, modifier))
729 }
730}
731
732#[derive(Debug, Clone, Default, Serialize, Deserialize)]
747pub struct EventMetadata {
748 #[serde(default)]
750 pub description: String,
751
752 #[serde(default)]
755 pub on_trigger: String,
756
757 #[serde(default)]
760 pub on_publish: String,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(untagged)]
766pub enum HatBackend {
767 Named(String),
769 KiroAgent {
771 #[serde(rename = "type")]
772 backend_type: String,
773 agent: String,
774 },
775 Custom { command: String, args: Vec<String> },
777}
778
779impl HatBackend {
780 pub fn to_cli_backend(&self) -> String {
782 match self {
783 HatBackend::Named(name) => name.clone(),
784 HatBackend::KiroAgent { .. } => "kiro".to_string(),
785 HatBackend::Custom { .. } => "custom".to_string(),
786 }
787 }
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize)]
792pub struct HatConfig {
793 pub name: String,
795
796 pub description: Option<String>,
799
800 #[serde(default)]
803 pub triggers: Vec<String>,
804
805 #[serde(default)]
807 pub publishes: Vec<String>,
808
809 #[serde(default)]
811 pub instructions: String,
812
813 #[serde(default)]
815 pub backend: Option<HatBackend>,
816
817 #[serde(default)]
819 pub default_publishes: Option<String>,
820}
821
822impl HatConfig {
823 pub fn trigger_topics(&self) -> Vec<Topic> {
825 self.triggers.iter().map(|s| Topic::new(s)).collect()
826 }
827
828 pub fn publish_topics(&self) -> Vec<Topic> {
830 self.publishes.iter().map(|s| Topic::new(s)).collect()
831 }
832}
833
834#[derive(Debug, thiserror::Error)]
836pub enum ConfigError {
837 #[error("IO error: {0}")]
838 Io(#[from] std::io::Error),
839
840 #[error("YAML parse error: {0}")]
841 Yaml(#[from] serde_yaml::Error),
842
843 #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
844 AmbiguousRouting {
845 trigger: String,
846 hat1: String,
847 hat2: String,
848 },
849
850 #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
851 MutuallyExclusive { field1: String, field2: String },
852
853 #[error("Custom backend requires a command - set 'cli.command' in config")]
854 CustomBackendRequiresCommand,
855
856 #[error(
857 "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."
858 )]
859 ReservedTrigger { trigger: String, hat: String },
860
861 #[error(
862 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
863 )]
864 MissingDescription { hat: String },
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn test_default_config() {
873 let config = RalphConfig::default();
874 assert!(config.hats.is_empty());
876 assert_eq!(config.event_loop.max_iterations, 100);
877 assert!(!config.verbose);
878 }
879
880 #[test]
881 fn test_parse_yaml_with_custom_hats() {
882 let yaml = r#"
883event_loop:
884 prompt_file: "TASK.md"
885 completion_promise: "DONE"
886 max_iterations: 50
887cli:
888 backend: "claude"
889hats:
890 implementer:
891 name: "Implementer"
892 triggers: ["task.*", "review.done"]
893 publishes: ["impl.done"]
894 instructions: "You are the implementation agent."
895"#;
896 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
897 assert_eq!(config.hats.len(), 1);
899 assert_eq!(config.event_loop.prompt_file, "TASK.md");
900
901 let hat = config.hats.get("implementer").unwrap();
902 assert_eq!(hat.triggers.len(), 2);
903 }
904
905 #[test]
906 fn test_parse_yaml_v1_format() {
907 let yaml = r#"
909agent: gemini
910prompt_file: "TASK.md"
911completion_promise: "RALPH_DONE"
912max_iterations: 75
913max_runtime: 7200
914max_cost: 10.0
915verbose: true
916"#;
917 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
918
919 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
925
926 assert_eq!(config.cli.backend, "gemini");
928 assert_eq!(config.event_loop.prompt_file, "TASK.md");
929 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
930 assert_eq!(config.event_loop.max_iterations, 75);
931 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
932 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
933 assert!(config.verbose);
934 }
935
936 #[test]
937 fn test_agent_priority() {
938 let yaml = r"
939agent: auto
940agent_priority: [gemini, claude, codex]
941";
942 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
943 let priority = config.get_agent_priority();
944 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
945 }
946
947 #[test]
948 fn test_default_agent_priority() {
949 let config = RalphConfig::default();
950 let priority = config.get_agent_priority();
951 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
952 }
953
954 #[test]
955 fn test_validate_deferred_features() {
956 let yaml = r"
957archive_prompts: true
958enable_metrics: true
959";
960 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
961 let warnings = config.validate().unwrap();
962
963 assert_eq!(warnings.len(), 2);
964 assert!(warnings
965 .iter()
966 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
967 assert!(warnings
968 .iter()
969 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
970 }
971
972 #[test]
973 fn test_validate_dropped_fields() {
974 let yaml = r#"
975max_tokens: 4096
976retry_delay: 5
977adapters:
978 claude:
979 tool_permissions: ["read", "write"]
980"#;
981 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
982 let warnings = config.validate().unwrap();
983
984 assert_eq!(warnings.len(), 3);
985 assert!(warnings.iter().any(
986 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
987 ));
988 assert!(warnings.iter().any(
989 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
990 ));
991 assert!(warnings
992 .iter()
993 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
994 }
995
996 #[test]
997 fn test_suppress_warnings() {
998 let yaml = r"
999_suppress_warnings: true
1000archive_prompts: true
1001max_tokens: 4096
1002";
1003 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1004 let warnings = config.validate().unwrap();
1005
1006 assert!(warnings.is_empty());
1008 }
1009
1010 #[test]
1011 fn test_adapter_settings() {
1012 let yaml = r"
1013adapters:
1014 claude:
1015 timeout: 600
1016 enabled: true
1017 gemini:
1018 timeout: 300
1019 enabled: false
1020";
1021 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1022
1023 let claude = config.adapter_settings("claude");
1024 assert_eq!(claude.timeout, 600);
1025 assert!(claude.enabled);
1026
1027 let gemini = config.adapter_settings("gemini");
1028 assert_eq!(gemini.timeout, 300);
1029 assert!(!gemini.enabled);
1030 }
1031
1032 #[test]
1033 fn test_unknown_fields_ignored() {
1034 let yaml = r#"
1036agent: claude
1037unknown_field: "some value"
1038future_feature: true
1039"#;
1040 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1041 assert!(result.is_ok());
1043 }
1044
1045 #[test]
1046 fn test_ambiguous_routing_rejected() {
1047 let yaml = r#"
1050hats:
1051 planner:
1052 name: "Planner"
1053 description: "Plans tasks"
1054 triggers: ["planning.start", "build.done"]
1055 builder:
1056 name: "Builder"
1057 description: "Builds code"
1058 triggers: ["build.task", "build.done"]
1059"#;
1060 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1061 let result = config.validate();
1062
1063 assert!(result.is_err());
1064 let err = result.unwrap_err();
1065 assert!(
1066 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1067 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1068 err
1069 );
1070 }
1071
1072 #[test]
1073 fn test_unique_triggers_accepted() {
1074 let yaml = r#"
1077hats:
1078 planner:
1079 name: "Planner"
1080 description: "Plans tasks"
1081 triggers: ["planning.start", "build.done", "build.blocked"]
1082 builder:
1083 name: "Builder"
1084 description: "Builds code"
1085 triggers: ["build.task"]
1086"#;
1087 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1088 let result = config.validate();
1089
1090 assert!(
1091 result.is_ok(),
1092 "Expected valid config, got: {:?}",
1093 result.unwrap_err()
1094 );
1095 }
1096
1097 #[test]
1098 fn test_reserved_trigger_task_start_rejected() {
1099 let yaml = r#"
1101hats:
1102 my_hat:
1103 name: "My Hat"
1104 description: "Test hat"
1105 triggers: ["task.start"]
1106"#;
1107 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1108 let result = config.validate();
1109
1110 assert!(result.is_err());
1111 let err = result.unwrap_err();
1112 assert!(
1113 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1114 if trigger == "task.start" && hat == "my_hat"),
1115 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1116 err
1117 );
1118 }
1119
1120 #[test]
1121 fn test_reserved_trigger_task_resume_rejected() {
1122 let yaml = r#"
1124hats:
1125 my_hat:
1126 name: "My Hat"
1127 description: "Test hat"
1128 triggers: ["task.resume", "other.event"]
1129"#;
1130 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1131 let result = config.validate();
1132
1133 assert!(result.is_err());
1134 let err = result.unwrap_err();
1135 assert!(
1136 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1137 if trigger == "task.resume" && hat == "my_hat"),
1138 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1139 err
1140 );
1141 }
1142
1143 #[test]
1144 fn test_missing_description_rejected() {
1145 let yaml = r#"
1147hats:
1148 my_hat:
1149 name: "My Hat"
1150 triggers: ["build.task"]
1151"#;
1152 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1153 let result = config.validate();
1154
1155 assert!(result.is_err());
1156 let err = result.unwrap_err();
1157 assert!(
1158 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1159 "Expected MissingDescription error, got: {:?}",
1160 err
1161 );
1162 }
1163
1164 #[test]
1165 fn test_empty_description_rejected() {
1166 let yaml = r#"
1168hats:
1169 my_hat:
1170 name: "My Hat"
1171 description: " "
1172 triggers: ["build.task"]
1173"#;
1174 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1175 let result = config.validate();
1176
1177 assert!(result.is_err());
1178 let err = result.unwrap_err();
1179 assert!(
1180 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1181 "Expected MissingDescription error for empty description, got: {:?}",
1182 err
1183 );
1184 }
1185
1186 #[test]
1187 fn test_core_config_defaults() {
1188 let config = RalphConfig::default();
1189 assert_eq!(config.core.scratchpad, ".agent/scratchpad.md");
1190 assert_eq!(config.core.specs_dir, "./specs/");
1191 assert_eq!(config.core.guardrails.len(), 3);
1193 assert!(config.core.guardrails[0].contains("Fresh context"));
1194 assert!(config.core.guardrails[1].contains("search first"));
1195 assert!(config.core.guardrails[2].contains("Backpressure"));
1196 }
1197
1198 #[test]
1199 fn test_core_config_customizable() {
1200 let yaml = r#"
1201core:
1202 scratchpad: ".workspace/plan.md"
1203 specs_dir: "./specifications/"
1204"#;
1205 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1206 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1207 assert_eq!(config.core.specs_dir, "./specifications/");
1208 assert_eq!(config.core.guardrails.len(), 3);
1210 }
1211
1212 #[test]
1213 fn test_core_config_custom_guardrails() {
1214 let yaml = r#"
1215core:
1216 scratchpad: ".agent/scratchpad.md"
1217 specs_dir: "./specs/"
1218 guardrails:
1219 - "Custom rule one"
1220 - "Custom rule two"
1221"#;
1222 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1223 assert_eq!(config.core.guardrails.len(), 2);
1224 assert_eq!(config.core.guardrails[0], "Custom rule one");
1225 assert_eq!(config.core.guardrails[1], "Custom rule two");
1226 }
1227
1228 #[test]
1229 fn test_prompt_and_prompt_file_mutually_exclusive() {
1230 let yaml = r#"
1232event_loop:
1233 prompt: "inline text"
1234 prompt_file: "custom.md"
1235"#;
1236 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1237 let result = config.validate();
1238
1239 assert!(result.is_err());
1240 let err = result.unwrap_err();
1241 assert!(
1242 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1243 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1244 "Expected MutuallyExclusive error, got: {:?}",
1245 err
1246 );
1247 }
1248
1249 #[test]
1250 fn test_prompt_with_default_prompt_file_allowed() {
1251 let yaml = r#"
1253event_loop:
1254 prompt: "inline text"
1255"#;
1256 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1257 let result = config.validate();
1258
1259 assert!(
1260 result.is_ok(),
1261 "Should allow inline prompt with default prompt_file"
1262 );
1263 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1264 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1265 }
1266
1267 #[test]
1268 fn test_custom_backend_requires_command() {
1269 let yaml = r#"
1271cli:
1272 backend: "custom"
1273"#;
1274 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1275 let result = config.validate();
1276
1277 assert!(result.is_err());
1278 let err = result.unwrap_err();
1279 assert!(
1280 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1281 "Expected CustomBackendRequiresCommand error, got: {:?}",
1282 err
1283 );
1284 }
1285
1286 #[test]
1287 fn test_custom_backend_with_empty_command_errors() {
1288 let yaml = r#"
1290cli:
1291 backend: "custom"
1292 command: ""
1293"#;
1294 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1295 let result = config.validate();
1296
1297 assert!(result.is_err());
1298 let err = result.unwrap_err();
1299 assert!(
1300 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1301 "Expected CustomBackendRequiresCommand error, got: {:?}",
1302 err
1303 );
1304 }
1305
1306 #[test]
1307 fn test_custom_backend_with_command_succeeds() {
1308 let yaml = r#"
1310cli:
1311 backend: "custom"
1312 command: "my-agent"
1313"#;
1314 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1315 let result = config.validate();
1316
1317 assert!(
1318 result.is_ok(),
1319 "Should allow custom backend with command: {:?}",
1320 result.unwrap_err()
1321 );
1322 }
1323
1324 #[test]
1325 fn test_prompt_file_with_no_inline_allowed() {
1326 let yaml = r#"
1328event_loop:
1329 prompt_file: "custom.md"
1330"#;
1331 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1332 let result = config.validate();
1333
1334 assert!(
1335 result.is_ok(),
1336 "Should allow prompt_file without inline prompt"
1337 );
1338 assert_eq!(config.event_loop.prompt, None);
1339 assert_eq!(config.event_loop.prompt_file, "custom.md");
1340 }
1341
1342 #[test]
1343 fn test_default_prompt_file_value() {
1344 let config = RalphConfig::default();
1345 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1346 assert_eq!(config.event_loop.prompt, None);
1347 }
1348
1349 #[test]
1350 fn test_tui_config_default() {
1351 let config = RalphConfig::default();
1352 assert_eq!(config.tui.prefix_key, "ctrl-a");
1353 }
1354
1355 #[test]
1356 fn test_tui_config_parse_ctrl_b() {
1357 let yaml = r#"
1358tui:
1359 prefix_key: "ctrl-b"
1360"#;
1361 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1362 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1363
1364 use crossterm::event::{KeyCode, KeyModifiers};
1365 assert_eq!(key_code, KeyCode::Char('b'));
1366 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1367 }
1368
1369 #[test]
1370 fn test_tui_config_parse_invalid_format() {
1371 let tui_config = TuiConfig {
1372 prefix_key: "invalid".to_string(),
1373 };
1374 let result = tui_config.parse_prefix();
1375 assert!(result.is_err());
1376 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1377 }
1378
1379 #[test]
1380 fn test_tui_config_parse_invalid_modifier() {
1381 let tui_config = TuiConfig {
1382 prefix_key: "alt-a".to_string(),
1383 };
1384 let result = tui_config.parse_prefix();
1385 assert!(result.is_err());
1386 assert!(result.unwrap_err().contains("Invalid modifier"));
1387 }
1388
1389 #[test]
1390 fn test_tui_config_parse_invalid_key() {
1391 let tui_config = TuiConfig {
1392 prefix_key: "ctrl-abc".to_string(),
1393 };
1394 let result = tui_config.parse_prefix();
1395 assert!(result.is_err());
1396 assert!(result.unwrap_err().contains("Invalid key"));
1397 }
1398
1399 #[test]
1400 fn test_hat_backend_named() {
1401 let yaml = r#""claude""#;
1402 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1403 assert_eq!(backend.to_cli_backend(), "claude");
1404 match backend {
1405 HatBackend::Named(name) => assert_eq!(name, "claude"),
1406 _ => panic!("Expected Named variant"),
1407 }
1408 }
1409
1410 #[test]
1411 fn test_hat_backend_kiro_agent() {
1412 let yaml = r#"
1413type: "kiro"
1414agent: "builder"
1415"#;
1416 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1417 assert_eq!(backend.to_cli_backend(), "kiro");
1418 match backend {
1419 HatBackend::KiroAgent {
1420 backend_type,
1421 agent,
1422 } => {
1423 assert_eq!(backend_type, "kiro");
1424 assert_eq!(agent, "builder");
1425 }
1426 _ => panic!("Expected KiroAgent variant"),
1427 }
1428 }
1429
1430 #[test]
1431 fn test_hat_backend_custom() {
1432 let yaml = r#"
1433command: "/usr/bin/my-agent"
1434args: ["--flag", "value"]
1435"#;
1436 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1437 assert_eq!(backend.to_cli_backend(), "custom");
1438 match backend {
1439 HatBackend::Custom { command, args } => {
1440 assert_eq!(command, "/usr/bin/my-agent");
1441 assert_eq!(args, vec!["--flag", "value"]);
1442 }
1443 _ => panic!("Expected Custom variant"),
1444 }
1445 }
1446
1447 #[test]
1448 fn test_hat_config_with_backend() {
1449 let yaml = r#"
1450name: "Custom Builder"
1451triggers: ["build.task"]
1452publishes: ["build.done"]
1453instructions: "Build stuff"
1454backend: "gemini"
1455default_publishes: "task.done"
1456"#;
1457 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1458 assert_eq!(hat.name, "Custom Builder");
1459 assert!(hat.backend.is_some());
1460 match hat.backend.unwrap() {
1461 HatBackend::Named(name) => assert_eq!(name, "gemini"),
1462 _ => panic!("Expected Named backend"),
1463 }
1464 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1465 }
1466
1467 #[test]
1468 fn test_hat_config_without_backend() {
1469 let yaml = r#"
1470name: "Default Hat"
1471triggers: ["task.start"]
1472publishes: ["task.done"]
1473instructions: "Do work"
1474"#;
1475 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1476 assert_eq!(hat.name, "Default Hat");
1477 assert!(hat.backend.is_none());
1478 assert!(hat.default_publishes.is_none());
1479 }
1480
1481 #[test]
1482 fn test_mixed_backends_config() {
1483 let yaml = r#"
1484event_loop:
1485 prompt_file: "TASK.md"
1486 max_iterations: 50
1487
1488cli:
1489 backend: "claude"
1490
1491hats:
1492 planner:
1493 name: "Planner"
1494 triggers: ["task.start"]
1495 publishes: ["build.task"]
1496 instructions: "Plan the work"
1497 backend: "claude"
1498
1499 builder:
1500 name: "Builder"
1501 triggers: ["build.task"]
1502 publishes: ["build.done"]
1503 instructions: "Build the thing"
1504 backend:
1505 type: "kiro"
1506 agent: "builder"
1507
1508 reviewer:
1509 name: "Reviewer"
1510 triggers: ["build.done"]
1511 publishes: ["review.complete"]
1512 instructions: "Review the work"
1513 backend:
1514 command: "/usr/local/bin/custom-agent"
1515 args: ["--mode", "review"]
1516 default_publishes: "review.complete"
1517"#;
1518 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1519 assert_eq!(config.hats.len(), 3);
1520
1521 let planner = config.hats.get("planner").unwrap();
1523 assert!(planner.backend.is_some());
1524 match planner.backend.as_ref().unwrap() {
1525 HatBackend::Named(name) => assert_eq!(name, "claude"),
1526 _ => panic!("Expected Named backend for planner"),
1527 }
1528
1529 let builder = config.hats.get("builder").unwrap();
1531 assert!(builder.backend.is_some());
1532 match builder.backend.as_ref().unwrap() {
1533 HatBackend::KiroAgent {
1534 backend_type,
1535 agent,
1536 } => {
1537 assert_eq!(backend_type, "kiro");
1538 assert_eq!(agent, "builder");
1539 }
1540 _ => panic!("Expected KiroAgent backend for builder"),
1541 }
1542
1543 let reviewer = config.hats.get("reviewer").unwrap();
1545 assert!(reviewer.backend.is_some());
1546 match reviewer.backend.as_ref().unwrap() {
1547 HatBackend::Custom { command, args } => {
1548 assert_eq!(command, "/usr/local/bin/custom-agent");
1549 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1550 }
1551 _ => panic!("Expected Custom backend for reviewer"),
1552 }
1553 assert_eq!(
1554 reviewer.default_publishes,
1555 Some("review.complete".to_string())
1556 );
1557 }
1558}