1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use serde::{Deserialize, Serialize};
4use crate::error::MpsError;
5
6fn default_true() -> bool { true }
7fn five() -> u64 { 5 }
8fn sixty() -> u64 { 60 }
9fn seven() -> u64 { 7 }
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NotifyConfig {
15 #[serde(default = "default_true")]
16 pub enabled: bool,
17 #[serde(default = "five")]
19 pub window_minutes: u64,
20 #[serde(default = "default_true")]
22 pub notify_open_tasks: bool,
23 #[serde(default)]
25 pub open_task_tags: Vec<String>,
26 #[serde(default)]
28 pub task_notify_at: Option<String>,
29 #[serde(default = "sixty")]
31 pub task_cooldown_minutes: u64,
32 #[serde(default = "seven")]
34 pub overdue_days: u64,
35}
36
37impl Default for NotifyConfig {
38 fn default() -> Self {
39 Self {
40 enabled: true,
41 window_minutes: 5,
42 notify_open_tasks: true,
43 open_task_tags: Vec::new(),
44 task_notify_at: None,
45 task_cooldown_minutes: 60,
46 overdue_days: 7,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct MetaShared {
58 #[serde(default)]
59 pub version: u32,
60 #[serde(default)]
61 pub config: MetaConfig,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct MetaConfig {
66 #[serde(default)]
67 pub type_aliases: HashMap<String, String>,
68 #[serde(default)]
69 pub command_aliases: HashMap<String, String>,
70 #[serde(default)]
71 pub default_command: Option<String>,
72 #[serde(default)]
73 pub custom_tags: Vec<String>,
74 #[serde(default)]
75 pub notify: NotifyConfig,
76}
77
78impl MetaShared {
79 pub fn filename() -> &'static str { ".mps.meta" }
80
81 pub fn path(storage_dir: &Path) -> PathBuf {
82 storage_dir.join(Self::filename())
83 }
84
85 pub fn load(storage_dir: &Path) -> Self {
87 let path = Self::path(storage_dir);
88 if !path.exists() { return Self::default(); }
89 std::fs::read_to_string(&path)
90 .ok()
91 .and_then(|s| serde_json::from_str(&s).ok())
92 .unwrap_or_default()
93 }
94
95 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
97 let path = Self::path(storage_dir);
98 let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
99 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
100 std::fs::rename(&tmp, &path)?;
101 Ok(())
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct MetaLocal {
111 #[serde(default)]
112 pub version: u32,
113 #[serde(default)]
115 pub notified: HashMap<String, i64>,
116 #[serde(default)]
118 pub last_task_date: Option<String>,
119 #[serde(default)]
120 pub cache: MetaCache,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct MetaCache {
125 pub tag_counts_date: Option<String>,
126 #[serde(default)]
127 pub tag_counts: HashMap<String, u32>,
128}
129
130impl MetaLocal {
131 pub fn filename() -> &'static str { ".mps.local" }
132
133 pub fn path(storage_dir: &Path) -> PathBuf {
134 storage_dir.join(Self::filename())
135 }
136
137 pub fn load(storage_dir: &Path) -> Self {
139 let path = Self::path(storage_dir);
140 if !path.exists() { return Self::default(); }
141 std::fs::read_to_string(&path)
142 .ok()
143 .and_then(|s| serde_json::from_str(&s).ok())
144 .unwrap_or_default()
145 }
146
147 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
150 let path = Self::path(storage_dir);
151 let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
152 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
153 std::fs::rename(&tmp, &path)?;
154 ensure_local_gitignored(storage_dir);
155 Ok(())
156 }
157
158 pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
160 if let Some(&ts) = self.notified.get(epoch_ref) {
161 let now = chrono::Local::now().timestamp();
162 return now - ts < cooldown_secs;
163 }
164 false
165 }
166
167 pub fn mark_notified(&mut self, epoch_ref: &str) {
169 self.notified.insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
170 }
171
172 pub fn task_briefing_done_today(&self) -> bool {
174 let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
175 self.last_task_date.as_deref() == Some(today.as_str())
176 }
177
178 pub fn mark_task_briefing(&mut self) {
180 self.last_task_date = Some(chrono::Local::now().date_naive().format("%Y-%m-%d").to_string());
181 }
182
183 pub fn prune(&mut self, before_ts: i64) {
185 self.notified.retain(|_, &mut ts| ts >= before_ts);
186 }
187}
188
189fn ensure_local_gitignored(storage_dir: &Path) {
192 let gitignore = storage_dir.join(".gitignore");
193 let entry = ".mps.local";
194 let already_present = std::fs::read_to_string(&gitignore)
195 .map(|s| s.lines().any(|l| l.trim() == entry))
196 .unwrap_or(false);
197 if !already_present {
198 use std::io::Write;
199 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore) {
200 let _ = writeln!(f, "{}", entry);
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
210 let dir = tempfile::tempdir().unwrap();
211 let p = dir.path().to_path_buf();
212 (dir, p)
213 }
214
215 #[test]
216 fn test_meta_shared_load_absent_returns_default() {
217 let (_dir, p) = tmp_store();
218 let m = MetaShared::load(&p);
219 assert_eq!(m.version, 0);
220 assert!(m.config.type_aliases.is_empty());
221 }
222
223 #[test]
224 fn test_meta_shared_save_load_roundtrip() {
225 let (_dir, p) = tmp_store();
226 let mut m = MetaShared::default();
227 m.version = 1;
228 m.config.default_command = Some("list".into());
229 m.config.custom_tags = vec!["work".into(), "personal".into()];
230 m.config.type_aliases.insert("t".into(), "task".into());
231 m.save(&p).unwrap();
232
233 let m2 = MetaShared::load(&p);
234 assert_eq!(m2.version, 1);
235 assert_eq!(m2.config.default_command.as_deref(), Some("list"));
236 assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
237 assert_eq!(m2.config.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
238 }
239
240 #[test]
241 fn test_meta_local_load_absent_returns_default() {
242 let (_dir, p) = tmp_store();
243 let m = MetaLocal::load(&p);
244 assert!(m.notified.is_empty());
245 assert!(m.last_task_date.is_none());
246 }
247
248 #[test]
249 fn test_meta_local_save_load_roundtrip() {
250 let (_dir, p) = tmp_store();
251 let mut m = MetaLocal::default();
252 m.notified.insert("20260524.1".into(), 1000000);
253 m.last_task_date = Some("2026-05-24".into());
254 m.save(&p).unwrap();
255
256 let m2 = MetaLocal::load(&p);
257 assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
258 assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
259 }
260
261 #[test]
262 fn test_was_notified_within_cooldown() {
263 let mut m = MetaLocal::default();
264 let now = chrono::Local::now().timestamp();
265 m.notified.insert("ref-1".into(), now - 30); assert!(m.was_notified("ref-1", 60)); assert!(!m.was_notified("ref-1", 20)); }
269
270 #[test]
271 fn test_was_notified_absent_returns_false() {
272 let m = MetaLocal::default();
273 assert!(!m.was_notified("no-such-ref", 3600));
274 }
275
276 #[test]
277 fn test_mark_notified_sets_timestamp() {
278 let mut m = MetaLocal::default();
279 assert!(!m.was_notified("ref-2", 60));
280 m.mark_notified("ref-2");
281 assert!(m.was_notified("ref-2", 60));
282 }
283
284 #[test]
285 fn test_task_briefing_done_today_false_by_default() {
286 let m = MetaLocal::default();
287 assert!(!m.task_briefing_done_today());
288 }
289
290 #[test]
291 fn test_mark_task_briefing_sets_today() {
292 let mut m = MetaLocal::default();
293 m.mark_task_briefing();
294 assert!(m.task_briefing_done_today());
295 }
296
297 #[test]
298 fn test_task_briefing_done_yesterday_is_false() {
299 let mut m = MetaLocal::default();
300 m.last_task_date = Some("2000-01-01".into()); assert!(!m.task_briefing_done_today());
302 }
303
304 #[test]
305 fn test_prune_removes_old_entries() {
306 let mut m = MetaLocal::default();
307 m.notified.insert("old".into(), 1000);
308 m.notified.insert("new".into(), 9_000_000_000);
309 m.prune(5_000_000);
310 assert!(!m.notified.contains_key("old"));
311 assert!(m.notified.contains_key("new"));
312 }
313
314 #[test]
315 fn test_prune_keeps_entries_at_boundary() {
316 let mut m = MetaLocal::default();
317 m.notified.insert("exact".into(), 5000);
318 m.prune(5000); assert!(m.notified.contains_key("exact"));
320 }
321}