Skip to main content

agentctl/hub/
cache.rs

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}