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
322 #[test]
325 fn test_save_auto_adds_mps_local_to_gitignore() {
326 let (_dir, p) = tmp_store();
327 let m = MetaLocal::default();
328 m.save(&p).unwrap();
329
330 let gitignore = p.join(".gitignore");
331 assert!(gitignore.exists(), ".gitignore must be created");
332 let content = std::fs::read_to_string(&gitignore).unwrap();
333 assert!(content.lines().any(|l| l.trim() == ".mps.local"),
334 ".gitignore must contain .mps.local");
335 }
336
337 #[test]
338 fn test_save_does_not_duplicate_gitignore_entry() {
339 let (_dir, p) = tmp_store();
340 std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
342 let m = MetaLocal::default();
343 m.save(&p).unwrap();
344 m.save(&p).unwrap(); let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
347 let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
348 assert_eq!(count, 1, "entry must not be duplicated");
349 }
350
351 #[test]
354 fn test_meta_shared_corrupted_json_returns_default() {
355 let (_dir, p) = tmp_store();
356 std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
357 let m = MetaShared::load(&p);
358 assert_eq!(m.version, 0);
360 assert!(m.config.type_aliases.is_empty());
361 }
362
363 #[test]
364 fn test_meta_local_corrupted_json_returns_default() {
365 let (_dir, p) = tmp_store();
366 std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
367 let m = MetaLocal::load(&p);
368 assert!(m.notified.is_empty());
369 }
370
371 #[test]
374 fn test_was_notified_exactly_at_cooldown_is_fresh() {
375 let mut m = MetaLocal::default();
376 let now = chrono::Local::now().timestamp();
377 m.notified.insert("ref".into(), now - 60);
379 assert!(!m.was_notified("ref", 60), "at exactly cooldown, entry is expired");
381 m.notified.insert("ref".into(), now - 59);
383 assert!(m.was_notified("ref", 60), "59s ago with 60s cooldown → fresh");
384 }
385
386 #[test]
389 fn test_meta_shared_atomic_save_no_tmp_file_left() {
390 let (_dir, p) = tmp_store();
391 let m = MetaShared::default();
392 m.save(&p).unwrap();
393 let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
395 .filter_map(|e| e.ok())
396 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
397 .collect();
398 assert!(leftovers.is_empty(), "no .tmp files should remain after save");
399 }
400
401 #[test]
402 fn test_meta_local_atomic_save_no_tmp_file_left() {
403 let (_dir, p) = tmp_store();
404 let m = MetaLocal::default();
405 m.save(&p).unwrap();
406 let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
407 .filter_map(|e| e.ok())
408 .filter(|e| {
409 let n = e.file_name();
410 let s = n.to_string_lossy();
411 s.contains(".tmp") && s.contains("local")
412 })
413 .collect();
414 assert!(leftovers.is_empty(), "no .tmp files should remain after save");
415 }
416}