things_mcp/core/
backup.rs1use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use rusqlite::{backup, Connection, OpenFlags};
10
11pub struct Backup {
12 pub path: PathBuf,
13 pub bytes: u64,
14}
15
16pub fn snapshot(live_db: &Path, backup_dir: &Path) -> anyhow::Result<Backup> {
17 std::fs::create_dir_all(backup_dir)?;
18 let stamp = utc_stamp();
19 let out = backup_dir.join(format!("things-{stamp}.sqlite"));
20
21 let src = Connection::open_with_flags(
22 live_db,
23 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
24 )?;
25 let mut dst = Connection::open(&out)?;
26 let backup = backup::Backup::new(&src, &mut dst)?;
27 backup.run_to_completion(64, std::time::Duration::from_millis(10), None)?;
28 drop(backup);
29 drop(dst);
30 drop(src);
31
32 let bytes = std::fs::metadata(&out)?.len();
33 Ok(Backup { path: out, bytes })
34}
35
36pub fn rotate(backup_dir: &Path, retain: u32) -> anyhow::Result<usize> {
37 if !backup_dir.exists() {
38 return Ok(0);
39 }
40 let mut entries: Vec<_> = std::fs::read_dir(backup_dir)?
41 .filter_map(Result::ok)
42 .filter(|e| {
43 e.file_name().to_string_lossy().starts_with("things-")
44 && e.file_name().to_string_lossy().ends_with(".sqlite")
45 })
46 .collect();
47 entries.sort_by_key(|e| e.file_name());
48 let drop_n = entries.len().saturating_sub(retain as usize);
50 for entry in entries.iter().take(drop_n) {
51 std::fs::remove_file(entry.path())?;
52 }
53 Ok(drop_n)
54}
55
56fn utc_stamp() -> String {
57 let secs = SystemTime::now()
58 .duration_since(UNIX_EPOCH)
59 .map(|d| d.as_secs())
60 .unwrap_or(0);
61 let (y, mo, d, h, mi, s) = unix_to_ymdhms(secs as i64);
62 format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
63}
64
65pub(crate) fn unix_to_ymdhms(unix_secs: i64) -> (i32, u32, u32, u32, u32, u32) {
68 let unix_secs = unix_secs.max(0) as u64;
69 let secs = unix_secs as i64;
70 let s = secs.rem_euclid(60) as u32;
71 let m_total = secs.div_euclid(60);
72 let mi = m_total.rem_euclid(60) as u32;
73 let h_total = m_total.div_euclid(60);
74 let h = h_total.rem_euclid(24) as u32;
75 let mut days = h_total.div_euclid(24);
76 let mut y: i32 = 1970;
78 loop {
79 let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
80 let year_days = if leap { 366 } else { 365 };
81 if days < year_days {
82 break;
83 }
84 days -= year_days;
85 y += 1;
86 }
87 let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
88 let months_len = [
89 31,
90 if leap { 29 } else { 28 },
91 31,
92 30,
93 31,
94 30,
95 31,
96 31,
97 30,
98 31,
99 30,
100 31,
101 ];
102 let mut mo: u32 = 1;
103 for len in months_len.iter() {
104 if days < *len {
105 break;
106 }
107 days -= *len;
108 mo += 1;
109 }
110 let d = (days as u32) + 1;
111 (y, mo, d, h, mi, s)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use tempfile::tempdir;
118
119 #[test]
120 fn snapshot_copies_a_sqlite_file() {
121 let tmp = tempdir().unwrap();
122 let src = tmp.path().join("live.sqlite");
123 {
124 let c = Connection::open(&src).unwrap();
125 c.execute_batch("CREATE TABLE t(x INTEGER); INSERT INTO t VALUES (42);")
126 .unwrap();
127 }
128 let dir = tmp.path().join("backups");
129 let backup = snapshot(&src, &dir).unwrap();
130 assert!(backup.path.exists());
131 assert!(backup.bytes > 0);
132 let c = Connection::open(&backup.path).unwrap();
134 let v: i64 = c.query_row("SELECT x FROM t", [], |r| r.get(0)).unwrap();
135 assert_eq!(v, 42);
136 }
137
138 #[test]
139 fn rotate_keeps_only_n_newest() {
140 let tmp = tempdir().unwrap();
141 for i in 0..5 {
142 let name = format!("things-2026010{i}-000000.sqlite");
143 std::fs::write(tmp.path().join(&name), b"").unwrap();
144 }
145 let dropped = rotate(tmp.path(), 2).unwrap();
146 assert_eq!(dropped, 3);
147 let kept: Vec<_> = std::fs::read_dir(tmp.path())
148 .unwrap()
149 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
150 .collect();
151 assert_eq!(kept.len(), 2);
152 assert!(kept.iter().any(|n| n.contains("20260103")));
153 assert!(kept.iter().any(|n| n.contains("20260104")));
154 }
155}