polymarket_api/
cache.rs

1use {
2    crate::error::{PolymarketError, Result},
3    serde::{Deserialize, Serialize},
4    std::{
5        fs,
6        path::{Path, PathBuf},
7        time::{SystemTime, UNIX_EPOCH},
8    },
9};
10
11/// Generic file-based cache for storing serializable data
12#[derive(Clone)]
13pub struct FileCache {
14    cache_dir: PathBuf,
15    default_ttl_seconds: Option<u64>,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19struct CacheEntry<T> {
20    data: T,
21    cached_at: u64,
22    ttl_seconds: Option<u64>,
23}
24
25impl FileCache {
26    /// Create a new FileCache with the given cache directory
27    pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
28        let cache_dir = cache_dir.as_ref().to_path_buf();
29        fs::create_dir_all(&cache_dir).map_err(|e| {
30            PolymarketError::InvalidData(format!("Failed to create cache directory: {}", e))
31        })?;
32
33        Ok(Self {
34            cache_dir,
35            default_ttl_seconds: None,
36        })
37    }
38
39    /// Set a default TTL for cached entries (in seconds)
40    pub fn with_default_ttl(mut self, ttl_seconds: u64) -> Self {
41        self.default_ttl_seconds = Some(ttl_seconds);
42        self
43    }
44
45    /// Get cached data by key
46    pub fn get<T>(&self, key: &str) -> Result<Option<T>>
47    where
48        T: for<'de> Deserialize<'de>,
49    {
50        let cache_file = self.cache_file_path(key);
51
52        if !cache_file.exists() {
53            return Ok(None);
54        }
55
56        let content = fs::read_to_string(&cache_file).map_err(|e| {
57            PolymarketError::InvalidData(format!("Failed to read cache file: {}", e))
58        })?;
59
60        let entry: CacheEntry<T> =
61            serde_json::from_str(&content).map_err(PolymarketError::Serialization)?;
62
63        // Check if entry has expired
64        if let Some(ttl) = entry.ttl_seconds {
65            let now = SystemTime::now()
66                .duration_since(UNIX_EPOCH)
67                .map_err(|e| PolymarketError::InvalidData(format!("System time error: {}", e)))?
68                .as_secs();
69
70            if now.saturating_sub(entry.cached_at) > ttl {
71                // Cache expired, remove file and return None
72                let _ = fs::remove_file(&cache_file);
73                return Ok(None);
74            }
75        }
76
77        Ok(Some(entry.data))
78    }
79
80    /// Store data in cache with the given key
81    pub fn set<T>(&self, key: &str, data: T) -> Result<()>
82    where
83        T: Serialize,
84    {
85        let cache_file = self.cache_file_path(key);
86
87        let cached_at = SystemTime::now()
88            .duration_since(UNIX_EPOCH)
89            .map_err(|e| PolymarketError::InvalidData(format!("System time error: {}", e)))?
90            .as_secs();
91
92        let entry = CacheEntry {
93            data,
94            cached_at,
95            ttl_seconds: self.default_ttl_seconds,
96        };
97
98        let json = serde_json::to_string_pretty(&entry).map_err(PolymarketError::Serialization)?;
99
100        // Write to temp file first, then rename (atomic operation)
101        let temp_file = cache_file.with_extension("tmp");
102        fs::write(&temp_file, json).map_err(|e| {
103            PolymarketError::InvalidData(format!("Failed to write cache file: {}", e))
104        })?;
105
106        fs::rename(&temp_file, &cache_file).map_err(|e| {
107            PolymarketError::InvalidData(format!("Failed to rename cache file: {}", e))
108        })?;
109
110        Ok(())
111    }
112
113    /// Remove a cached entry
114    pub fn remove(&self, key: &str) -> Result<()> {
115        let cache_file = self.cache_file_path(key);
116        if cache_file.exists() {
117            fs::remove_file(&cache_file).map_err(|e| {
118                PolymarketError::InvalidData(format!("Failed to remove cache file: {}", e))
119            })?;
120        }
121        Ok(())
122    }
123
124    /// Clear all cached entries
125    pub fn clear(&self) -> Result<()> {
126        if self.cache_dir.exists() {
127            for entry in fs::read_dir(&self.cache_dir).map_err(|e| {
128                PolymarketError::InvalidData(format!("Failed to read cache directory: {}", e))
129            })? {
130                let entry = entry.map_err(|e| {
131                    PolymarketError::InvalidData(format!("Failed to read directory entry: {}", e))
132                })?;
133                let path = entry.path();
134                if path.is_file() && path.extension().map(|e| e == "json").unwrap_or(false) {
135                    fs::remove_file(&path).map_err(|e| {
136                        PolymarketError::InvalidData(format!("Failed to remove cache file: {}", e))
137                    })?;
138                }
139            }
140        }
141        Ok(())
142    }
143
144    /// Get the cache directory path
145    pub fn cache_dir(&self) -> &Path {
146        &self.cache_dir
147    }
148
149    fn cache_file_path(&self, key: &str) -> PathBuf {
150        // Sanitize key to be filesystem-safe
151        let sanitized = key
152            .chars()
153            .map(|c| {
154                if c.is_alphanumeric() || c == '-' || c == '_' {
155                    c
156                } else {
157                    '_'
158                }
159            })
160            .collect::<String>();
161        self.cache_dir.join(format!("{}.json", sanitized))
162    }
163}
164
165/// Helper function to get default cache directory
166pub fn default_cache_dir() -> PathBuf {
167    dirs::cache_dir()
168        .map(|d| d.join("polymarket-api"))
169        .unwrap_or_else(|| PathBuf::from(".cache"))
170}