Skip to main content

normalize_package_index/
cache.rs

1//! Local cache for package indices (offline support).
2
3use std::fs;
4use std::io::Read;
5use std::path::PathBuf;
6use std::time::{Duration, SystemTime};
7
8/// HTTP cache metadata for index files.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
10pub struct IndexMeta {
11    pub etag: Option<String>,
12    pub last_modified: Option<String>,
13    pub cached_at: u64, // Unix timestamp
14    pub url: String,
15}
16
17/// Get base cache directory: ~/.cache/moss
18fn cache_base() -> Option<PathBuf> {
19    let base = if let Ok(cache) = std::env::var("XDG_CACHE_HOME") {
20        PathBuf::from(cache)
21    } else if let Ok(home) = std::env::var("HOME") {
22        PathBuf::from(home).join(".cache")
23    } else if let Ok(home) = std::env::var("USERPROFILE") {
24        PathBuf::from(home).join(".cache")
25    } else {
26        return None;
27    };
28    Some(base.join("moss"))
29}
30
31/// Get index cache directory: ~/.cache/moss/indices
32fn index_cache_dir() -> Option<PathBuf> {
33    Some(cache_base()?.join("indices"))
34}
35
36/// Generate a safe cache key from a URL.
37#[allow(dead_code)]
38pub fn index_cache_key(url: &str) -> String {
39    // Use a simple hash-like approach: take the URL and make it filesystem-safe
40    url.chars()
41        .map(|c| match c {
42            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
43            _ => '_',
44        })
45        .collect()
46}
47
48/// Get paths for cached index data and metadata.
49fn index_paths(ecosystem: &str, name: &str) -> Option<(PathBuf, PathBuf)> {
50    let dir = index_cache_dir()?.join(ecosystem);
51    let data_path = dir.join(format!("{}.data", name));
52    let meta_path = dir.join(format!("{}.meta.json", name));
53    Some((data_path, meta_path))
54}
55
56/// Read index metadata (for staleness check).
57pub fn read_index_meta(ecosystem: &str, name: &str) -> Option<IndexMeta> {
58    let (_, meta_path) = index_paths(ecosystem, name)?;
59    let content = fs::read_to_string(&meta_path).ok()?;
60    serde_json::from_str(&content).ok()
61}
62
63/// Read cached index data.
64pub fn read_index(ecosystem: &str, name: &str) -> Option<Vec<u8>> {
65    let (data_path, _) = index_paths(ecosystem, name)?;
66    fs::read(&data_path).ok()
67}
68
69/// Read cached index if not expired.
70pub fn read_index_if_fresh(ecosystem: &str, name: &str, max_age: Duration) -> Option<Vec<u8>> {
71    let meta = read_index_meta(ecosystem, name)?;
72
73    let now = SystemTime::now()
74        .duration_since(SystemTime::UNIX_EPOCH)
75        .ok()?
76        .as_secs();
77
78    if now - meta.cached_at > max_age.as_secs() {
79        return None; // Expired
80    }
81
82    read_index(ecosystem, name)
83}
84
85/// Write index data and metadata to cache.
86pub fn write_index(
87    ecosystem: &str,
88    name: &str,
89    data: &[u8],
90    url: &str,
91    etag: Option<&str>,
92    last_modified: Option<&str>,
93) {
94    let Some((data_path, meta_path)) = index_paths(ecosystem, name) else {
95        return;
96    };
97
98    // Create directory if needed
99    if let Some(parent) = data_path.parent() {
100        let _ = fs::create_dir_all(parent);
101    }
102
103    // Write data
104    if fs::write(&data_path, data).is_err() {
105        return;
106    }
107
108    // Write metadata
109    let now = SystemTime::now()
110        .duration_since(SystemTime::UNIX_EPOCH)
111        .map(|d| d.as_secs())
112        .unwrap_or(0);
113
114    let meta = IndexMeta {
115        etag: etag.map(String::from),
116        last_modified: last_modified.map(String::from),
117        cached_at: now,
118        url: url.to_string(),
119    };
120
121    if let Ok(json) = serde_json::to_string_pretty(&meta) {
122        let _ = fs::write(&meta_path, json);
123    }
124}
125
126/// Fetch URL with cache support using conditional requests.
127/// Returns (data, was_cached) tuple.
128pub fn fetch_with_cache(
129    ecosystem: &str,
130    name: &str,
131    url: &str,
132    max_age: Duration,
133) -> Result<(Vec<u8>, bool), String> {
134    // Check if we have fresh cached data
135    if let Some(data) = read_index_if_fresh(ecosystem, name, max_age) {
136        return Ok((data, true));
137    }
138
139    // Check for stale cache to use conditional request
140    let meta = read_index_meta(ecosystem, name);
141
142    // Build request with conditional headers
143    let mut request = ureq::get(url);
144
145    if let Some(ref m) = meta {
146        if let Some(ref etag) = m.etag {
147            request = request.set("If-None-Match", etag);
148        }
149        if let Some(ref lm) = m.last_modified {
150            request = request.set("If-Modified-Since", lm);
151        }
152    }
153
154    let response = request.call().map_err(|e| e.to_string())?;
155
156    // 304 Not Modified - use cached data
157    if response.status() == 304 {
158        if let Some(data) = read_index(ecosystem, name) {
159            // Update cached_at timestamp
160            if let Some(m) = meta {
161                write_index(
162                    ecosystem,
163                    name,
164                    &data,
165                    url,
166                    m.etag.as_deref(),
167                    m.last_modified.as_deref(),
168                );
169            }
170            return Ok((data, true));
171        }
172    }
173
174    // Get response headers for caching
175    let etag = response.header("ETag").map(String::from);
176    let last_modified = response.header("Last-Modified").map(String::from);
177
178    // Read response body
179    let mut data = Vec::new();
180    response
181        .into_reader()
182        .read_to_end(&mut data)
183        .map_err(|e| e.to_string())?;
184
185    // Cache the response
186    write_index(
187        ecosystem,
188        name,
189        &data,
190        url,
191        etag.as_deref(),
192        last_modified.as_deref(),
193    );
194
195    Ok((data, false))
196}