Skip to main content

agentctl/hub/
registry.rs

1use std::path::Path;
2
3use anyhow::{bail, Result};
4
5use crate::config::{Config, HubEntry};
6use crate::hub::cache::{self, Fetcher};
7
8pub enum HubKind {
9    Skill,
10    Doc,
11}
12
13fn hubs_mut<'a>(cfg: &'a mut Config, kind: &HubKind) -> &'a mut Vec<HubEntry> {
14    match kind {
15        HubKind::Skill => &mut cfg.skill_hubs,
16        HubKind::Doc => &mut cfg.doc_hubs,
17    }
18}
19
20pub fn add(
21    config_path: &Path,
22    kind: HubKind,
23    id: &str,
24    index_url: &str,
25    git_url: Option<&str>,
26) -> Result<()> {
27    let mut cfg = Config::load_from(config_path)?;
28    let hubs = hubs_mut(&mut cfg, &kind);
29    if hubs.iter().any(|h| h.id == id) {
30        bail!("hub '{id}' already exists");
31    }
32    hubs.push(HubEntry {
33        id: id.to_string(),
34        index_url: index_url.to_string(),
35        git_url: git_url.map(str::to_string),
36        enabled: true,
37        ttl_hours: 6,
38    });
39    cfg.save_to(config_path)
40}
41
42pub fn remove(config_path: &Path, id: &str) -> Result<()> {
43    let mut cfg = Config::load_from(config_path)?;
44    let before = cfg.skill_hubs.len() + cfg.doc_hubs.len();
45    cfg.skill_hubs.retain(|h| h.id != id);
46    cfg.doc_hubs.retain(|h| h.id != id);
47    if cfg.skill_hubs.len() + cfg.doc_hubs.len() == before {
48        bail!("hub '{id}' not found");
49    }
50    cfg.save_to(config_path)
51}
52
53pub fn set_enabled(config_path: &Path, id: &str, enabled: bool) -> Result<()> {
54    let mut cfg = Config::load_from(config_path)?;
55    let hub = cfg
56        .skill_hubs
57        .iter_mut()
58        .chain(cfg.doc_hubs.iter_mut())
59        .find(|h| h.id == id)
60        .ok_or_else(|| anyhow::anyhow!("hub '{id}' not found"))?;
61    hub.enabled = enabled;
62    cfg.save_to(config_path)
63}
64
65pub fn refresh_one(config_path: &Path, id: &str) -> Result<()> {
66    refresh_one_with(config_path, id, None, cache::http_fetch)
67}
68
69pub fn refresh_one_force(config_path: &Path, id: &str) -> Result<()> {
70    let cfg = Config::load_from(config_path)?;
71    let hub = cfg
72        .skill_hubs
73        .iter()
74        .chain(cfg.doc_hubs.iter())
75        .find(|h| h.id == id)
76        .ok_or_else(|| anyhow::anyhow!("hub '{id}' not found"))?;
77    let cache_dir = cache::cache_dir_for(&hub.id);
78    if cache_dir.exists() {
79        std::fs::remove_dir_all(&cache_dir)?;
80    }
81    cache::refresh_to(&cache_dir, &hub.index_url, cache::http_fetch)?;
82    Ok(())
83}
84
85pub fn refresh_one_with(
86    config_path: &Path,
87    id: &str,
88    cache_root: Option<&Path>,
89    fetcher: Fetcher,
90) -> Result<()> {
91    let cfg = Config::load_from(config_path)?;
92    let hub = cfg
93        .skill_hubs
94        .iter()
95        .chain(cfg.doc_hubs.iter())
96        .find(|h| h.id == id)
97        .ok_or_else(|| anyhow::anyhow!("hub '{id}' not found"))?;
98    let dir = cache_root
99        .map(|r| r.join(&hub.id))
100        .unwrap_or_else(|| cache::cache_dir_for(&hub.id));
101    cache::refresh_to(&dir, &hub.index_url, fetcher)?;
102    Ok(())
103}
104
105pub fn refresh_all(config_path: &Path) -> Result<()> {
106    refresh_all_with(config_path, None, cache::http_fetch)
107}
108
109pub fn refresh_all_force(config_path: &Path) -> Result<()> {
110    let cfg = Config::load_from(config_path)?;
111    for hub in cfg
112        .skill_hubs
113        .iter()
114        .chain(cfg.doc_hubs.iter())
115        .filter(|h| h.enabled)
116    {
117        let cache_dir = cache::cache_dir_for(&hub.id);
118        if cache_dir.exists() {
119            if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
120                eprintln!("warning: failed to remove cache for '{}': {e}", hub.id);
121                continue;
122            }
123        }
124        if let Err(e) = cache::refresh_to(&cache_dir, &hub.index_url, cache::http_fetch) {
125            eprintln!("warning: failed to refresh '{}': {e}", hub.id);
126        }
127    }
128    Ok(())
129}
130
131pub fn refresh_all_with(
132    config_path: &Path,
133    cache_root: Option<&Path>,
134    fetcher: Fetcher,
135) -> Result<()> {
136    let cfg = Config::load_from(config_path)?;
137    for hub in cfg
138        .skill_hubs
139        .iter()
140        .chain(cfg.doc_hubs.iter())
141        .filter(|h| h.enabled)
142    {
143        let dir = cache_root
144            .map(|r| r.join(&hub.id))
145            .unwrap_or_else(|| cache::cache_dir_for(&hub.id));
146        if let Err(e) = cache::refresh_to(&dir, &hub.index_url, fetcher) {
147            eprintln!("warning: failed to refresh '{}': {e}", hub.id);
148        }
149    }
150    Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::path::Path;
157    use tempfile::TempDir;
158
159    fn fixture(name: &str) -> std::path::PathBuf {
160        Path::new(env!("CARGO_MANIFEST_DIR"))
161            .join("tests/fixtures")
162            .join(name)
163    }
164
165    fn seed(dir: &TempDir) -> std::path::PathBuf {
166        let src = std::fs::read_to_string(fixture("config-valid.json")).unwrap();
167        let path = dir.path().join("config.json");
168        std::fs::write(&path, src).unwrap();
169        path
170    }
171
172    fn ok_fetcher(_url: &str) -> Result<String> {
173        Ok(std::fs::read_to_string(fixture("cache-index.json")).unwrap())
174    }
175
176    fn err_fetcher(_url: &str) -> Result<String> {
177        anyhow::bail!("mock fetch error")
178    }
179
180    fn hub_entry(id: &str, enabled: bool) -> HubEntry {
181        HubEntry {
182            id: id.to_string(),
183            index_url: "https://unused.example.com/index.json".to_string(),
184            git_url: None,
185            enabled,
186            ttl_hours: 6,
187        }
188    }
189
190    #[test]
191    fn add_new_hub() {
192        let dir = TempDir::new().unwrap();
193        let path = seed(&dir);
194        add(
195            &path,
196            HubKind::Skill,
197            "new-hub",
198            "https://example.com/index.json",
199            None,
200        )
201        .unwrap();
202        let cfg = Config::load_from(&path).unwrap();
203        assert_eq!(cfg.skill_hubs.len(), 2);
204        assert_eq!(cfg.skill_hubs[1].id, "new-hub");
205    }
206
207    #[test]
208    fn add_duplicate_errors() {
209        let dir = TempDir::new().unwrap();
210        let path = seed(&dir);
211        let err = add(
212            &path,
213            HubKind::Skill,
214            "agent-foundation",
215            "https://x.com",
216            None,
217        )
218        .unwrap_err();
219        assert!(err.to_string().contains("already exists"));
220    }
221
222    #[test]
223    fn remove_existing_hub() {
224        let dir = TempDir::new().unwrap();
225        let path = seed(&dir);
226        remove(&path, "agent-foundation").unwrap();
227        let cfg = Config::load_from(&path).unwrap();
228        assert!(cfg.skill_hubs.is_empty());
229    }
230
231    #[test]
232    fn remove_missing_errors() {
233        let dir = TempDir::new().unwrap();
234        let path = seed(&dir);
235        let err = remove(&path, "no-such-hub").unwrap_err();
236        assert!(err.to_string().contains("not found"));
237    }
238
239    #[test]
240    fn disable_and_enable_hub() {
241        let dir = TempDir::new().unwrap();
242        let path = seed(&dir);
243        set_enabled(&path, "agent-foundation", false).unwrap();
244        assert!(!Config::load_from(&path).unwrap().skill_hubs[0].enabled);
245        set_enabled(&path, "agent-foundation", true).unwrap();
246        assert!(Config::load_from(&path).unwrap().skill_hubs[0].enabled);
247    }
248
249    #[test]
250    fn refresh_one_missing_hub_errors() {
251        let dir = TempDir::new().unwrap();
252        let path = seed(&dir);
253        let err = refresh_one_with(&path, "no-such-hub", Some(dir.path()), ok_fetcher).unwrap_err();
254        assert!(err.to_string().contains("not found"));
255    }
256
257    #[test]
258    fn refresh_one_fetch_error_propagates() {
259        let dir = TempDir::new().unwrap();
260        let config_path = dir.path().join("config.json");
261        let cfg = Config {
262            skills_root: None,
263            skill_hubs: vec![hub_entry("test-hub", true)],
264            doc_hubs: vec![],
265        };
266        cfg.save_to(&config_path).unwrap();
267        let err =
268            refresh_one_with(&config_path, "test-hub", Some(dir.path()), err_fetcher).unwrap_err();
269        assert!(err.to_string().contains("mock fetch error"));
270    }
271
272    #[test]
273    fn refresh_one_writes_cache_on_success() {
274        let dir = TempDir::new().unwrap();
275        let config_path = dir.path().join("config.json");
276        let cache_root = dir.path().join("cache");
277        let cfg = Config {
278            skills_root: None,
279            skill_hubs: vec![hub_entry("test-hub", true)],
280            doc_hubs: vec![],
281        };
282        cfg.save_to(&config_path).unwrap();
283        refresh_one_with(&config_path, "test-hub", Some(&cache_root), ok_fetcher).unwrap();
284        assert!(cache_root.join("test-hub").join("index.json").exists());
285    }
286
287    #[test]
288    fn refresh_all_skips_disabled_hubs() {
289        let dir = TempDir::new().unwrap();
290        let config_path = dir.path().join("config.json");
291        let cache_root = dir.path().join("cache");
292        let cfg = Config {
293            skills_root: None,
294            skill_hubs: vec![
295                hub_entry("enabled-hub", true),
296                hub_entry("disabled-hub", false),
297            ],
298            doc_hubs: vec![],
299        };
300        cfg.save_to(&config_path).unwrap();
301        refresh_all_with(&config_path, Some(&cache_root), ok_fetcher).unwrap();
302        assert!(cache_root.join("enabled-hub").join("index.json").exists());
303        assert!(!cache_root.join("disabled-hub").exists());
304    }
305}