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