bbc_news_cli/
cache.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::app::NewsStory;
8
9const CACHE_EXPIRY_SECS: u64 = 900; // 15 minutes
10
11#[derive(Serialize, Deserialize)]
12struct CachedFeed {
13    stories: Vec<NewsStory>,
14    timestamp: u64,
15    feed_url: String,
16}
17
18#[derive(Serialize, Deserialize)]
19struct CachedArticle {
20    content: String,
21    timestamp: u64,
22    url: String,
23}
24
25pub struct Cache {
26    cache_dir: PathBuf,
27}
28
29impl Cache {
30    pub fn new() -> Result<Self> {
31        // Use ~/.bbcli/cache for consistency with config location
32        let home = dirs::home_dir()
33            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
34
35        let cache_dir = home.join(".bbcli").join("cache");
36
37        // Create cache directory if it doesn't exist
38        fs::create_dir_all(&cache_dir)?;
39
40        Ok(Cache { cache_dir })
41    }
42
43    /// Get current Unix timestamp
44    fn current_timestamp() -> u64 {
45        SystemTime::now()
46            .duration_since(UNIX_EPOCH)
47            .unwrap()
48            .as_secs()
49    }
50
51    /// Get cache file path for a feed
52    fn feed_cache_path(&self, feed_url: &str) -> PathBuf {
53        let hash = Self::hash_string(feed_url);
54        self.cache_dir.join(format!("feed_{}.bin", hash))
55    }
56
57    /// Get cache file path for an article
58    fn article_cache_path(&self, article_url: &str) -> PathBuf {
59        let hash = Self::hash_string(article_url);
60        self.cache_dir.join(format!("article_{}.bin", hash))
61    }
62
63    /// Simple hash function for URLs
64    fn hash_string(s: &str) -> String {
65        use std::collections::hash_map::DefaultHasher;
66        use std::hash::{Hash, Hasher};
67
68        let mut hasher = DefaultHasher::new();
69        s.hash(&mut hasher);
70        format!("{:x}", hasher.finish())
71    }
72
73    /// Save feed to cache
74    pub fn save_feed(&self, feed_url: &str, stories: &[NewsStory]) -> Result<()> {
75        let cached_feed = CachedFeed {
76            stories: stories.to_vec(),
77            timestamp: Self::current_timestamp(),
78            feed_url: feed_url.to_string(),
79        };
80
81        let path = self.feed_cache_path(feed_url);
82        let encoded = bincode::serialize(&cached_feed)?;
83        fs::write(path, encoded)?;
84
85        Ok(())
86    }
87
88    /// Load feed from cache if not expired
89    pub fn load_feed(&self, feed_url: &str) -> Option<Vec<NewsStory>> {
90        let path = self.feed_cache_path(feed_url);
91
92        if !path.exists() {
93            return None;
94        }
95
96        let data = fs::read(&path).ok()?;
97        let cached_feed: CachedFeed = bincode::deserialize(&data).ok()?;
98
99        // Check if cache is expired
100        let age = Self::current_timestamp() - cached_feed.timestamp;
101        if age > CACHE_EXPIRY_SECS {
102            return None;
103        }
104
105        Some(cached_feed.stories)
106    }
107
108    /// Load feed from cache regardless of expiry (for offline mode)
109    pub fn load_feed_offline(&self, feed_url: &str) -> Option<Vec<NewsStory>> {
110        let path = self.feed_cache_path(feed_url);
111
112        if !path.exists() {
113            return None;
114        }
115
116        let data = fs::read(&path).ok()?;
117        let cached_feed: CachedFeed = bincode::deserialize(&data).ok()?;
118
119        Some(cached_feed.stories)
120    }
121
122    /// Save article content to cache
123    pub fn save_article(&self, article_url: &str, content: &str) -> Result<()> {
124        let cached_article = CachedArticle {
125            content: content.to_string(),
126            timestamp: Self::current_timestamp(),
127            url: article_url.to_string(),
128        };
129
130        let path = self.article_cache_path(article_url);
131        let encoded = bincode::serialize(&cached_article)?;
132        fs::write(path, encoded)?;
133
134        Ok(())
135    }
136
137    /// Load article from cache if not expired
138    pub fn load_article(&self, article_url: &str) -> Option<String> {
139        let path = self.article_cache_path(article_url);
140
141        if !path.exists() {
142            return None;
143        }
144
145        let data = fs::read(&path).ok()?;
146        let cached_article: CachedArticle = bincode::deserialize(&data).ok()?;
147
148        // Check if cache is expired (articles cache for longer - 1 hour)
149        let age = Self::current_timestamp() - cached_article.timestamp;
150        if age > 3600 {
151            return None;
152        }
153
154        Some(cached_article.content)
155    }
156
157    /// Load article from cache regardless of expiry (for offline mode)
158    pub fn load_article_offline(&self, article_url: &str) -> Option<String> {
159        let path = self.article_cache_path(article_url);
160
161        if !path.exists() {
162            return None;
163        }
164
165        let data = fs::read(&path).ok()?;
166        let cached_article: CachedArticle = bincode::deserialize(&data).ok()?;
167
168        Some(cached_article.content)
169    }
170
171    /// Get cache age in seconds for a feed
172    pub fn get_feed_age(&self, feed_url: &str) -> Option<u64> {
173        let path = self.feed_cache_path(feed_url);
174
175        if !path.exists() {
176            return None;
177        }
178
179        let data = fs::read(&path).ok()?;
180        let cached_feed: CachedFeed = bincode::deserialize(&data).ok()?;
181
182        Some(Self::current_timestamp() - cached_feed.timestamp)
183    }
184
185    /// Clear all cache
186    pub fn clear_all(&self) -> Result<()> {
187        if self.cache_dir.exists() {
188            fs::remove_dir_all(&self.cache_dir)?;
189            fs::create_dir_all(&self.cache_dir)?;
190        }
191        Ok(())
192    }
193}