Skip to main content

skiff_cli/
cache.rs

1//! Atomic JSON cache under `$SKIFF_CACHE_DIR`.
2//!
3//! Writes use [`crate::fsutil::atomic_write_0600`]. Cache keys hash connection
4//! config (excluding filter/TTL fields).
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10use serde::Serialize;
11use serde_json::Value;
12use sha2::{Digest, Sha256};
13
14use crate::error::Result;
15use crate::fsutil::{atomic_write_0600, ensure_dir_0700};
16use crate::paths::{cache_dir, DEFAULT_CACHE_TTL};
17
18pub use crate::paths::DEFAULT_CACHE_TTL as DEFAULT_TTL;
19
20/// Fields that do not affect which tools/spec are returned (Python `cache_key_for`).
21const EXCLUDED_KEY_FIELDS: &[&str] = &["cache_ttl", "description", "include", "exclude", "methods"];
22
23pub fn cache_key_for(config: &Value) -> String {
24    let mut filtered = serde_json::Map::new();
25    if let Some(obj) = config.as_object() {
26        for (k, v) in obj {
27            if EXCLUDED_KEY_FIELDS.contains(&k.as_str()) {
28                continue;
29            }
30            let mut v = v.clone();
31            if k == "auth_headers" {
32                if let Some(arr) = v.as_array_mut() {
33                    arr.sort_by_key(|a| a.to_string());
34                }
35            }
36            filtered.insert(k.clone(), v);
37        }
38    }
39    let canonical = Value::Object(filtered);
40    let bytes = serde_json::to_vec(&canonical).unwrap_or_default();
41    let digest = Sha256::digest(bytes);
42    hex::encode(&digest[..8]) // 16 hex chars
43}
44
45fn cache_path(key: &str) -> PathBuf {
46    cache_dir().join(format!("{key}.json"))
47}
48
49/// Atomically write JSON to `path` (temp + rename) with mode `0o600`.
50pub fn atomic_write_json(path: &Path, data: &impl Serialize) -> Result<()> {
51    if let Some(parent) = path.parent() {
52        ensure_dir_0700(parent)?;
53    }
54    let text = serde_json::to_string(data)?;
55    atomic_write_0600(path, text.as_bytes())
56}
57
58pub fn save_cache(key: &str, data: &Value) -> Result<()> {
59    atomic_write_json(&cache_path(key), data)
60}
61
62pub fn load_cached(key: &str, ttl: u64) -> Result<Option<Value>> {
63    let path = cache_path(key);
64    if !path.exists() {
65        return Ok(None);
66    }
67    let meta = fs::metadata(&path)?;
68    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
69    let age = SystemTime::now()
70        .duration_since(modified)
71        .unwrap_or_default()
72        .as_secs();
73    if age >= ttl {
74        return Ok(None);
75    }
76    let text = fs::read_to_string(&path)?;
77    let data: Value = serde_json::from_str(&text)?;
78    Ok(Some(data))
79}
80
81pub fn load_cached_default(key: &str) -> Result<Option<Value>> {
82    load_cached(key, DEFAULT_CACHE_TTL)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
89    use serde_json::json;
90    use std::time::{Duration, SystemTime};
91    use tempfile::tempdir;
92
93    #[test]
94    fn cache_key_deterministic_and_excludes_ttl() {
95        let k1 = cache_key_for(&json!({"url": "https://example.com/spec.json"}));
96        let k2 = cache_key_for(&json!({"url": "https://example.com/spec.json"}));
97        assert_eq!(k1, k2);
98        assert_eq!(k1.len(), 16);
99
100        let a = cache_key_for(&json!({"url": "https://example.com/a.json"}));
101        let b = cache_key_for(&json!({"url": "https://example.com/b.json"}));
102        assert_ne!(a, b);
103
104        let t1 = cache_key_for(&json!({
105            "url": "https://example.com/spec.json",
106            "cache_ttl": 60
107        }));
108        let t2 = cache_key_for(&json!({
109            "url": "https://example.com/spec.json",
110            "cache_ttl": 3600
111        }));
112        assert_eq!(t1, t2);
113    }
114
115    #[test]
116    fn cache_roundtrip_and_expiry() {
117        let _guard = TEST_PATHS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
118        let dir = tempdir().unwrap();
119        set_cache_dir_override(Some(dir.path().to_path_buf()));
120
121        save_cache("testkey", &json!({"foo": "bar"})).unwrap();
122        assert_eq!(
123            load_cached("testkey", 3600).unwrap(),
124            Some(json!({"foo": "bar"}))
125        );
126        assert!(load_cached("nonexistent", 3600).unwrap().is_none());
127
128        save_cache("testkey", &json!({"a": 1})).unwrap();
129        let cache_file = dir.path().join("testkey.json");
130        let old = SystemTime::now() - Duration::from_secs(7200);
131        filetime_set(&cache_file, old);
132        assert!(load_cached("testkey", 3600).unwrap().is_none());
133
134        set_cache_dir_override(None);
135    }
136
137    fn filetime_set(path: &Path, when: SystemTime) {
138        let file = fs::File::open(path).unwrap();
139        file.set_modified(when).unwrap();
140    }
141}