1use 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
20const 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]) }
44
45fn cache_path(key: &str) -> PathBuf {
46 cache_dir().join(format!("{key}.json"))
47}
48
49pub 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}