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)
31    pub fn with_defaults() -> Self {
32        Self {
33            channels: vec![Channel::github_global()],
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 github-global is 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
59        Ok(registry)
60    }
61
62    /// Save channel registry to base directory
63    pub fn save(&self, base_dir: &Path) -> Result<()> {
64        let path = base_dir.join(Self::FILENAME);
65        fs::create_dir_all(base_dir)?;
66        let content =
67            toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParseSimple {
68                message: e.to_string(),
69            })?;
70        fs::write(path, content)?;
71        Ok(())
72    }
73
74    /// Add a channel
75    pub fn add(&mut self, channel: Channel) -> Result<()> {
76        if self.channels.iter().any(|c| c.name == channel.name) {
77            return Err(DotAgentError::ChannelAlreadyExists { name: channel.name });
78        }
79        self.channels.push(channel);
80        Ok(())
81    }
82
83    /// Remove a channel by name (cannot remove builtin channels)
84    pub fn remove(&mut self, name: &str) -> Result<Channel> {
85        let idx = self
86            .channels
87            .iter()
88            .position(|c| c.name == name)
89            .ok_or_else(|| DotAgentError::ChannelNotFound {
90                name: name.to_string(),
91            })?;
92
93        if self.channels[idx].builtin {
94            return Err(DotAgentError::CannotRemoveBuiltinChannel {
95                name: name.to_string(),
96            });
97        }
98
99        Ok(self.channels.remove(idx))
100    }
101
102    /// Get a channel by name
103    pub fn get(&self, name: &str) -> Option<&Channel> {
104        self.channels.iter().find(|c| c.name == name)
105    }
106
107    /// Get a mutable reference to a channel by name
108    pub fn get_mut(&mut self, name: &str) -> Option<&mut Channel> {
109        self.channels.iter_mut().find(|c| c.name == name)
110    }
111
112    /// List all channels
113    pub fn list(&self) -> &[Channel] {
114        &self.channels
115    }
116
117    /// List enabled channels
118    pub fn list_enabled(&self) -> Vec<&Channel> {
119        self.channels.iter().filter(|c| c.enabled).collect()
120    }
121
122    /// List searchable channels (enabled and supports search)
123    pub fn list_searchable(&self) -> Vec<&Channel> {
124        self.channels
125            .iter()
126            .filter(|c| c.enabled && c.is_searchable())
127            .collect()
128    }
129
130    /// List channels by type
131    pub fn list_by_type(&self, channel_type: ChannelType) -> Vec<&Channel> {
132        self.channels
133            .iter()
134            .filter(|c| c.channel_type == channel_type)
135            .collect()
136    }
137
138    /// Enable a channel
139    pub fn enable(&mut self, name: &str) -> Result<()> {
140        let channel = self
141            .get_mut(name)
142            .ok_or_else(|| DotAgentError::ChannelNotFound {
143                name: name.to_string(),
144            })?;
145        channel.enabled = true;
146        Ok(())
147    }
148
149    /// Disable a channel
150    pub fn disable(&mut self, name: &str) -> Result<()> {
151        let channel = self
152            .get_mut(name)
153            .ok_or_else(|| DotAgentError::ChannelNotFound {
154                name: name.to_string(),
155            })?;
156        channel.enabled = false;
157        Ok(())
158    }
159
160    /// Get cache directory for a channel
161    pub fn cache_dir(base_dir: &Path, channel_name: &str) -> PathBuf {
162        base_dir.join("cache").join("channels").join(channel_name)
163    }
164
165    /// Check if a channel exists
166    pub fn contains(&self, name: &str) -> bool {
167        self.channels.iter().any(|c| c.name == name)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use tempfile::TempDir;
175
176    #[test]
177    fn registry_with_defaults() {
178        let registry = ChannelRegistry::with_defaults();
179        assert_eq!(registry.channels.len(), 1);
180        assert_eq!(registry.channels[0].name, "github");
181        assert!(registry.channels[0].builtin);
182    }
183
184    #[test]
185    fn registry_add_remove() {
186        let mut registry = ChannelRegistry::with_defaults();
187
188        let channel = Channel::from_url("test", "https://github.com/test/awesome");
189        registry.add(channel).unwrap();
190        assert_eq!(registry.channels.len(), 2);
191
192        // Duplicate should fail
193        let channel2 = Channel::from_url("test", "https://github.com/other/awesome");
194        assert!(registry.add(channel2).is_err());
195
196        // Remove
197        let removed = registry.remove("test").unwrap();
198        assert_eq!(removed.name, "test");
199        assert_eq!(registry.channels.len(), 1);
200
201        // Cannot remove builtin
202        assert!(registry.remove("github").is_err());
203    }
204
205    #[test]
206    fn registry_enable_disable() {
207        let mut registry = ChannelRegistry::with_defaults();
208        registry
209            .add(Channel::from_url("test", "https://example.com"))
210            .unwrap();
211
212        assert!(registry.get("test").unwrap().enabled);
213
214        registry.disable("test").unwrap();
215        assert!(!registry.get("test").unwrap().enabled);
216
217        registry.enable("test").unwrap();
218        assert!(registry.get("test").unwrap().enabled);
219    }
220
221    #[test]
222    fn registry_save_load() {
223        let temp = TempDir::new().unwrap();
224        let base = temp.path();
225
226        let mut registry = ChannelRegistry::with_defaults();
227        registry
228            .add(Channel::from_url("test", "https://github.com/test/awesome"))
229            .unwrap();
230        registry.save(base).unwrap();
231
232        let loaded = ChannelRegistry::load(base).unwrap();
233        assert_eq!(loaded.channels.len(), 2);
234        assert!(loaded.get("github").is_some());
235        assert!(loaded.get("test").is_some());
236    }
237
238    #[test]
239    fn list_searchable() {
240        let mut registry = ChannelRegistry::with_defaults();
241        registry
242            .add(Channel::from_url(
243                "awesome1",
244                "https://github.com/test/awesome-list",
245            ))
246            .unwrap();
247        registry
248            .add(Channel::from_url(
249                "direct1",
250                "https://github.com/test/dotfiles",
251            ))
252            .unwrap();
253
254        let searchable = registry.list_searchable();
255        // github-global and awesome1 are searchable
256        assert_eq!(searchable.len(), 2);
257    }
258
259    #[test]
260    fn list_by_type() {
261        let mut registry = ChannelRegistry::with_defaults();
262        registry
263            .add(Channel::from_url(
264                "awesome1",
265                "https://github.com/test/awesome-list",
266            ))
267            .unwrap();
268        registry
269            .add(Channel::from_hub("hub1", "official", "test"))
270            .unwrap();
271
272        let github = registry.list_by_type(ChannelType::GitHubGlobal);
273        assert_eq!(github.len(), 1);
274
275        let awesome = registry.list_by_type(ChannelType::AwesomeList);
276        assert_eq!(awesome.len(), 1);
277
278        let hub = registry.list_by_type(ChannelType::Hub);
279        assert_eq!(hub.len(), 1);
280    }
281}