1use crate::error::MpsError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6fn default_true() -> bool {
7 true
8}
9fn five() -> u64 {
10 5
11}
12fn sixty() -> u64 {
13 60
14}
15fn seven() -> u64 {
16 7
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct NotifyConfig {
23 #[serde(default = "default_true")]
24 pub enabled: bool,
25 #[serde(default = "five")]
27 pub window_minutes: u64,
28 #[serde(default = "default_true")]
30 pub notify_open_tasks: bool,
31 #[serde(default)]
33 pub open_task_tags: Vec<String>,
34 #[serde(default)]
36 pub task_notify_at: Option<String>,
37 #[serde(default = "sixty")]
39 pub task_cooldown_minutes: u64,
40 #[serde(default = "seven")]
42 pub overdue_days: u64,
43}
44
45impl Default for NotifyConfig {
46 fn default() -> Self {
47 Self {
48 enabled: true,
49 window_minutes: 5,
50 notify_open_tasks: true,
51 open_task_tags: Vec::new(),
52 task_notify_at: None,
53 task_cooldown_minutes: 60,
54 overdue_days: 7,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct MetaShared {
66 #[serde(default)]
67 pub version: u32,
68 #[serde(default)]
69 pub config: MetaConfig,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct ChatMetaConfig {
76 #[serde(default)]
77 pub url: Option<String>,
78 #[serde(default)]
79 pub model: Option<String>,
80 #[serde(default)]
81 pub context_days: Option<u64>,
82 #[serde(default)]
83 pub stream: Option<bool>,
84 #[serde(default)]
85 pub connect_timeout_secs: Option<u64>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct MetaConfig {
90 #[serde(default)]
91 pub type_aliases: HashMap<String, String>,
92 #[serde(default)]
93 pub command_aliases: HashMap<String, String>,
94 #[serde(default)]
95 pub default_command: Option<String>,
96 #[serde(default)]
97 pub custom_tags: Vec<String>,
98 #[serde(default)]
99 pub notify: NotifyConfig,
100 #[serde(default)]
101 pub chat: ChatMetaConfig,
102}
103
104impl MetaShared {
105 pub fn filename() -> &'static str {
106 ".mps.meta"
107 }
108
109 pub fn path(storage_dir: &Path) -> PathBuf {
110 storage_dir.join(Self::filename())
111 }
112
113 pub fn load(storage_dir: &Path) -> Self {
115 let path = Self::path(storage_dir);
116 if !path.exists() {
117 return Self::default();
118 }
119 std::fs::read_to_string(&path)
120 .ok()
121 .and_then(|s| serde_json::from_str(&s).ok())
122 .unwrap_or_default()
123 }
124
125 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
127 let path = Self::path(storage_dir);
128 let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
129 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
130 std::fs::rename(&tmp, &path)?;
131 Ok(())
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct MetaLocal {
141 #[serde(default)]
142 pub version: u32,
143 #[serde(default)]
145 pub notified: HashMap<String, i64>,
146 #[serde(default)]
148 pub last_task_date: Option<String>,
149 #[serde(default)]
150 pub cache: MetaCache,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct MetaCache {
161 #[serde(default)]
163 pub files_snapshot: Vec<(String, u64)>,
164 #[serde(default)]
166 pub tag_counts: HashMap<String, u32>,
167}
168
169impl MetaCache {
170 pub fn is_valid(&self, current_files: &[(String, u64)]) -> bool {
173 self.files_snapshot == current_files
174 }
175
176 pub fn build_snapshot(files: &std::path::Path) -> Vec<(String, u64)> {
178 let re = crate::constants::mps_file_name_regexp();
179 let mut snapshot: Vec<(String, u64)> = std::fs::read_dir(files)
180 .map(|rd| {
181 rd.filter_map(|e| e.ok())
182 .filter_map(|e| {
183 let name = e.file_name().to_string_lossy().into_owned();
184 if !re.is_match(&name) {
185 return None;
186 }
187 let size = e.metadata().ok()?.len();
188 Some((name, size))
189 })
190 .collect()
191 })
192 .unwrap_or_default();
193 snapshot.sort_by(|a, b| a.0.cmp(&b.0));
194 snapshot
195 }
196}
197
198impl MetaLocal {
199 pub fn filename() -> &'static str {
200 ".mps.local"
201 }
202
203 pub fn path(storage_dir: &Path) -> PathBuf {
204 storage_dir.join(Self::filename())
205 }
206
207 pub fn load(storage_dir: &Path) -> Self {
209 let path = Self::path(storage_dir);
210 if !path.exists() {
211 return Self::default();
212 }
213 std::fs::read_to_string(&path)
214 .ok()
215 .and_then(|s| serde_json::from_str(&s).ok())
216 .unwrap_or_default()
217 }
218
219 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
222 let path = Self::path(storage_dir);
223 let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
224 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
225 std::fs::rename(&tmp, &path)?;
226 ensure_local_gitignored(storage_dir);
227 Ok(())
228 }
229
230 pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
232 if let Some(&ts) = self.notified.get(epoch_ref) {
233 let now = chrono::Local::now().timestamp();
234 return now - ts < cooldown_secs;
235 }
236 false
237 }
238
239 pub fn mark_notified(&mut self, epoch_ref: &str) {
241 self.notified
242 .insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
243 }
244
245 pub fn task_briefing_done_today(&self) -> bool {
247 let today = chrono::Local::now()
248 .date_naive()
249 .format("%Y-%m-%d")
250 .to_string();
251 self.last_task_date.as_deref() == Some(today.as_str())
252 }
253
254 pub fn mark_task_briefing(&mut self) {
256 self.last_task_date = Some(
257 chrono::Local::now()
258 .date_naive()
259 .format("%Y-%m-%d")
260 .to_string(),
261 );
262 }
263
264 pub fn prune(&mut self, before_ts: i64) {
266 self.notified.retain(|_, &mut ts| ts >= before_ts);
267 }
268}
269
270fn ensure_local_gitignored(storage_dir: &Path) {
273 let gitignore = storage_dir.join(".gitignore");
274 let entry = ".mps.local";
275 let already_present = std::fs::read_to_string(&gitignore)
276 .map(|s| s.lines().any(|l| l.trim() == entry))
277 .unwrap_or(false);
278 if !already_present {
279 use std::io::Write;
280 if let Ok(mut f) = std::fs::OpenOptions::new()
281 .create(true)
282 .append(true)
283 .open(&gitignore)
284 {
285 let _ = writeln!(f, "{}", entry);
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
295 let dir = tempfile::tempdir().unwrap();
296 let p = dir.path().to_path_buf();
297 (dir, p)
298 }
299
300 #[test]
301 fn test_meta_shared_load_absent_returns_default() {
302 let (_dir, p) = tmp_store();
303 let m = MetaShared::load(&p);
304 assert_eq!(m.version, 0);
305 assert!(m.config.type_aliases.is_empty());
306 }
307
308 #[test]
309 fn test_meta_shared_save_load_roundtrip() {
310 let (_dir, p) = tmp_store();
311 let mut m = MetaShared {
312 version: 1,
313 ..Default::default()
314 };
315 m.config.default_command = Some("list".into());
316 m.config.custom_tags = vec!["work".into(), "personal".into()];
317 m.config.type_aliases.insert("t".into(), "task".into());
318 m.save(&p).unwrap();
319
320 let m2 = MetaShared::load(&p);
321 assert_eq!(m2.version, 1);
322 assert_eq!(m2.config.default_command.as_deref(), Some("list"));
323 assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
324 assert_eq!(
325 m2.config.type_aliases.get("t").map(|s| s.as_str()),
326 Some("task")
327 );
328 }
329
330 #[test]
331 fn test_meta_local_load_absent_returns_default() {
332 let (_dir, p) = tmp_store();
333 let m = MetaLocal::load(&p);
334 assert!(m.notified.is_empty());
335 assert!(m.last_task_date.is_none());
336 }
337
338 #[test]
339 fn test_meta_local_save_load_roundtrip() {
340 let (_dir, p) = tmp_store();
341 let mut m = MetaLocal::default();
342 m.notified.insert("20260524.1".into(), 1000000);
343 m.last_task_date = Some("2026-05-24".into());
344 m.save(&p).unwrap();
345
346 let m2 = MetaLocal::load(&p);
347 assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
348 assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
349 }
350
351 #[test]
352 fn test_was_notified_within_cooldown() {
353 let mut m = MetaLocal::default();
354 let now = chrono::Local::now().timestamp();
355 m.notified.insert("ref-1".into(), now - 30); assert!(m.was_notified("ref-1", 60)); assert!(!m.was_notified("ref-1", 20)); }
359
360 #[test]
361 fn test_was_notified_absent_returns_false() {
362 let m = MetaLocal::default();
363 assert!(!m.was_notified("no-such-ref", 3600));
364 }
365
366 #[test]
367 fn test_mark_notified_sets_timestamp() {
368 let mut m = MetaLocal::default();
369 assert!(!m.was_notified("ref-2", 60));
370 m.mark_notified("ref-2");
371 assert!(m.was_notified("ref-2", 60));
372 }
373
374 #[test]
375 fn test_task_briefing_done_today_false_by_default() {
376 let m = MetaLocal::default();
377 assert!(!m.task_briefing_done_today());
378 }
379
380 #[test]
381 fn test_mark_task_briefing_sets_today() {
382 let mut m = MetaLocal::default();
383 m.mark_task_briefing();
384 assert!(m.task_briefing_done_today());
385 }
386
387 #[test]
388 fn test_task_briefing_done_yesterday_is_false() {
389 let m = MetaLocal {
390 last_task_date: Some("2000-01-01".into()),
391 ..Default::default()
392 }; assert!(!m.task_briefing_done_today());
394 }
395
396 #[test]
397 fn test_prune_removes_old_entries() {
398 let mut m = MetaLocal::default();
399 m.notified.insert("old".into(), 1000);
400 m.notified.insert("new".into(), 9_000_000_000);
401 m.prune(5_000_000);
402 assert!(!m.notified.contains_key("old"));
403 assert!(m.notified.contains_key("new"));
404 }
405
406 #[test]
407 fn test_prune_keeps_entries_at_boundary() {
408 let mut m = MetaLocal::default();
409 m.notified.insert("exact".into(), 5000);
410 m.prune(5000); assert!(m.notified.contains_key("exact"));
412 }
413
414 #[test]
417 fn test_save_auto_adds_mps_local_to_gitignore() {
418 let (_dir, p) = tmp_store();
419 let m = MetaLocal::default();
420 m.save(&p).unwrap();
421
422 let gitignore = p.join(".gitignore");
423 assert!(gitignore.exists(), ".gitignore must be created");
424 let content = std::fs::read_to_string(&gitignore).unwrap();
425 assert!(
426 content.lines().any(|l| l.trim() == ".mps.local"),
427 ".gitignore must contain .mps.local"
428 );
429 }
430
431 #[test]
432 fn test_save_does_not_duplicate_gitignore_entry() {
433 let (_dir, p) = tmp_store();
434 std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
436 let m = MetaLocal::default();
437 m.save(&p).unwrap();
438 m.save(&p).unwrap(); let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
441 let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
442 assert_eq!(count, 1, "entry must not be duplicated");
443 }
444
445 #[test]
448 fn test_meta_shared_corrupted_json_returns_default() {
449 let (_dir, p) = tmp_store();
450 std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
451 let m = MetaShared::load(&p);
452 assert_eq!(m.version, 0);
454 assert!(m.config.type_aliases.is_empty());
455 }
456
457 #[test]
458 fn test_meta_local_corrupted_json_returns_default() {
459 let (_dir, p) = tmp_store();
460 std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
461 let m = MetaLocal::load(&p);
462 assert!(m.notified.is_empty());
463 }
464
465 #[test]
468 fn test_was_notified_exactly_at_cooldown_is_fresh() {
469 let mut m = MetaLocal::default();
470 let now = chrono::Local::now().timestamp();
471 m.notified.insert("ref".into(), now - 60);
473 assert!(
475 !m.was_notified("ref", 60),
476 "at exactly cooldown, entry is expired"
477 );
478 m.notified.insert("ref".into(), now - 59);
480 assert!(
481 m.was_notified("ref", 60),
482 "59s ago with 60s cooldown → fresh"
483 );
484 }
485
486 #[test]
489 fn test_meta_shared_atomic_save_no_tmp_file_left() {
490 let (_dir, p) = tmp_store();
491 let m = MetaShared::default();
492 m.save(&p).unwrap();
493 let leftovers: Vec<_> = std::fs::read_dir(&p)
495 .unwrap()
496 .filter_map(|e| e.ok())
497 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
498 .collect();
499 assert!(
500 leftovers.is_empty(),
501 "no .tmp files should remain after save"
502 );
503 }
504
505 #[test]
506 fn test_meta_local_atomic_save_no_tmp_file_left() {
507 let (_dir, p) = tmp_store();
508 let m = MetaLocal::default();
509 m.save(&p).unwrap();
510 let leftovers: Vec<_> = std::fs::read_dir(&p)
511 .unwrap()
512 .filter_map(|e| e.ok())
513 .filter(|e| {
514 let n = e.file_name();
515 let s = n.to_string_lossy();
516 s.contains(".tmp") && s.contains("local")
517 })
518 .collect();
519 assert!(
520 leftovers.is_empty(),
521 "no .tmp files should remain after save"
522 );
523 }
524
525 #[test]
528 fn test_meta_cache_valid_when_snapshot_matches() {
529 let mut cache = crate::meta::MetaCache::default();
530 let snapshot = vec![("20260101.1000.mps".into(), 42u64)];
531 cache.files_snapshot = snapshot.clone();
532 assert!(cache.is_valid(&snapshot));
533 }
534
535 #[test]
536 fn test_meta_cache_invalid_when_file_size_changed() {
537 let cache = crate::meta::MetaCache {
538 files_snapshot: vec![("20260101.1000.mps".into(), 42u64)],
539 ..Default::default()
540 };
541 let current = vec![("20260101.1000.mps".into(), 99u64)]; assert!(!cache.is_valid(¤t));
543 }
544
545 #[test]
546 fn test_meta_cache_invalid_when_file_added() {
547 let cache = crate::meta::MetaCache {
548 files_snapshot: vec![("20260101.1000.mps".into(), 42u64)],
549 ..Default::default()
550 };
551 let current = vec![
552 ("20260101.1000.mps".into(), 42u64),
553 ("20260102.1001.mps".into(), 10u64), ];
555 assert!(!cache.is_valid(¤t));
556 }
557
558 #[test]
559 fn test_meta_cache_invalid_when_empty_vs_nonempty() {
560 let cache = crate::meta::MetaCache::default(); let current = vec![("20260101.1000.mps".into(), 42u64)];
562 assert!(!cache.is_valid(¤t));
563 }
564
565 #[test]
566 fn test_meta_cache_valid_when_both_empty() {
567 let cache = crate::meta::MetaCache::default();
568 assert!(cache.is_valid(&[]));
569 }
570
571 #[test]
572 fn test_build_snapshot_returns_sorted_mps_files_only() {
573 let (_dir, p) = tmp_store();
574 std::fs::write(p.join("20260101.1000000000.mps"), "content-a").unwrap();
575 std::fs::write(p.join("20260102.1000000001.mps"), "content-b").unwrap();
576 std::fs::write(p.join("not-an-mps.txt"), "ignored").unwrap();
577
578 let snapshot = crate::meta::MetaCache::build_snapshot(&p);
579 assert_eq!(snapshot.len(), 2, "only .mps files");
580 assert_eq!(snapshot[0].0, "20260101.1000000000.mps");
581 assert_eq!(snapshot[1].0, "20260102.1000000001.mps");
582 assert_eq!(snapshot[0].1, b"content-a".len() as u64);
584 assert_eq!(snapshot[1].1, b"content-b".len() as u64);
585 }
586
587 #[test]
588 fn test_build_snapshot_empty_dir() {
589 let (_dir, p) = tmp_store();
590 let snapshot = crate::meta::MetaCache::build_snapshot(&p);
591 assert!(snapshot.is_empty());
592 }
593}