1use crate::Db;
2use rusqlite::Connection;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7pub const KEEP_DAYS: i64 = 14;
8
9const POLL_SECS: u64 = 60;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Kept {
13 pub path: PathBuf,
14 pub bytes: u64,
15}
16
17#[derive(Debug, Clone, Default)]
18pub struct Status {
19 pub queue_depth: usize,
20 pub last_error: Option<String>,
21 pub last_run_unix: Option<i64>,
22 pub next_run_unix: Option<i64>,
23 pub running: bool,
24 pub progress: f32,
25 pub last_elapsed: Option<Duration>,
26 pub last_changed: Option<usize>,
27 pub last_bytes: Option<u64>,
28 pub unconfigured: bool,
29 pub detail: Option<String>,
30 pub rate_per_s: Option<f32>,
31}
32
33#[derive(Clone)]
34pub struct Handle {
35 status: Arc<Mutex<Status>>,
36 nudge: std::sync::mpsc::Sender<()>,
37}
38
39impl Handle {
40 pub fn status(&self) -> Status {
41 self.status
42 .lock()
43 .unwrap_or_else(|poison| poison.into_inner())
44 .clone()
45 }
46
47 pub fn backup_now(&self) {
48 let _ = self.nudge.send(());
49 }
50}
51
52pub fn keep(connection: &Connection, dir: &Path, stem: &str) -> Result<Kept, String> {
53 std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
54 let stamp = format_stamp(now_unix());
55 let final_name = format!("{stem}-{stamp}.db.gz");
56 let plain_path = dir.join(format!("{final_name}.plain"));
57 let part_path = dir.join(format!("{final_name}.part"));
58 let final_path = dir.join(&final_name);
59
60 backup_into(connection, &plain_path).map_err(|error| error.to_string())?;
61 crate::archive::gzip_file(&plain_path, &part_path).map_err(|error| error.to_string())?;
62 let _ = std::fs::remove_file(&plain_path);
63 std::fs::rename(&part_path, &final_path).map_err(|error| error.to_string())?;
64
65 if let Ok(existing) = list_backup_names(dir, stem) {
66 for name in backups_to_delete(&existing, stem, now_unix()) {
67 let _ = std::fs::remove_file(dir.join(name));
68 }
69 }
70 let bytes = std::fs::metadata(&final_path)
71 .map(|metadata| metadata.len())
72 .unwrap_or(0);
73 Ok(Kept {
74 path: final_path,
75 bytes,
76 })
77}
78
79pub fn due(dir: &Path, stem: &str, now: i64) -> bool {
80 match latest(dir, stem) {
81 Some(at) => at.div_euclid(86_400) != now.div_euclid(86_400),
82 None => true,
83 }
84}
85
86pub fn next_due_unix(after: i64) -> i64 {
87 (after.div_euclid(86_400) + 1) * 86_400
88}
89
90pub fn latest(dir: &Path, stem: &str) -> Option<i64> {
91 list_backup_names(dir, stem)
92 .ok()?
93 .last()
94 .and_then(|name| stamp_to_unix(name, stem))
95}
96
97pub fn backup_into(connection: &Connection, destination: &Path) -> rusqlite::Result<()> {
98 let destination = destination
99 .to_str()
100 .expect("backup destination must be valid utf-8");
101 connection.execute("VACUUM INTO ?1", [destination])?;
102 Ok(())
103}
104
105pub fn spawn(db: Db, dir_of: fn(&Connection) -> Option<PathBuf>, stem: &'static str) -> Handle {
106 let status = Arc::new(Mutex::new(Status::default()));
107 let (nudge, nudged) = std::sync::mpsc::channel();
108 let handle = Handle {
109 status: Arc::clone(&status),
110 nudge,
111 };
112 std::thread::Builder::new()
113 .name("backup".into())
114 .spawn(move || run_scheduled(db, status, nudged, dir_of, stem))
115 .expect("the backup thread must start");
116 handle
117}
118
119fn run_scheduled(
120 db: Db,
121 status: Arc<Mutex<Status>>,
122 nudged: std::sync::mpsc::Receiver<()>,
123 dir_of: fn(&Connection) -> Option<PathBuf>,
124 stem: &'static str,
125) {
126 let mut forced = false;
127 loop {
128 run_once(&db, &status, forced, dir_of, stem);
129 forced = match nudged.recv_timeout(Duration::from_secs(POLL_SECS)) {
130 Ok(()) => true,
131 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => false,
132 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
133 std::thread::sleep(Duration::from_secs(POLL_SECS));
134 false
135 }
136 };
137 }
138}
139
140fn run_once(
141 db: &Db,
142 status: &Mutex<Status>,
143 forced: bool,
144 dir_of: fn(&Connection) -> Option<PathBuf>,
145 stem: &str,
146) {
147 let dir = {
148 let connection = db.lock().unwrap_or_else(|poison| poison.into_inner());
149 dir_of(&connection)
150 };
151 let Some(dir) = dir else {
152 let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
153 status.unconfigured = true;
154 status.next_run_unix = None;
155 return;
156 };
157
158 let now = now_unix();
159 if forced || due(&dir, stem, now) {
160 {
161 let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
162 status.unconfigured = false;
163 status.running = true;
164 status.progress = 0.0;
165 status.queue_depth = 0;
166 }
167 let started = Instant::now();
168 let outcome = {
169 let connection = db.lock().unwrap_or_else(|poison| poison.into_inner());
170 keep(&connection, &dir, stem)
171 };
172 let ran_at = now_unix();
173 let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
174 status.running = false;
175 status.progress = 0.0;
176 status.queue_depth = 0;
177 status.last_run_unix = Some(ran_at);
178 status.next_run_unix = Some(next_due_unix(ran_at));
179 status.last_elapsed = Some(started.elapsed());
180 match outcome {
181 Ok(kept) => {
182 status.last_bytes = Some(kept.bytes);
183 status.last_error = None;
184 }
185 Err(error) => status.last_error = Some(error),
186 }
187 } else {
188 let mut status = status.lock().unwrap_or_else(|poison| poison.into_inner());
189 status.unconfigured = false;
190 status.next_run_unix = Some(latest(&dir, stem).map(next_due_unix).unwrap_or(now));
191 }
192}
193
194fn list_backup_names(dir: &Path, stem: &str) -> std::io::Result<Vec<String>> {
195 let prefix = format!("{stem}-");
196 let mut names: Vec<_> = std::fs::read_dir(dir)?
197 .filter_map(Result::ok)
198 .filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
199 .filter(|name| {
200 name.starts_with(&prefix) && (name.ends_with(".db") || name.ends_with(".db.gz"))
201 })
202 .collect();
203 names.sort();
204 Ok(names)
205}
206
207fn backups_to_delete(names: &[String], stem: &str, now: i64) -> Vec<String> {
208 let cutoff = now - KEEP_DAYS * 86_400;
209 names
210 .iter()
211 .filter(|name| stamp_to_unix(name, stem).is_some_and(|at| at < cutoff))
212 .cloned()
213 .collect()
214}
215
216fn stamp_to_unix(name: &str, stem: &str) -> Option<i64> {
217 let core = name.strip_prefix(&format!("{stem}-"))?;
218 let core = core
219 .strip_suffix(".gz")
220 .unwrap_or(core)
221 .strip_suffix(".db")?;
222 let (date, time) = core.split_once('-')?;
223 if date.len() != 8 || time.len() != 4 {
224 return None;
225 }
226 let year = date[0..4].parse().ok()?;
227 let month = date[4..6].parse().ok()?;
228 let day = date[6..8].parse().ok()?;
229 let hour: i64 = time[0..2].parse().ok()?;
230 let minute: i64 = time[2..4].parse().ok()?;
231 Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60)
232}
233
234fn now_unix() -> i64 {
235 SystemTime::now()
236 .duration_since(UNIX_EPOCH)
237 .map(|duration| duration.as_secs() as i64)
238 .unwrap_or(0)
239}
240
241fn format_stamp(unix: i64) -> String {
242 let days = unix.div_euclid(86_400);
243 let seconds = unix.rem_euclid(86_400);
244 let (year, month, day) = civil_from_days(days);
245 let hour = seconds / 3_600;
246 let minute = seconds % 3_600 / 60;
247 format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}")
248}
249
250fn civil_from_days(days: i64) -> (i64, u32, u32) {
251 let shifted = days + 719_468;
252 let era = if shifted >= 0 {
253 shifted
254 } else {
255 shifted - 146_096
256 } / 146_097;
257 let day_of_era = (shifted - era * 146_097) as u64;
258 let year_of_era =
259 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
260 let year = year_of_era as i64 + era * 400;
261 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
262 let month_prime = (5 * day_of_year + 2) / 153;
263 let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32;
264 let month = if month_prime < 10 {
265 month_prime + 3
266 } else {
267 month_prime - 9
268 } as u32;
269 (if month <= 2 { year + 1 } else { year }, month, day)
270}
271
272fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
273 let year = if month <= 2 { year - 1 } else { year };
274 let era = if year >= 0 { year } else { year - 399 } / 400;
275 let year_of_era = year - era * 400;
276 let month_prime = if month > 2 { month - 3 } else { month + 9 };
277 let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
278 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
279 era * 146_097 + day_of_era - 719_468
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::settings::{self, SETTINGS_TABLE_SQL};
286
287 fn scratch(tag: &str) -> tempfile::TempDir {
288 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tmp/testscratch");
289 std::fs::create_dir_all(&root).unwrap();
290 tempfile::Builder::new()
291 .prefix(&format!("backup-{tag}-"))
292 .tempdir_in(root)
293 .unwrap()
294 }
295
296 fn backup_dir(connection: &Connection) -> Option<PathBuf> {
297 settings::get(connection, "backup_dir")
298 .ok()
299 .flatten()
300 .map(PathBuf::from)
301 }
302
303 fn db_with_backup_dir(dir: Option<&Path>) -> Db {
304 let connection = Connection::open_in_memory().unwrap();
305 connection.execute_batch(SETTINGS_TABLE_SQL).unwrap();
306 if let Some(dir) = dir {
307 settings::set(&connection, "backup_dir", &dir.to_string_lossy()).unwrap();
308 }
309 Arc::new(Mutex::new(connection))
310 }
311
312 #[test]
313 fn keep_days_keeps_the_fourteen_day_ruling() {
314 assert_eq!(KEEP_DAYS, 14);
315 }
316
317 #[test]
318 fn keeps_everything_inside_fourteen_days() {
319 let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
320 let names = vec![
321 "desk-20260805-1200.db.gz".to_owned(),
322 "desk-20260812-0400.db.gz".to_owned(),
323 "desk-20260818-1100.db.gz".to_owned(),
324 ];
325 assert!(backups_to_delete(&names, "desk", now).is_empty());
326 }
327
328 #[test]
329 fn deletes_only_what_is_older_than_fourteen_days() {
330 let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
331 let names = vec![
332 "desk-20260720-1200.db".to_owned(),
333 "desk-20260804-1100.db.gz".to_owned(),
334 "desk-20260810-0400.db.gz".to_owned(),
335 ];
336 assert_eq!(
337 backups_to_delete(&names, "desk", now),
338 vec![
339 "desk-20260720-1200.db".to_owned(),
340 "desk-20260804-1100.db.gz".to_owned()
341 ]
342 );
343 }
344
345 #[test]
346 fn a_name_without_a_readable_stamp_is_never_deleted() {
347 let now = stamp_to_unix("desk-20260818-1200.db.gz", "desk").unwrap();
348 assert!(backups_to_delete(&["desk-garbled.db".to_owned()], "desk", now).is_empty());
349 }
350
351 #[test]
352 fn backup_names_round_trip_through_their_stamp() {
353 let stamp = format_stamp(1_700_000_000);
354 assert_eq!(
355 stamp_to_unix(&format!("desk-{stamp}.db"), "desk"),
356 Some(1_700_000_000 - 20)
357 );
358 }
359
360 #[test]
361 fn a_gz_stamp_reads_the_same_as_a_bare_one() {
362 assert_eq!(
363 stamp_to_unix("desk-20260812-0400.db.gz", "desk"),
364 stamp_to_unix("desk-20260812-0400.db", "desk")
365 );
366 assert!(stamp_to_unix("desk-assets.tar", "desk").is_none());
367 }
368
369 #[test]
370 fn a_folder_with_no_copies_is_always_due() {
371 let holder = scratch("due-empty");
372 assert!(due(holder.path(), "desk", 1_786_000_000));
373 }
374
375 #[test]
376 fn a_missing_folder_is_due_rather_than_an_error() {
377 let holder = scratch("due-missing");
378 assert!(due(&holder.path().join("not-there"), "desk", 1_786_000_000));
379 }
380
381 #[test]
382 fn one_copy_a_day_holds_every_later_boot_off() {
383 let holder = scratch("due-fresh");
384 std::fs::write(holder.path().join("desk-20260812-0400.db.gz"), b"x").unwrap();
385 let at = latest(holder.path(), "desk").unwrap();
386 assert!(!due(holder.path(), "desk", at + 60));
387 assert!(!due(holder.path(), "desk", at + 12 * 3_600));
388 assert!(due(holder.path(), "desk", at + 86_400));
389 }
390
391 #[test]
392 fn a_late_night_copy_is_stale_the_next_morning() {
393 let holder = scratch("due-midnight");
394 std::fs::write(holder.path().join("desk-20260812-2350.db.gz"), b"x").unwrap();
395 let at = latest(holder.path(), "desk").unwrap();
396 assert!(due(holder.path(), "desk", at + 20 * 60));
397 }
398
399 #[test]
400 fn next_due_is_the_coming_midnight() {
401 let at = stamp_to_unix("desk-20260812-0400.db.gz", "desk").unwrap();
402 assert_eq!(
403 next_due_unix(at),
404 stamp_to_unix("desk-20260813-0000.db.gz", "desk").unwrap()
405 );
406 }
407
408 #[test]
409 fn strangers_in_the_folder_are_ignored() {
410 let holder = scratch("strangers");
411 for name in ["notes.txt", "desk-nonsense.db", "backup.db"] {
412 std::fs::write(holder.path().join(name), b"x").unwrap();
413 }
414 assert_eq!(latest(holder.path(), "desk"), None);
415 assert!(due(holder.path(), "desk", 1_786_000_000));
416 }
417
418 #[test]
419 fn a_malformed_stamp_reads_as_nothing() {
420 assert_eq!(stamp_to_unix("desk-2026081-0400.db", "desk"), None);
421 assert_eq!(stamp_to_unix("desk-20260812-040.db", "desk"), None);
422 assert_eq!(stamp_to_unix("desk-abcdefgh-0400.db", "desk"), None);
423 assert_eq!(stamp_to_unix("other-20260812-0400.db", "desk"), None);
424 assert_eq!(stamp_to_unix("desk-20260812-0400.sqlite", "desk"), None);
425 }
426
427 #[test]
428 fn stamps_advance_with_the_clock() {
429 let earlier = stamp_to_unix("desk-20260812-0400.db", "desk").unwrap();
430 let later = stamp_to_unix("desk-20260812-0500.db", "desk").unwrap();
431 assert_eq!(later - earlier, 3_600);
432 let tomorrow = stamp_to_unix("desk-20260813-0400.db", "desk").unwrap();
433 assert_eq!(tomorrow - earlier, 86_400);
434 }
435
436 #[test]
437 fn run_once_backs_up_when_due_and_reports_a_fresh_next_due() {
438 let holder = scratch("run-once-due");
439 let db = db_with_backup_dir(Some(holder.path()));
440 let status = Mutex::new(Status::default());
441
442 run_once(&db, &status, false, backup_dir, "desk");
443
444 let status = status.into_inner().unwrap();
445 assert!(!status.running);
446 assert_eq!(status.progress, 0.0);
447 assert_eq!(status.queue_depth, 0);
448 let last = status.last_run_unix.unwrap();
449 assert_eq!(status.next_run_unix, Some(next_due_unix(last)));
450 assert!(status.last_error.is_none());
451 assert!(status.last_elapsed.is_some());
452 assert_eq!(list_backup_names(holder.path(), "desk").unwrap().len(), 1);
453 }
454
455 #[test]
456 fn run_once_leaves_a_fresh_backup_alone_but_still_reports_next_due() {
457 let holder = scratch("not-due");
458 let stamp = format_stamp(now_unix());
459 std::fs::write(holder.path().join(format!("desk-{stamp}.db.gz")), b"x").unwrap();
460 let db = db_with_backup_dir(Some(holder.path()));
461 let status = Mutex::new(Status::default());
462
463 run_once(&db, &status, false, backup_dir, "desk");
464
465 let status = status.into_inner().unwrap();
466 assert_eq!(status.last_run_unix, None);
467 assert_eq!(list_backup_names(holder.path(), "desk").unwrap().len(), 1);
468 assert!(status.next_run_unix.is_some());
469 }
470
471 #[test]
472 fn run_once_on_an_unset_location_does_nothing_and_never_errors() {
473 let db = db_with_backup_dir(None);
474 let status = Mutex::new(Status::default());
475 run_once(&db, &status, false, backup_dir, "desk");
476 let status = status.into_inner().unwrap();
477 assert!(status.unconfigured);
478 assert!(!status.running);
479 assert_eq!(status.last_error, None);
480 assert_eq!(status.last_run_unix, None);
481 assert_eq!(status.next_run_unix, None);
482 }
483
484 #[test]
485 fn backup_dir_reads_back_exactly_what_settings_wrote_with_no_fallback() {
486 let connection = Connection::open_in_memory().unwrap();
487 connection.execute_batch(SETTINGS_TABLE_SQL).unwrap();
488 assert_eq!(backup_dir(&connection), None);
489 settings::set(&connection, "backup_dir", "/picked/location").unwrap();
490 assert_eq!(
491 backup_dir(&connection),
492 Some(PathBuf::from("/picked/location"))
493 );
494 }
495
496 #[test]
497 fn latest_reads_the_newest_stamp() {
498 let holder = scratch("latest");
499 for name in [
500 "desk-20260810-0100.db.gz",
501 "desk-20260812-2300.db.gz",
502 "desk-20260811-1200.db.gz",
503 ] {
504 std::fs::write(holder.path().join(name), b"x").unwrap();
505 }
506 assert_eq!(
507 latest(holder.path(), "desk"),
508 stamp_to_unix("desk-20260812-2300.db.gz", "desk")
509 );
510 }
511}