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}