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}