use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::MpsError;
fn default_true() -> bool { true }
fn five() -> u64 { 5 }
fn sixty() -> u64 { 60 }
fn seven() -> u64 { 7 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "five")]
pub window_minutes: u64,
#[serde(default = "default_true")]
pub notify_open_tasks: bool,
#[serde(default)]
pub open_task_tags: Vec<String>,
#[serde(default)]
pub task_notify_at: Option<String>,
#[serde(default = "sixty")]
pub task_cooldown_minutes: u64,
#[serde(default = "seven")]
pub overdue_days: u64,
}
impl Default for NotifyConfig {
fn default() -> Self {
Self {
enabled: true,
window_minutes: 5,
notify_open_tasks: true,
open_task_tags: Vec::new(),
task_notify_at: None,
task_cooldown_minutes: 60,
overdue_days: 7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaShared {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub config: MetaConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaConfig {
#[serde(default)]
pub type_aliases: HashMap<String, String>,
#[serde(default)]
pub command_aliases: HashMap<String, String>,
#[serde(default)]
pub default_command: Option<String>,
#[serde(default)]
pub custom_tags: Vec<String>,
#[serde(default)]
pub notify: NotifyConfig,
}
impl MetaShared {
pub fn filename() -> &'static str { ".mps.meta" }
pub fn path(storage_dir: &Path) -> PathBuf {
storage_dir.join(Self::filename())
}
pub fn load(storage_dir: &Path) -> Self {
let path = Self::path(storage_dir);
if !path.exists() { return Self::default(); }
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
let path = Self::path(storage_dir);
let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaLocal {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub notified: HashMap<String, i64>,
#[serde(default)]
pub last_task_date: Option<String>,
#[serde(default)]
pub cache: MetaCache,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaCache {
pub tag_counts_date: Option<String>,
#[serde(default)]
pub tag_counts: HashMap<String, u32>,
}
impl MetaLocal {
pub fn filename() -> &'static str { ".mps.local" }
pub fn path(storage_dir: &Path) -> PathBuf {
storage_dir.join(Self::filename())
}
pub fn load(storage_dir: &Path) -> Self {
let path = Self::path(storage_dir);
if !path.exists() { return Self::default(); }
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
let path = Self::path(storage_dir);
let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
std::fs::rename(&tmp, &path)?;
ensure_local_gitignored(storage_dir);
Ok(())
}
pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
if let Some(&ts) = self.notified.get(epoch_ref) {
let now = chrono::Local::now().timestamp();
return now - ts < cooldown_secs;
}
false
}
pub fn mark_notified(&mut self, epoch_ref: &str) {
self.notified.insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
}
pub fn task_briefing_done_today(&self) -> bool {
let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
self.last_task_date.as_deref() == Some(today.as_str())
}
pub fn mark_task_briefing(&mut self) {
self.last_task_date = Some(chrono::Local::now().date_naive().format("%Y-%m-%d").to_string());
}
pub fn prune(&mut self, before_ts: i64) {
self.notified.retain(|_, &mut ts| ts >= before_ts);
}
}
fn ensure_local_gitignored(storage_dir: &Path) {
let gitignore = storage_dir.join(".gitignore");
let entry = ".mps.local";
let already_present = std::fs::read_to_string(&gitignore)
.map(|s| s.lines().any(|l| l.trim() == entry))
.unwrap_or(false);
if !already_present {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore) {
let _ = writeln!(f, "{}", entry);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().to_path_buf();
(dir, p)
}
#[test]
fn test_meta_shared_load_absent_returns_default() {
let (_dir, p) = tmp_store();
let m = MetaShared::load(&p);
assert_eq!(m.version, 0);
assert!(m.config.type_aliases.is_empty());
}
#[test]
fn test_meta_shared_save_load_roundtrip() {
let (_dir, p) = tmp_store();
let mut m = MetaShared::default();
m.version = 1;
m.config.default_command = Some("list".into());
m.config.custom_tags = vec!["work".into(), "personal".into()];
m.config.type_aliases.insert("t".into(), "task".into());
m.save(&p).unwrap();
let m2 = MetaShared::load(&p);
assert_eq!(m2.version, 1);
assert_eq!(m2.config.default_command.as_deref(), Some("list"));
assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
assert_eq!(m2.config.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
}
#[test]
fn test_meta_local_load_absent_returns_default() {
let (_dir, p) = tmp_store();
let m = MetaLocal::load(&p);
assert!(m.notified.is_empty());
assert!(m.last_task_date.is_none());
}
#[test]
fn test_meta_local_save_load_roundtrip() {
let (_dir, p) = tmp_store();
let mut m = MetaLocal::default();
m.notified.insert("20260524.1".into(), 1000000);
m.last_task_date = Some("2026-05-24".into());
m.save(&p).unwrap();
let m2 = MetaLocal::load(&p);
assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
}
#[test]
fn test_was_notified_within_cooldown() {
let mut m = MetaLocal::default();
let now = chrono::Local::now().timestamp();
m.notified.insert("ref-1".into(), now - 30); assert!(m.was_notified("ref-1", 60)); assert!(!m.was_notified("ref-1", 20)); }
#[test]
fn test_was_notified_absent_returns_false() {
let m = MetaLocal::default();
assert!(!m.was_notified("no-such-ref", 3600));
}
#[test]
fn test_mark_notified_sets_timestamp() {
let mut m = MetaLocal::default();
assert!(!m.was_notified("ref-2", 60));
m.mark_notified("ref-2");
assert!(m.was_notified("ref-2", 60));
}
#[test]
fn test_task_briefing_done_today_false_by_default() {
let m = MetaLocal::default();
assert!(!m.task_briefing_done_today());
}
#[test]
fn test_mark_task_briefing_sets_today() {
let mut m = MetaLocal::default();
m.mark_task_briefing();
assert!(m.task_briefing_done_today());
}
#[test]
fn test_task_briefing_done_yesterday_is_false() {
let mut m = MetaLocal::default();
m.last_task_date = Some("2000-01-01".into()); assert!(!m.task_briefing_done_today());
}
#[test]
fn test_prune_removes_old_entries() {
let mut m = MetaLocal::default();
m.notified.insert("old".into(), 1000);
m.notified.insert("new".into(), 9_000_000_000);
m.prune(5_000_000);
assert!(!m.notified.contains_key("old"));
assert!(m.notified.contains_key("new"));
}
#[test]
fn test_prune_keeps_entries_at_boundary() {
let mut m = MetaLocal::default();
m.notified.insert("exact".into(), 5000);
m.prune(5000); assert!(m.notified.contains_key("exact"));
}
#[test]
fn test_save_auto_adds_mps_local_to_gitignore() {
let (_dir, p) = tmp_store();
let m = MetaLocal::default();
m.save(&p).unwrap();
let gitignore = p.join(".gitignore");
assert!(gitignore.exists(), ".gitignore must be created");
let content = std::fs::read_to_string(&gitignore).unwrap();
assert!(content.lines().any(|l| l.trim() == ".mps.local"),
".gitignore must contain .mps.local");
}
#[test]
fn test_save_does_not_duplicate_gitignore_entry() {
let (_dir, p) = tmp_store();
std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
let m = MetaLocal::default();
m.save(&p).unwrap();
m.save(&p).unwrap();
let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
assert_eq!(count, 1, "entry must not be duplicated");
}
#[test]
fn test_meta_shared_corrupted_json_returns_default() {
let (_dir, p) = tmp_store();
std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
let m = MetaShared::load(&p);
assert_eq!(m.version, 0);
assert!(m.config.type_aliases.is_empty());
}
#[test]
fn test_meta_local_corrupted_json_returns_default() {
let (_dir, p) = tmp_store();
std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
let m = MetaLocal::load(&p);
assert!(m.notified.is_empty());
}
#[test]
fn test_was_notified_exactly_at_cooldown_is_fresh() {
let mut m = MetaLocal::default();
let now = chrono::Local::now().timestamp();
m.notified.insert("ref".into(), now - 60);
assert!(!m.was_notified("ref", 60), "at exactly cooldown, entry is expired");
m.notified.insert("ref".into(), now - 59);
assert!(m.was_notified("ref", 60), "59s ago with 60s cooldown → fresh");
}
#[test]
fn test_meta_shared_atomic_save_no_tmp_file_left() {
let (_dir, p) = tmp_store();
let m = MetaShared::default();
m.save(&p).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "no .tmp files should remain after save");
}
#[test]
fn test_meta_local_atomic_save_no_tmp_file_left() {
let (_dir, p) = tmp_store();
let m = MetaLocal::default();
m.save(&p).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name();
let s = n.to_string_lossy();
s.contains(".tmp") && s.contains("local")
})
.collect();
assert!(leftovers.is_empty(), "no .tmp files should remain after save");
}
}