//! Local filesystem backup verification — scan for PostgreSQL backup files //! and check their recency. use std::path::Path; use tracing::instrument; use crate::types::BackupCheckResult; /// Scan `directory` for backup files matching `{database}_*.sql.gz`, find the /// newest one by modification time, and return a status based on its age. /// /// Returns "ok" if the newest backup is younger than `max_age_hours`, "stale" /// if older, "missing" if no matching files exist, or "error" on I/O failures. #[instrument(skip_all)] pub fn check_backup( target_name: &str, directory: &str, database: &str, max_age_hours: u64, ) -> BackupCheckResult { let checked_at = chrono::Utc::now().to_rfc3339(); let dir = Path::new(directory); if !dir.exists() { return BackupCheckResult { target: target_name.to_string(), database_name: database.to_string(), status: "error".to_string(), last_backup_at: None, size_bytes: None, age_hours: None, checked_at, error: Some(format!("backup directory does not exist: {directory}")), }; } let prefix_underscore = format!("{database}_"); let prefix_hyphen = format!("{database}-"); let suffix = ".sql.gz"; let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, Err(e) => { return BackupCheckResult { target: target_name.to_string(), database_name: database.to_string(), status: "error".to_string(), last_backup_at: None, size_bytes: None, age_hours: None, checked_at, error: Some(format!("failed to read backup directory: {e}")), }; } }; let mut newest: Option<(std::time::SystemTime, u64, String)> = None; for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if !(name_str.starts_with(&prefix_underscore) || name_str.starts_with(&prefix_hyphen)) || !name_str.ends_with(suffix) { continue; } let metadata = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; if !metadata.is_file() { continue; } let modified = match metadata.modified() { Ok(t) => t, Err(_) => continue, }; let size = metadata.len(); if newest.as_ref().is_none_or(|(best_time, _, _)| modified > *best_time) { newest = Some((modified, size, name_str.into_owned())); } } let Some((modified_time, size, _filename)) = newest else { return BackupCheckResult { target: target_name.to_string(), database_name: database.to_string(), status: "missing".to_string(), last_backup_at: None, size_bytes: None, age_hours: None, checked_at, error: None, }; }; let modified_chrono = chrono::DateTime::::from(modified_time); let age = chrono::Utc::now().signed_duration_since(modified_chrono); let age_hours = age.num_hours(); let status = if age_hours < max_age_hours as i64 { "ok" } else { "stale" }; BackupCheckResult { target: target_name.to_string(), database_name: database.to_string(), status: status.to_string(), last_backup_at: Some(modified_chrono.to_rfc3339()), size_bytes: Some(size as i64), age_hours: Some(age_hours), checked_at, error: None, } } #[cfg(test)] mod tests { use super::*; #[test] fn missing_directory_returns_error() { let result = check_backup("test", "/nonexistent/path/123", "testdb", 25); assert_eq!(result.status, "error"); assert!(result.error.as_ref().unwrap().contains("does not exist")); } #[test] fn empty_directory_returns_missing() { let dir = std::env::temp_dir().join("pom_backup_test_empty"); let _ = std::fs::create_dir_all(&dir); let result = check_backup("test", dir.to_str().unwrap(), "testdb", 25); assert_eq!(result.status, "missing"); assert!(result.error.is_none()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn recent_backup_returns_ok() { let dir = std::env::temp_dir().join("pom_backup_test_ok"); let _ = std::fs::create_dir_all(&dir); let file = dir.join("mydb_2026-04-15.sql.gz"); std::fs::write(&file, b"fake backup").unwrap(); let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25); assert_eq!(result.status, "ok"); assert!(result.last_backup_at.is_some()); assert!(result.size_bytes.unwrap() > 0); assert_eq!(result.age_hours.unwrap(), 0); let _ = std::fs::remove_dir_all(&dir); } #[test] fn hyphen_separator_returns_ok() { let dir = std::env::temp_dir().join("pom_backup_test_hyphen"); let _ = std::fs::create_dir_all(&dir); let file = dir.join("mydb-20260508-030000.sql.gz"); std::fs::write(&file, b"fake backup").unwrap(); let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25); assert_eq!(result.status, "ok"); assert!(result.size_bytes.unwrap() > 0); let _ = std::fs::remove_dir_all(&dir); } #[test] fn wrong_prefix_not_matched() { let dir = std::env::temp_dir().join("pom_backup_test_prefix"); let _ = std::fs::create_dir_all(&dir); let file = dir.join("otherdb_2026-04-15.sql.gz"); std::fs::write(&file, b"wrong db").unwrap(); let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25); assert_eq!(result.status, "missing"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn wrong_suffix_not_matched() { let dir = std::env::temp_dir().join("pom_backup_test_suffix"); let _ = std::fs::create_dir_all(&dir); let file = dir.join("mydb_2026-04-15.sql"); std::fs::write(&file, b"wrong suffix").unwrap(); let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25); assert_eq!(result.status, "missing"); let _ = std::fs::remove_dir_all(&dir); } }