1#![allow(dead_code)]
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Duration, Utc};
7
8pub type Fetcher = fn(&str) -> Result<String>;
9
10pub fn cache_dir_for(hub_id: &str) -> PathBuf {
11 dirs::home_dir()
12 .unwrap_or_else(|| PathBuf::from("."))
13 .join(".agentctl")
14 .join("cache")
15 .join("hubs")
16 .join(hub_id)
17}
18
19pub fn get(hub_id: &str, index_url: &str, ttl_hours: u64) -> Result<String> {
20 get_from(
21 &cache_dir_for(hub_id),
22 index_url,
23 ttl_hours,
24 hub_id,
25 http_fetch,
26 )
27}
28
29pub fn refresh(hub_id: &str, index_url: &str) -> Result<String> {
30 refresh_to(&cache_dir_for(hub_id), index_url, http_fetch)
31}
32
33pub fn get_from(
34 dir: &Path,
35 index_url: &str,
36 ttl_hours: u64,
37 hub_id: &str,
38 fetcher: Fetcher,
39) -> Result<String> {
40 let index_path = dir.join("index.json");
41 let fetched_at_path = dir.join("fetched_at");
42
43 if !needs_refresh(&fetched_at_path, ttl_hours) {
44 return Ok(std::fs::read_to_string(&index_path)?);
45 }
46
47 match fetcher(index_url) {
48 Ok(body) => {
49 std::fs::create_dir_all(dir)?;
50 std::fs::write(&index_path, &body)?;
51 std::fs::write(&fetched_at_path, Utc::now().to_rfc3339())?;
52 Ok(body)
53 }
54 Err(e) => {
55 if index_path.exists() {
56 eprintln!("warning: fetch failed ({e}), using stale cache for '{hub_id}'");
57 Ok(std::fs::read_to_string(&index_path)?)
58 } else {
59 Err(e.context(format!("fetch failed and no cache exists for '{hub_id}'")))
60 }
61 }
62 }
63}
64
65pub fn refresh_to(dir: &Path, index_url: &str, fetcher: Fetcher) -> Result<String> {
66 let body = fetcher(index_url)?;
67 std::fs::create_dir_all(dir)?;
68 std::fs::write(dir.join("index.json"), &body)?;
69 std::fs::write(dir.join("fetched_at"), Utc::now().to_rfc3339())?;
70 Ok(body)
71}
72
73fn needs_refresh(fetched_at_path: &Path, ttl_hours: u64) -> bool {
74 let Ok(ts) = std::fs::read_to_string(fetched_at_path) else {
75 return true;
76 };
77 let Ok(fetched_at) = ts.trim().parse::<DateTime<Utc>>() else {
78 return true;
79 };
80 Utc::now() - fetched_at > Duration::hours(ttl_hours as i64)
81}
82
83pub fn http_fetch(url: &str) -> Result<String> {
84 ureq::get(url)
85 .call()
86 .context("HTTP request failed")?
87 .into_string()
88 .context("failed to read response body")
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::path::Path;
95 use tempfile::TempDir;
96
97 fn fixture(name: &str) -> PathBuf {
98 Path::new(env!("CARGO_MANIFEST_DIR"))
99 .join("tests/fixtures")
100 .join(name)
101 }
102
103 fn seed_cache(dir: &TempDir, fetched_at: &str) {
104 let index = std::fs::read_to_string(fixture("cache-index.json")).unwrap();
105 std::fs::write(dir.path().join("index.json"), index).unwrap();
106 std::fs::write(dir.path().join("fetched_at"), fetched_at).unwrap();
107 }
108
109 fn ok_fetcher(_url: &str) -> Result<String> {
110 Ok(std::fs::read_to_string(
111 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cache-index.json"),
112 )
113 .unwrap())
114 }
115
116 fn err_fetcher(_url: &str) -> Result<String> {
117 anyhow::bail!("HTTP request failed")
118 }
119
120 #[test]
121 fn fresh_cache_does_not_need_refresh() {
122 let dir = TempDir::new().unwrap();
123 std::fs::write(dir.path().join("fetched_at"), Utc::now().to_rfc3339()).unwrap();
124 assert!(!needs_refresh(&dir.path().join("fetched_at"), 6));
125 }
126
127 #[test]
128 fn missing_fetched_at_needs_refresh() {
129 let dir = TempDir::new().unwrap();
130 assert!(needs_refresh(&dir.path().join("fetched_at"), 6));
131 }
132
133 #[test]
134 fn expired_cache_needs_refresh() {
135 let dir = TempDir::new().unwrap();
136 let old = (Utc::now() - Duration::hours(7)).to_rfc3339();
137 std::fs::write(dir.path().join("fetched_at"), old).unwrap();
138 assert!(needs_refresh(&dir.path().join("fetched_at"), 6));
139 }
140
141 #[test]
142 fn corrupt_fetched_at_needs_refresh() {
143 let dir = TempDir::new().unwrap();
144 std::fs::write(dir.path().join("fetched_at"), "not-a-date").unwrap();
145 assert!(needs_refresh(&dir.path().join("fetched_at"), 6));
146 }
147
148 #[test]
149 fn get_from_returns_fresh_cache_without_fetching() {
150 let dir = TempDir::new().unwrap();
151 seed_cache(&dir, &Utc::now().to_rfc3339());
152 let result = get_from(dir.path(), "unused", 6, "test-hub", err_fetcher);
153 assert!(result.unwrap().contains("test-hub"));
154 }
155
156 #[test]
157 fn get_from_fetches_when_stale() {
158 let dir = TempDir::new().unwrap();
159 let old = (Utc::now() - Duration::hours(7)).to_rfc3339();
160 seed_cache(&dir, &old);
161 let result = get_from(dir.path(), "unused", 6, "test-hub", ok_fetcher);
162 assert!(result.unwrap().contains("test-hub"));
163 }
164
165 #[test]
166 fn get_from_uses_stale_cache_on_fetch_failure() {
167 let dir = TempDir::new().unwrap();
168 let old = (Utc::now() - Duration::hours(7)).to_rfc3339();
169 seed_cache(&dir, &old);
170 let result = get_from(dir.path(), "unused", 6, "test-hub", err_fetcher);
171 assert!(result.unwrap().contains("test-hub"));
172 }
173
174 #[test]
175 fn get_from_errors_when_no_cache_and_fetch_fails() {
176 let dir = TempDir::new().unwrap();
177 let result = get_from(dir.path(), "unused", 6, "test-hub", err_fetcher);
178 assert!(result.is_err());
179 }
180
181 #[test]
182 fn refresh_to_writes_cache_on_success() {
183 let dir = TempDir::new().unwrap();
184 refresh_to(dir.path(), "unused", ok_fetcher).unwrap();
185 assert!(dir.path().join("index.json").exists());
186 assert!(dir.path().join("fetched_at").exists());
187 }
188
189 #[test]
190 fn refresh_to_errors_on_fetch_failure() {
191 let dir = TempDir::new().unwrap();
192 assert!(refresh_to(dir.path(), "unused", err_fetcher).is_err());
193 }
194}