main
1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::github::Notification;
9
10/// Persistent state stored between runs
11#[derive(Debug, Serialize, Deserialize, Default)]
12pub struct Cache {
13 /// Timestamp of the last successful fetch
14 pub last_fetched: Option<DateTime<Utc>>,
15 /// Cached notifications
16 pub notifications: Vec<Notification>,
17 /// IDs of notifications we've already marked as done.
18 /// GitHub's API still returns "done" notifications with all=true,
19 /// so we track them to filter them out on re-fetch.
20 #[serde(default)]
21 pub done_ids: HashSet<String>,
22}
23
24impl Cache {
25 /// Default cache file location
26 pub fn default_path() -> PathBuf {
27 let cache_dir = dirs::cache_dir()
28 .unwrap_or_else(|| dirs::home_dir().unwrap().join(".cache"))
29 .join("github-notif-manager");
30 cache_dir.join("cache.json")
31 }
32
33 /// Load cache from disk, or return empty cache if not found
34 pub fn load(path: &Path) -> Result<Self> {
35 if !path.exists() {
36 return Ok(Self::default());
37 }
38
39 let content = fs::read_to_string(path)
40 .context(format!("Failed to read cache file: {:?}", path))?;
41
42 serde_json::from_str(&content)
43 .context("Failed to parse cache file (delete it to reset)")
44 }
45
46 /// Save cache to disk
47 pub fn save(&self, path: &Path) -> Result<()> {
48 if let Some(parent) = path.parent() {
49 fs::create_dir_all(parent)
50 .context(format!("Failed to create cache directory: {:?}", parent))?;
51 }
52
53 let content = serde_json::to_string_pretty(self)
54 .context("Failed to serialize cache")?;
55
56 fs::write(path, content)
57 .context(format!("Failed to write cache file: {:?}", path))?;
58
59 Ok(())
60 }
61
62 /// Merge new notifications into cache.
63 /// Filters out notifications we've already marked as done.
64 /// Returns (fresh_count, filtered_count) - number of notifications received and filtered.
65 pub fn merge(&mut self, fresh: Vec<Notification>, full_fetch: bool) -> (usize, usize) {
66 let fresh_count = fresh.len();
67
68 // Filter out notifications we've already marked as done
69 let fresh: Vec<Notification> = fresh
70 .into_iter()
71 .filter(|n| !self.done_ids.contains(&n.id))
72 .collect();
73
74 let filtered_count = fresh_count - fresh.len();
75
76 if full_fetch {
77 self.notifications = fresh;
78 } else {
79 for new_notif in fresh {
80 if let Some(existing) = self
81 .notifications
82 .iter_mut()
83 .find(|n| n.id == new_notif.id)
84 {
85 *existing = new_notif;
86 } else {
87 self.notifications.push(new_notif);
88 }
89 }
90 }
91
92 self.last_fetched = Some(Utc::now());
93 (fresh_count, filtered_count)
94 }
95
96 /// Mark a notification as done — removes from notifications and tracks ID
97 pub fn mark_done(&mut self, notification_id: &str) {
98 self.notifications.retain(|n| n.id != notification_id);
99 self.done_ids.insert(notification_id.to_string());
100 }
101
102 /// Get the `since` parameter for incremental fetching
103 pub fn since_param(&self) -> Option<String> {
104 self.last_fetched.map(|dt| dt.to_rfc3339())
105 }
106
107 /// Number of tracked done IDs
108 pub fn done_count(&self) -> usize {
109 self.done_ids.len()
110 }
111
112 /// Clear all done IDs from the cache
113 pub fn clear_done_ids(&mut self) {
114 self.done_ids.clear();
115 }
116}