use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GfsRotation {
pub daily_count: u32,
pub weekly_count: u32,
pub monthly_count: u32,
}
impl Default for GfsRotation {
fn default() -> Self {
GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 12,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupRecord {
pub id: u64,
pub created_at: SystemTime,
pub size_bytes: u64,
pub is_weekly: bool,
pub is_monthly: bool,
}
impl GfsRotation {
pub fn prune_candidates(&self, records: &[BackupRecord], now: SystemTime) -> Vec<u64> {
let daily_cutoff = now
.checked_sub(Duration::from_secs(u64::from(self.daily_count) * 86_400))
.unwrap_or(SystemTime::UNIX_EPOCH);
let weekly_cutoff = now
.checked_sub(Duration::from_secs(
u64::from(self.weekly_count) * 7 * 86_400,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
let monthly_cutoff = now
.checked_sub(Duration::from_secs(
u64::from(self.monthly_count) * 30 * 86_400,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
records
.iter()
.filter_map(|r| {
let in_daily = r.created_at >= daily_cutoff;
let in_weekly = r.is_weekly && r.created_at >= weekly_cutoff;
let in_monthly = r.is_monthly && r.created_at >= monthly_cutoff;
if !in_daily && !in_weekly && !in_monthly {
Some(r.id)
} else {
None
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn days_ago(n: u64) -> SystemTime {
SystemTime::now() - Duration::from_secs(n * 86_400)
}
fn record(id: u64, days: u64, is_weekly: bool, is_monthly: bool) -> BackupRecord {
BackupRecord {
id,
created_at: days_ago(days),
size_bytes: 1024,
is_weekly,
is_monthly,
}
}
#[test]
fn recent_daily_not_pruned() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 3,
};
let records = vec![record(1, 1, false, false)];
let pruned = gfs.prune_candidates(&records, SystemTime::now());
assert!(pruned.is_empty(), "1-day-old backup should be kept");
}
#[test]
fn old_non_tier_backup_pruned() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 3,
};
let records = vec![record(1, 100, false, false)];
let pruned = gfs.prune_candidates(&records, SystemTime::now());
assert_eq!(pruned, vec![1], "100-day-old plain backup should be pruned");
}
#[test]
fn weekly_inside_window_kept() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 3,
};
let records = vec![record(1, 20, true, false)]; let pruned = gfs.prune_candidates(&records, SystemTime::now());
assert!(
pruned.is_empty(),
"20-day-old weekly backup should be kept (4-week window)"
);
}
#[test]
fn weekly_outside_window_pruned() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 2,
monthly_count: 1,
};
let records = vec![record(1, 20, true, false)];
let pruned = gfs.prune_candidates(&records, SystemTime::now());
assert_eq!(
pruned,
vec![1],
"20-day-old weekly should be pruned (2-week window)"
);
}
#[test]
fn monthly_inside_window_kept() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 3,
};
let records = vec![record(1, 80, false, true)]; let pruned = gfs.prune_candidates(&records, SystemTime::now());
assert!(
pruned.is_empty(),
"80-day-old monthly backup should be kept (3-month window)"
);
}
#[test]
fn gfs_100_day_simulation() {
let gfs = GfsRotation {
daily_count: 7,
weekly_count: 4,
monthly_count: 3,
};
let now = SystemTime::now();
let records: Vec<BackupRecord> = (0u64..100)
.map(|day| BackupRecord {
id: day,
created_at: now - Duration::from_secs((100 - day) * 86_400),
size_bytes: 512,
is_weekly: day % 7 == 0,
is_monthly: day % 30 == 0,
})
.collect();
let pruned = gfs.prune_candidates(&records, now);
let kept_count = 100 - pruned.len();
assert!(
kept_count >= 7,
"Expected ≥7 kept backups; got {kept_count}"
);
}
#[test]
fn empty_records_returns_empty() {
let gfs = GfsRotation::default();
let pruned = gfs.prune_candidates(&[], SystemTime::now());
assert!(pruned.is_empty());
}
#[test]
fn serialises_round_trip() {
let gfs = GfsRotation::default();
let json = serde_json::to_string(&gfs).unwrap();
let back: GfsRotation = serde_json::from_str(&json).unwrap();
assert_eq!(back.daily_count, gfs.daily_count);
assert_eq!(back.weekly_count, gfs.weekly_count);
assert_eq!(back.monthly_count, gfs.monthly_count);
}
}