Skip to main content

things_mcp/core/
backup.rs

1//! Startup backup of the live Things SQLite.
2//!
3//! Uses the SQLite online-backup API via `rusqlite::backup` rather than
4//! filesystem copy — safe to run while Things itself is writing.
5
6use 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    // oldest first; keep the newest `retain`
49    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
65/// Decompose Unix epoch seconds into (year, month, day, hour, minute, second).
66/// Pure UTC. Year ≥ 1970 assumed; negatives are clamped to 0 (i.e. 1970-01-01).
67pub(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    // 1970-01-01 was a Thursday; compute date by stepping years and months.
77    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        // verify the copy is a valid SQLite with the row
133        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}