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; #[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 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 fs::create_dir_all(&cache_dir)?;
39
40 Ok(Cache { cache_dir })
41 }
42
43 fn current_timestamp() -> u64 {
45 SystemTime::now()
46 .duration_since(UNIX_EPOCH)
47 .unwrap()
48 .as_secs()
49 }
50
51 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 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 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 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 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 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 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 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 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 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 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 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 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}