Skip to main content

orch/channels/
routing.rs

1//! Channel-to-project routing map.
2//!
3//! Built at engine startup from per-project `.orch.yml` configs.
4//! Provides reverse lookup: (channel, topic/channel_id) → repo.
5
6use std::collections::HashMap;
7
8/// Per-project channel configuration, read from `.orch.yml`.
9#[derive(Debug, Clone, Default)]
10pub struct ProjectChannelConfig {
11    pub telegram_topic_id: Option<String>,
12    #[allow(dead_code)]
13    pub telegram_bot_token: Option<String>,
14    #[allow(dead_code)]
15    pub telegram_chat_id: Option<String>,
16    pub discord_channel_id: Option<String>,
17    #[allow(dead_code)]
18    pub discord_bot_token: Option<String>,
19    #[allow(dead_code)]
20    pub discord_guild_id: Option<String>,
21}
22
23/// Global channel configuration from `~/.orch/config.yml`.
24#[derive(Debug, Clone, Default)]
25pub struct GlobalChannelConfig {
26    pub telegram_general_topic_id: Option<String>,
27    pub discord_general_channel_id: Option<String>,
28    /// Telegram topic ID for the control session (from `control.channels.telegram.topic_id`).
29    pub control_telegram_topic_id: Option<String>,
30    /// Discord channel ID for the control session (from `control.channels.discord.channel_id`).
31    pub control_discord_channel_id: Option<String>,
32}
33
34/// Maps channel targets to projects and vice versa.
35pub struct ChannelRouter {
36    /// (channel_name, topic_or_channel_id) → repo
37    target_to_repo: HashMap<(String, String), String>,
38    /// repo → { channel_name: topic_or_channel_id }
39    repo_to_targets: HashMap<String, HashMap<String, String>>,
40    /// General channel IDs per channel type
41    general: HashMap<String, String>,
42    /// Control session channel IDs per channel type
43    control: HashMap<String, String>,
44    /// All configured project repos
45    project_list: Vec<String>,
46}
47
48impl ChannelRouter {
49    /// Build router from global config + per-project configs.
50    pub fn new(global: &GlobalChannelConfig, projects: &[(String, ProjectChannelConfig)]) -> Self {
51        let mut target_to_repo = HashMap::new();
52        let mut repo_to_targets: HashMap<String, HashMap<String, String>> = HashMap::new();
53        let mut general = HashMap::new();
54        let mut control = HashMap::new();
55        let mut project_list = Vec::new();
56
57        if let Some(ref id) = global.telegram_general_topic_id {
58            general.insert("telegram".to_string(), id.clone());
59        }
60        if let Some(ref id) = global.discord_general_channel_id {
61            general.insert("discord".to_string(), id.clone());
62        }
63        if let Some(ref id) = global.control_telegram_topic_id {
64            control.insert("telegram".to_string(), id.clone());
65        }
66        if let Some(ref id) = global.control_discord_channel_id {
67            control.insert("discord".to_string(), id.clone());
68        }
69
70        for (repo, config) in projects {
71            project_list.push(repo.clone());
72            let mut targets = HashMap::new();
73
74            if let Some(ref topic_id) = config.telegram_topic_id {
75                target_to_repo.insert(("telegram".to_string(), topic_id.clone()), repo.clone());
76                targets.insert("telegram".to_string(), topic_id.clone());
77            }
78
79            if let Some(ref channel_id) = config.discord_channel_id {
80                target_to_repo.insert(("discord".to_string(), channel_id.clone()), repo.clone());
81                targets.insert("discord".to_string(), channel_id.clone());
82            }
83
84            repo_to_targets.insert(repo.clone(), targets);
85        }
86
87        Self {
88            target_to_repo,
89            repo_to_targets,
90            general,
91            control,
92            project_list,
93        }
94    }
95
96    /// Resolve which project a channel message belongs to.
97    /// Returns None if the target isn't mapped (e.g., General channel).
98    pub fn resolve_project(&self, channel: &str, topic_or_channel_id: &str) -> Option<&str> {
99        self.target_to_repo
100            .get(&(channel.to_string(), topic_or_channel_id.to_string()))
101            .map(|s| s.as_str())
102    }
103
104    /// Check if a target is the General channel.
105    pub fn is_general(&self, channel: &str, topic_or_channel_id: &str) -> bool {
106        self.general
107            .get(channel)
108            .map(|id| id == topic_or_channel_id)
109            .unwrap_or(false)
110    }
111
112    /// Check if a target is the configured control session channel.
113    pub fn is_control_channel(&self, channel: &str, topic_or_channel_id: &str) -> bool {
114        self.control
115            .get(channel)
116            .map(|id| id == topic_or_channel_id)
117            .unwrap_or(false)
118    }
119
120    /// Get the target (topic/channel ID) for a project on a given channel.
121    pub fn target_for_project(&self, repo: &str, channel: &str) -> Option<&str> {
122        self.repo_to_targets
123            .get(repo)
124            .and_then(|targets| targets.get(channel))
125            .map(|s| s.as_str())
126    }
127
128    /// List all configured project repos.
129    pub fn projects(&self) -> &[String] {
130        &self.project_list
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn test_router() -> ChannelRouter {
139        let global = GlobalChannelConfig {
140            telegram_general_topic_id: Some("1".to_string()),
141            discord_general_channel_id: Some("9999".to_string()),
142            ..Default::default()
143        };
144        let projects = vec![
145            (
146                "owner/orch".to_string(),
147                ProjectChannelConfig {
148                    telegram_topic_id: Some("42".to_string()),
149                    discord_channel_id: Some("1111".to_string()),
150                    ..Default::default()
151                },
152            ),
153            (
154                "owner/bean".to_string(),
155                ProjectChannelConfig {
156                    telegram_topic_id: Some("87".to_string()),
157                    discord_channel_id: Some("2222".to_string()),
158                    ..Default::default()
159                },
160            ),
161        ];
162        ChannelRouter::new(&global, &projects)
163    }
164
165    #[test]
166    fn resolve_telegram_topic_to_project() {
167        let router = test_router();
168        assert_eq!(router.resolve_project("telegram", "42"), Some("owner/orch"));
169        assert_eq!(router.resolve_project("telegram", "87"), Some("owner/bean"));
170        assert_eq!(router.resolve_project("telegram", "99"), None);
171    }
172
173    #[test]
174    fn resolve_discord_channel_to_project() {
175        let router = test_router();
176        assert_eq!(
177            router.resolve_project("discord", "1111"),
178            Some("owner/orch")
179        );
180        assert_eq!(
181            router.resolve_project("discord", "2222"),
182            Some("owner/bean")
183        );
184    }
185
186    #[test]
187    fn is_general_channel() {
188        let router = test_router();
189        assert!(router.is_general("telegram", "1"));
190        assert!(router.is_general("discord", "9999"));
191        assert!(!router.is_general("telegram", "42"));
192        assert!(!router.is_general("discord", "1111"));
193    }
194
195    #[test]
196    fn is_control_channel() {
197        let global = GlobalChannelConfig {
198            telegram_general_topic_id: Some("1".to_string()),
199            discord_general_channel_id: Some("9999".to_string()),
200            control_telegram_topic_id: Some("55".to_string()),
201            control_discord_channel_id: Some("77".to_string()),
202        };
203        let router = ChannelRouter::new(&global, &[]);
204        assert!(router.is_control_channel("telegram", "55"));
205        assert!(router.is_control_channel("discord", "77"));
206        assert!(!router.is_control_channel("telegram", "1")); // general, not control
207        assert!(!router.is_control_channel("discord", "9999")); // general, not control
208        assert!(!router.is_control_channel("telegram", "42")); // project channel
209        assert!(!router.is_control_channel("slack", "55")); // unknown channel
210    }
211
212    #[test]
213    fn control_channel_unconfigured_returns_false() {
214        let router = test_router(); // no control fields set
215        assert!(!router.is_control_channel("telegram", "1"));
216        assert!(!router.is_control_channel("discord", "9999"));
217    }
218
219    #[test]
220    fn target_for_project() {
221        let router = test_router();
222        assert_eq!(
223            router.target_for_project("owner/orch", "telegram"),
224            Some("42")
225        );
226        assert_eq!(
227            router.target_for_project("owner/bean", "discord"),
228            Some("2222")
229        );
230        assert_eq!(router.target_for_project("owner/unknown", "telegram"), None);
231    }
232
233    #[test]
234    fn projects_list() {
235        let router = test_router();
236        assert_eq!(router.projects().len(), 2);
237        assert!(router.projects().contains(&"owner/orch".to_string()));
238        assert!(router.projects().contains(&"owner/bean".to_string()));
239    }
240
241    #[test]
242    fn unmapped_target_returns_none() {
243        let router = test_router();
244        assert_eq!(router.resolve_project("telegram", "1"), None); // General is not a project
245        assert_eq!(router.resolve_project("slack", "anything"), None);
246    }
247
248    #[test]
249    fn project_with_custom_config_doesnt_affect_others() {
250        let global = GlobalChannelConfig {
251            telegram_general_topic_id: Some("1".into()),
252            discord_general_channel_id: None,
253            ..Default::default()
254        };
255        let projects = vec![
256            (
257                "owner/orch".into(),
258                ProjectChannelConfig {
259                    telegram_topic_id: Some("42".into()),
260                    telegram_bot_token: Some("custom-token".into()),
261                    ..Default::default()
262                },
263            ),
264            (
265                "owner/bean".into(),
266                ProjectChannelConfig {
267                    telegram_topic_id: Some("87".into()),
268                    ..Default::default()
269                },
270            ),
271        ];
272        let router = ChannelRouter::new(&global, &projects);
273        // Custom bot_token on orch should not affect bean's routing
274        assert_eq!(router.resolve_project("telegram", "42"), Some("owner/orch"));
275        assert_eq!(router.resolve_project("telegram", "87"), Some("owner/bean"));
276        // Reverse lookup also works
277        assert_eq!(
278            router.target_for_project("owner/orch", "telegram"),
279            Some("42")
280        );
281        assert_eq!(
282            router.target_for_project("owner/bean", "telegram"),
283            Some("87")
284        );
285        // General still resolves
286        assert!(router.is_general("telegram", "1"));
287        // Projects list includes both
288        assert_eq!(router.projects().len(), 2);
289    }
290}