Skip to main content

dot_agent_core/channel/
channel_registry.rs

1//! Channel registry management
2//!
3//! Manages the list of registered Channels in ~/.dot-agent/channels.toml
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::{DotAgentError, Result};
11
12use super::types::{Channel, ChannelType};
13
14/// Channel registry configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ChannelRegistry {
17    /// Registered channels
18    pub channels: Vec<Channel>,
19}
20
21impl Default for ChannelRegistry {
22    fn default() -> Self {
23        Self::with_defaults()
24    }
25}
26
27impl ChannelRegistry {
28    const FILENAME: &'static str = "channels.toml";
29
30    /// Create registry with default channels (github-global, codex-skills)
31    pub fn with_defaults() -> Self {
32        Self {
33            channels: vec![Channel::github_global(), Channel::codex_skills()],
34        }
35    }
36
37    /// Load channel registry from base directory
38    pub fn load(base_dir: &Path) -> Result<Self> {
39        let path = base_dir.join(Self::FILENAME);
40        if !path.exists() {
41            return Ok(Self::with_defaults());
42        }
43
44        let content = fs::read_to_string(&path)?;
45        let mut registry: Self =
46            toml::from_str(&content).map_err(|e| DotAgentError::ConfigParseSimple {
47                message: e.to_string(),
48            })?;
49
50        // Ensure builtin channels are always present
51        if !registry
52            .channels
53            .iter()
54            .any(|c| c.builtin && c.name == "github")
55        {
56            registry.channels.insert(0, Channel::github_global());
57        }
58        if !registry
59            .channels
60            .iter()
61            .any(|c| c.builtin && c.name == "codex")
62        {
63            registry.channels.push(Channel::codex_skills());
64        }
65
66        Ok(registry)
67    }
68
69    /// Save channel registry to base directory
70    pub fn save(&self, base_dir: &Path) -> Result<()> {
71        let path = base_dir.join(Self::FILENAME);
72        fs::create_dir_all(base_dir)?;
73        let content =
74            toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParseSimple {
75                message: e.to_string(),
76            })?;
77        fs::write(path, content)?;
78        Ok(())
79    }
80
81    /// Add a channel
82    pub fn add(&mut self, channel: Channel) -> Result<()> {
83        if self.channels.iter().any(|c| c.name == channel.name) {
84            return Err(DotAgentError::ChannelAlreadyExists { name: channel.name });
85        }
86        self.channels.push(channel);
87        Ok(())
88    }
89
90    /// Remove a channel by name (cannot remove builtin channels)
91    pub fn remove(&mut self, name: &str) -> Result<Channel> {
92        let idx = self
93            .channels
94            .iter()
95            .position(|c| c.name == name)
96            .ok_or_else(|| DotAgentError::ChannelNotFound {
97                name: name.to_string(),
98            })?;
99
100        if self.channels[idx].builtin {
101            return Err(DotAgentError::CannotRemoveBuiltinChannel {
102                name: name.to_string(),
103            });
104        }
105
106        Ok(self.channels.remove(idx))
107    }
108
109    /// Get a channel by name
110    pub fn get(&self, name: &str) -> Option<&Channel> {
111        self.channels.iter().find(|c| c.name == name)
112    }
113
114    /// Get a mutable reference to a channel by name
115    pub fn get_mut(&mut self, name: &str) -> Option<&mut Channel> {
116        self.channels.iter_mut().find(|c| c.name == name)
117    }
118
119    /// List all channels
120    pub fn list(&self) -> &[Channel] {
121        &self.channels
122    }
123
124    /// List enabled channels
125    pub fn list_enabled(&self) -> Vec<&Channel> {
126        self.channels.iter().filter(|c| c.enabled).collect()
127    }
128
129    /// List searchable channels (enabled and supports search)
130    pub fn list_searchable(&self) -> Vec<&Channel> {
131        self.channels
132            .iter()
133            .filter(|c| c.enabled && c.is_searchable())
134            .collect()
135    }
136
137    /// List channels by type
138    pub fn list_by_type(&self, channel_type: ChannelType) -> Vec<&Channel> {
139        self.channels
140            .iter()
141            .filter(|c| c.channel_type == channel_type)
142            .collect()
143    }
144
145    /// Enable a channel
146    pub fn enable(&mut self, name: &str) -> Result<()> {
147        let channel = self
148            .get_mut(name)
149            .ok_or_else(|| DotAgentError::ChannelNotFound {
150                name: name.to_string(),
151            })?;
152        channel.enabled = true;
153        Ok(())
154    }
155
156    /// Disable a channel
157    pub fn disable(&mut self, name: &str) -> Result<()> {
158        let channel = self
159            .get_mut(name)
160            .ok_or_else(|| DotAgentError::ChannelNotFound {
161                name: name.to_string(),
162            })?;
163        channel.enabled = false;
164        Ok(())
165    }
166
167    /// Get cache directory for a channel
168    pub fn cache_dir(base_dir: &Path, channel_name: &str) -> PathBuf {
169        base_dir.join("cache").join("channels").join(channel_name)
170    }
171
172    /// Check if a channel exists
173    pub fn contains(&self, name: &str) -> bool {
174        self.channels.iter().any(|c| c.name == name)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use tempfile::TempDir;
182
183    #[test]
184    fn registry_with_defaults() {
185        let registry = ChannelRegistry::with_defaults();
186        assert_eq!(registry.channels.len(), 2);
187        assert_eq!(registry.channels[0].name, "github");
188        assert!(registry.channels[0].builtin);
189        assert_eq!(registry.channels[1].name, "codex");
190        assert!(registry.channels[1].builtin);
191    }
192
193    #[test]
194    fn registry_add_remove() {
195        let mut registry = ChannelRegistry::with_defaults();
196        let initial_count = registry.channels.len(); // 2 (github + codex)
197
198        let channel = Channel::from_url("test", "https://github.com/test/awesome");
199        registry.add(channel).unwrap();
200        assert_eq!(registry.channels.len(), initial_count + 1);
201
202        // Duplicate should fail
203        let channel2 = Channel::from_url("test", "https://github.com/other/awesome");
204        assert!(registry.add(channel2).is_err());
205
206        // Remove
207        let removed = registry.remove("test").unwrap();
208        assert_eq!(removed.name, "test");
209        assert_eq!(registry.channels.len(), initial_count);
210
211        // Cannot remove builtin
212        assert!(registry.remove("github").is_err());
213        assert!(registry.remove("codex").is_err());
214    }
215
216    #[test]
217    fn registry_enable_disable() {
218        let mut registry = ChannelRegistry::with_defaults();
219        registry
220            .add(Channel::from_url("test", "https://example.com"))
221            .unwrap();
222
223        assert!(registry.get("test").unwrap().enabled);
224
225        registry.disable("test").unwrap();
226        assert!(!registry.get("test").unwrap().enabled);
227
228        registry.enable("test").unwrap();
229        assert!(registry.get("test").unwrap().enabled);
230    }
231
232    #[test]
233    fn registry_save_load() {
234        let temp = TempDir::new().unwrap();
235        let base = temp.path();
236
237        let mut registry = ChannelRegistry::with_defaults();
238        let initial_count = registry.channels.len(); // 2 (github + codex)
239        registry
240            .add(Channel::from_url("test", "https://github.com/test/awesome"))
241            .unwrap();
242        registry.save(base).unwrap();
243
244        let loaded = ChannelRegistry::load(base).unwrap();
245        assert_eq!(loaded.channels.len(), initial_count + 1);
246        assert!(loaded.get("github").is_some());
247        assert!(loaded.get("codex").is_some());
248        assert!(loaded.get("test").is_some());
249    }
250
251    #[test]
252    fn list_searchable() {
253        let mut registry = ChannelRegistry::with_defaults();
254        registry
255            .add(Channel::from_url(
256                "awesome1",
257                "https://github.com/test/awesome-list",
258            ))
259            .unwrap();
260        registry
261            .add(Channel::from_url(
262                "direct1",
263                "https://github.com/test/dotfiles",
264            ))
265            .unwrap();
266
267        let searchable = registry.list_searchable();
268        // github-global, codex (marketplace), and awesome1 are searchable
269        assert_eq!(searchable.len(), 3);
270    }
271
272    #[test]
273    fn list_by_type() {
274        let mut registry = ChannelRegistry::with_defaults();
275        registry
276            .add(Channel::from_url(
277                "awesome1",
278                "https://github.com/test/awesome-list",
279            ))
280            .unwrap();
281        registry
282            .add(Channel::from_hub("hub1", "official", "test"))
283            .unwrap();
284
285        let github = registry.list_by_type(ChannelType::GitHubGlobal);
286        assert_eq!(github.len(), 1);
287
288        let awesome = registry.list_by_type(ChannelType::AwesomeList);
289        assert_eq!(awesome.len(), 1);
290
291        let hub = registry.list_by_type(ChannelType::Hub);
292        assert_eq!(hub.len(), 1);
293    }
294}