1use std::collections::HashMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::review_channel::{ReviewChannel, ReviewChannelError};
14use crate::session_channel::{SessionChannel, SessionChannelError};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ChannelCapabilitySet {
19 pub supports_review: bool,
21 pub supports_session: bool,
23 pub supports_notify: bool,
25 pub supports_rich_media: bool,
27 pub supports_threads: bool,
29}
30
31impl Default for ChannelCapabilitySet {
32 fn default() -> Self {
33 Self {
34 supports_review: true,
35 supports_session: true,
36 supports_notify: true,
37 supports_rich_media: false,
38 supports_threads: false,
39 }
40 }
41}
42
43pub trait ChannelFactory: Send + Sync {
49 fn channel_type(&self) -> &str;
51
52 fn build_review(
54 &self,
55 config: &serde_json::Value,
56 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError>;
57
58 fn build_session(
60 &self,
61 config: &serde_json::Value,
62 ) -> Result<Box<dyn SessionChannel>, SessionChannelError>;
63
64 fn capabilities(&self) -> ChannelCapabilitySet;
66}
67
68pub struct ChannelRegistry {
70 factories: HashMap<String, Box<dyn ChannelFactory>>,
71}
72
73impl ChannelRegistry {
74 pub fn new() -> Self {
76 Self {
77 factories: HashMap::new(),
78 }
79 }
80
81 pub fn register(&mut self, factory: Box<dyn ChannelFactory>) {
83 let name = factory.channel_type().to_string();
84 self.factories.insert(name, factory);
85 }
86
87 pub fn get(&self, channel_type: &str) -> Option<&dyn ChannelFactory> {
89 self.factories.get(channel_type).map(|f| f.as_ref())
90 }
91
92 pub fn channel_types(&self) -> Vec<&str> {
94 self.factories.keys().map(|k| k.as_str()).collect()
95 }
96
97 pub fn has_channel(&self, channel_type: &str) -> bool {
99 self.factories.contains_key(channel_type)
100 }
101
102 pub fn len(&self) -> usize {
104 self.factories.len()
105 }
106
107 pub fn is_empty(&self) -> bool {
109 self.factories.is_empty()
110 }
111
112 pub fn build_review_from_config(
114 &self,
115 route: &ChannelRouteConfig,
116 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
117 let factory = self.get(&route.channel_type).ok_or_else(|| {
118 ReviewChannelError::Other(format!(
119 "unknown channel type: '{}'. Registered: {:?}",
120 route.channel_type,
121 self.channel_types()
122 ))
123 })?;
124 factory.build_review(&route.config)
125 }
126
127 pub fn build_review_from_route(
132 &self,
133 route: &ReviewRouteConfig,
134 strategy: &crate::multi_channel::MultiChannelStrategy,
135 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
136 let configs = route.configs();
137 if configs.len() == 1 {
138 return self.build_review_from_config(configs[0]);
139 }
140 let mut channels: Vec<Box<dyn ReviewChannel>> = Vec::with_capacity(configs.len());
141 for config in configs {
142 channels.push(self.build_review_from_config(config)?);
143 }
144 Ok(Box::new(crate::multi_channel::MultiReviewChannel::new(
145 channels,
146 strategy.clone(),
147 )))
148 }
149
150 pub fn build_session_from_config(
152 &self,
153 route: &ChannelRouteConfig,
154 ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
155 let factory = self.get(&route.channel_type).ok_or_else(|| {
156 SessionChannelError::Other(format!(
157 "unknown channel type: '{}'. Registered: {:?}",
158 route.channel_type,
159 self.channel_types()
160 ))
161 })?;
162 factory.build_session(&route.config)
163 }
164}
165
166impl Default for ChannelRegistry {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ChannelRouteConfig {
175 #[serde(rename = "type")]
177 pub channel_type: String,
178 #[serde(flatten)]
180 pub config: serde_json::Value,
181}
182
183impl Default for ChannelRouteConfig {
184 fn default() -> Self {
185 Self {
186 channel_type: "terminal".to_string(),
187 config: serde_json::Value::Object(serde_json::Map::new()),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct NotifyRouteConfig {
195 #[serde(rename = "type")]
197 pub channel_type: String,
198 #[serde(default = "default_notify_level")]
200 pub level: String,
201 #[serde(flatten)]
203 pub config: serde_json::Value,
204}
205
206fn default_notify_level() -> String {
207 "info".to_string()
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(untagged)]
221pub enum ReviewRouteConfig {
222 Single(ChannelRouteConfig),
224 Multiple(Vec<ChannelRouteConfig>),
226}
227
228impl Default for ReviewRouteConfig {
229 fn default() -> Self {
230 Self::Single(ChannelRouteConfig::default())
231 }
232}
233
234impl ReviewRouteConfig {
235 pub fn configs(&self) -> Vec<&ChannelRouteConfig> {
237 match self {
238 Self::Single(c) => vec![c],
239 Self::Multiple(cs) => cs.iter().collect(),
240 }
241 }
242
243 pub fn is_multi(&self) -> bool {
245 matches!(self, Self::Multiple(cs) if cs.len() > 1)
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(untagged)]
252pub enum EscalationRouteConfig {
253 Single(ChannelRouteConfig),
255 Multiple(Vec<ChannelRouteConfig>),
257}
258
259impl EscalationRouteConfig {
260 pub fn configs(&self) -> Vec<&ChannelRouteConfig> {
262 match self {
263 Self::Single(c) => vec![c],
264 Self::Multiple(cs) => cs.iter().collect(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct ChannelRoutingConfig {
287 #[serde(default)]
290 pub review: ReviewRouteConfig,
291 #[serde(default)]
293 pub notify: Vec<NotifyRouteConfig>,
294 #[serde(default)]
296 pub session: ChannelRouteConfig,
297 #[serde(default)]
300 pub escalation: Option<EscalationRouteConfig>,
301 #[serde(default)]
303 pub default_agent: Option<String>,
304 #[serde(default)]
306 pub default_workflow: Option<String>,
307 #[serde(default)]
309 pub strategy: Option<String>,
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct TaConfig {
317 #[serde(default)]
319 pub channels: ChannelRoutingConfig,
320}
321
322pub fn load_config(project_root: &Path) -> TaConfig {
324 let config_path = project_root.join(".ta").join("config.yaml");
325 if !config_path.exists() {
326 return TaConfig::default();
327 }
328 match std::fs::read_to_string(&config_path) {
329 Ok(content) => serde_yaml::from_str(&content).unwrap_or_default(),
330 Err(_) => TaConfig::default(),
331 }
332}
333
334pub struct TerminalChannelFactory;
338
339impl ChannelFactory for TerminalChannelFactory {
340 fn channel_type(&self) -> &str {
341 "terminal"
342 }
343
344 fn build_review(
345 &self,
346 _config: &serde_json::Value,
347 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
348 Ok(Box::new(crate::terminal_channel::TerminalChannel::stdio()))
349 }
350
351 fn build_session(
352 &self,
353 _config: &serde_json::Value,
354 ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
355 Ok(Box::new(
356 crate::terminal_channel::TerminalSessionChannel::new(),
357 ))
358 }
359
360 fn capabilities(&self) -> ChannelCapabilitySet {
361 ChannelCapabilitySet {
362 supports_review: true,
363 supports_session: true,
364 supports_notify: true,
365 supports_rich_media: false,
366 supports_threads: false,
367 }
368 }
369}
370
371pub struct AutoApproveChannelFactory;
373
374impl ChannelFactory for AutoApproveChannelFactory {
375 fn channel_type(&self) -> &str {
376 "auto-approve"
377 }
378
379 fn build_review(
380 &self,
381 _config: &serde_json::Value,
382 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
383 Ok(Box::new(crate::terminal_channel::AutoApproveChannel::new()))
384 }
385
386 fn build_session(
387 &self,
388 _config: &serde_json::Value,
389 ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
390 Ok(Box::new(
392 crate::terminal_channel::TerminalSessionChannel::new(),
393 ))
394 }
395
396 fn capabilities(&self) -> ChannelCapabilitySet {
397 ChannelCapabilitySet {
398 supports_review: true,
399 supports_session: false,
400 supports_notify: false,
401 supports_rich_media: false,
402 supports_threads: false,
403 }
404 }
405}
406
407pub struct WebhookChannelFactory;
409
410impl ChannelFactory for WebhookChannelFactory {
411 fn channel_type(&self) -> &str {
412 "webhook"
413 }
414
415 fn build_review(
416 &self,
417 config: &serde_json::Value,
418 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
419 let endpoint = config
420 .get("endpoint")
421 .and_then(|v| v.as_str())
422 .ok_or_else(|| {
423 ReviewChannelError::Other("webhook requires 'endpoint' in config".into())
424 })?;
425 Ok(Box::new(crate::webhook_channel::WebhookChannel::new(
426 endpoint,
427 )))
428 }
429
430 fn build_session(
431 &self,
432 _config: &serde_json::Value,
433 ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
434 Err(SessionChannelError::Other(
436 "webhook does not support interactive sessions".into(),
437 ))
438 }
439
440 fn capabilities(&self) -> ChannelCapabilitySet {
441 ChannelCapabilitySet {
442 supports_review: true,
443 supports_session: false,
444 supports_notify: true,
445 supports_rich_media: false,
446 supports_threads: false,
447 }
448 }
449}
450
451pub fn default_registry() -> ChannelRegistry {
453 let mut registry = ChannelRegistry::new();
454 registry.register(Box::new(TerminalChannelFactory));
455 registry.register(Box::new(AutoApproveChannelFactory));
456 registry.register(Box::new(WebhookChannelFactory));
457 registry
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn default_registry_has_builtins() {
466 let registry = default_registry();
467 assert!(registry.has_channel("terminal"));
468 assert!(registry.has_channel("auto-approve"));
469 assert!(registry.has_channel("webhook"));
470 assert!(!registry.has_channel("slack"));
471 assert_eq!(registry.len(), 3);
472 }
473
474 #[test]
475 fn build_review_from_config() {
476 let registry = default_registry();
477 let route = ChannelRouteConfig {
478 channel_type: "terminal".into(),
479 config: serde_json::json!({}),
480 };
481 let channel = registry.build_review_from_config(&route);
482 assert!(channel.is_ok());
483 }
484
485 #[test]
486 fn build_review_unknown_type_errors() {
487 let registry = default_registry();
488 let route = ChannelRouteConfig {
489 channel_type: "slack".into(),
490 config: serde_json::json!({}),
491 };
492 let result = registry.build_review_from_config(&route);
493 assert!(result.is_err());
494 }
495
496 #[test]
497 fn channel_routing_config_single_review() {
498 let yaml = r#"
499review:
500 type: terminal
501notify:
502 - type: terminal
503 - type: webhook
504 endpoint: "/tmp/notify"
505 level: warning
506session:
507 type: terminal
508escalation:
509 type: webhook
510 endpoint: "/tmp/escalate"
511default_agent: claude-code
512"#;
513 let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
514 let review_configs = config.review.configs();
515 assert_eq!(review_configs.len(), 1);
516 assert_eq!(review_configs[0].channel_type, "terminal");
517 assert!(!config.review.is_multi());
518 assert_eq!(config.notify.len(), 2);
519 assert_eq!(config.notify[1].channel_type, "webhook");
520 assert_eq!(config.notify[1].level, "warning");
521 assert!(config.escalation.is_some());
522 assert_eq!(config.default_agent.as_deref(), Some("claude-code"));
523 }
524
525 #[test]
526 fn channel_routing_config_multi_review() {
527 let yaml = r#"
528review:
529 - type: terminal
530 - type: webhook
531 endpoint: "/tmp/review"
532session:
533 type: terminal
534strategy: first_response
535"#;
536 let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
537 let review_configs = config.review.configs();
538 assert_eq!(review_configs.len(), 2);
539 assert_eq!(review_configs[0].channel_type, "terminal");
540 assert_eq!(review_configs[1].channel_type, "webhook");
541 assert!(config.review.is_multi());
542 assert_eq!(config.strategy.as_deref(), Some("first_response"));
543 }
544
545 #[test]
546 fn channel_routing_config_multi_escalation() {
547 let yaml = r#"
548review:
549 type: terminal
550session:
551 type: terminal
552escalation:
553 - type: webhook
554 endpoint: "/tmp/esc1"
555 - type: webhook
556 endpoint: "/tmp/esc2"
557"#;
558 let config: ChannelRoutingConfig = serde_yaml::from_str(yaml).unwrap();
559 let esc = config.escalation.unwrap();
560 assert_eq!(esc.configs().len(), 2);
561 }
562
563 #[test]
564 fn ta_config_deserialization() {
565 let yaml = r#"
566channels:
567 review:
568 type: terminal
569 session:
570 type: terminal
571"#;
572 let config: TaConfig = serde_yaml::from_str(yaml).unwrap();
573 let review_configs = config.channels.review.configs();
574 assert_eq!(review_configs[0].channel_type, "terminal");
575 }
576
577 #[test]
578 fn default_ta_config() {
579 let config = TaConfig::default();
580 let review_configs = config.channels.review.configs();
581 assert_eq!(review_configs[0].channel_type, "terminal");
582 assert!(config.channels.notify.is_empty());
583 }
584
585 #[test]
586 fn build_multi_review_from_route_single() {
587 let registry = default_registry();
588 let route = ReviewRouteConfig::Single(ChannelRouteConfig {
589 channel_type: "terminal".into(),
590 config: serde_json::json!({}),
591 });
592 let strategy = crate::multi_channel::MultiChannelStrategy::FirstResponse;
593 let channel = registry.build_review_from_route(&route, &strategy);
594 assert!(channel.is_ok());
595 }
596
597 #[test]
598 fn build_multi_review_from_route_multiple() {
599 let registry = default_registry();
600 let route = ReviewRouteConfig::Multiple(vec![
601 ChannelRouteConfig {
602 channel_type: "auto-approve".into(),
603 config: serde_json::json!({}),
604 },
605 ChannelRouteConfig {
606 channel_type: "auto-approve".into(),
607 config: serde_json::json!({}),
608 },
609 ]);
610 let strategy = crate::multi_channel::MultiChannelStrategy::FirstResponse;
611 let channel = registry.build_review_from_route(&route, &strategy);
612 assert!(channel.is_ok());
613 }
614
615 #[test]
616 fn channel_capability_set_defaults() {
617 let caps = ChannelCapabilitySet::default();
618 assert!(caps.supports_review);
619 assert!(caps.supports_session);
620 assert!(caps.supports_notify);
621 assert!(!caps.supports_rich_media);
622 assert!(!caps.supports_threads);
623 }
624
625 #[test]
626 fn register_custom_factory() {
627 struct MockFactory;
628 impl ChannelFactory for MockFactory {
629 fn channel_type(&self) -> &str {
630 "mock"
631 }
632 fn build_review(
633 &self,
634 _config: &serde_json::Value,
635 ) -> Result<Box<dyn ReviewChannel>, ReviewChannelError> {
636 Ok(Box::new(crate::terminal_channel::AutoApproveChannel::new()))
637 }
638 fn build_session(
639 &self,
640 _config: &serde_json::Value,
641 ) -> Result<Box<dyn SessionChannel>, SessionChannelError> {
642 Err(SessionChannelError::Other("mock".into()))
643 }
644 fn capabilities(&self) -> ChannelCapabilitySet {
645 ChannelCapabilitySet::default()
646 }
647 }
648
649 let mut registry = default_registry();
650 registry.register(Box::new(MockFactory));
651 assert!(registry.has_channel("mock"));
652 assert_eq!(registry.len(), 4);
653 }
654
655 #[test]
656 fn load_config_missing_file() {
657 let dir = tempfile::TempDir::new().unwrap();
658 let config = load_config(dir.path());
659 assert_eq!(config.channels.review.configs()[0].channel_type, "terminal");
660 }
661
662 #[test]
663 fn webhook_factory_requires_endpoint() {
664 let registry = default_registry();
665 let route = ChannelRouteConfig {
666 channel_type: "webhook".into(),
667 config: serde_json::json!({}),
668 };
669 let result = registry.build_review_from_config(&route);
670 assert!(result.is_err());
671 }
672}