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