use std::io;
use std::path::{Path, PathBuf};
pub(crate) const MAX_BACKUPS_PER_REASON: usize = 10;
pub(crate) fn write_toml_with_backup(path: &Path, contents: &str, reason: &str) -> io::Result<()> {
if path.exists() {
if let Err(e) = make_backup(path, reason) {
eprintln!("mnml: TOML backup for {} failed: {e}", path.display());
}
prune_backups(path, reason, MAX_BACKUPS_PER_REASON);
}
std::fs::write(path, contents)
}
fn make_backup(path: &Path, reason: &str) -> io::Result<PathBuf> {
let backup = backup_path(path, reason);
std::fs::copy(path, &backup)?;
Ok(backup)
}
fn backup_path(path: &Path, reason: &str) -> PathBuf {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
path.with_file_name(format!("{name}.pre-{reason}-{}", utc_stamp()))
}
fn prune_backups(path: &Path, reason: &str, keep: usize) {
let Some(dir) = path.parent() else {
return;
};
let Some(base_name) = path.file_name().and_then(|s| s.to_str()) else {
return;
};
let prefix = format!("{base_name}.pre-{reason}-");
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut candidates: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.starts_with(&prefix))
.unwrap_or(false)
})
.collect();
if candidates.len() <= keep {
return;
}
candidates.sort();
let excess = candidates.len() - keep;
for old in &candidates[..excess] {
let _ = std::fs::remove_file(old);
}
}
pub(crate) fn utc_stamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (yr, mo, day, hr, mn, sc) = unix_to_ymdhms(now as i64);
format!("{yr:04}{mo:02}{day:02}-{hr:02}{mn:02}{sc:02}")
}
fn unix_to_ymdhms(mut ts: i64) -> (i32, u32, u32, u32, u32, u32) {
if ts < 0 {
ts = 0;
}
let sc = (ts % 60) as u32;
ts /= 60;
let mn = (ts % 60) as u32;
ts /= 60;
let hr = (ts % 24) as u32;
let mut days = ts / 24;
let mut yr: i32 = 1970;
loop {
let leap = is_leap(yr);
let year_days = if leap { 366 } else { 365 };
if days < year_days {
break;
}
days -= year_days;
yr += 1;
}
let month_lengths: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut mo: u32 = 1;
for (i, &len) in month_lengths.iter().enumerate() {
let feb_bonus = if i == 1 && is_leap(yr) { 1 } else { 0 };
let this_month = (len + feb_bonus) as i64;
if days < this_month {
mo = (i + 1) as u32;
break;
}
days -= this_month;
}
let day = (days + 1) as u32;
(yr, mo, day, hr, mn, sc)
}
fn is_leap(y: i32) -> bool {
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn writes_new_file_without_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_toml_with_backup(&path, "a = 1\n", "config").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "a = 1\n");
let siblings: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.contains(".pre-"))
.unwrap_or(false)
})
.collect();
assert!(siblings.is_empty());
}
#[test]
fn overwrites_leave_a_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "a = 1\n").unwrap();
write_toml_with_backup(&path, "a = 2\n", "config").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "a = 2\n");
let backups: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.contains(".pre-config-"))
.unwrap_or(false)
})
.collect();
assert_eq!(backups.len(), 1);
assert_eq!(fs::read_to_string(backups[0].path()).unwrap(), "a = 1\n");
}
#[test]
fn stamp_shape_is_15_chars() {
let s = utc_stamp();
assert_eq!(s.len(), 15, "YYYYMMDD-HHMMSS: {s}");
assert_eq!(&s[8..9], "-");
}
#[test]
fn prunes_past_max_keeping_newest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.toml");
fs::write(&path, "seed").unwrap();
for i in 0..(MAX_BACKUPS_PER_REASON + 3) {
let name = format!("t.toml.pre-config-2026081{i:02}-000000");
fs::write(dir.path().join(name), format!("v{i}")).unwrap();
}
prune_backups(&path, "config", MAX_BACKUPS_PER_REASON);
let remaining: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.map(|s| s.starts_with("t.toml.pre-config-"))
.unwrap_or(false)
})
.collect();
assert_eq!(remaining.len(), MAX_BACKUPS_PER_REASON);
for name in remaining.iter().map(|e| e.file_name()) {
let s = name.to_string_lossy().to_string();
assert!(
!s.contains("2026081000") && !s.contains("2026081100") && !s.contains("2026081200"),
"kept an over-old backup: {s}"
);
}
}
#[test]
fn different_reasons_dont_prune_each_other() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.toml");
fs::write(&path, "seed").unwrap();
for i in 0..15 {
let name = format!("t.toml.pre-config-2026081{i:02}-000000");
fs::write(dir.path().join(name), "x").unwrap();
}
fs::write(dir.path().join("t.toml.pre-manifest-20260810-000000"), "m").unwrap();
prune_backups(&path, "config", MAX_BACKUPS_PER_REASON);
let manifest_still_there = dir
.path()
.join("t.toml.pre-manifest-20260810-000000")
.exists();
assert!(manifest_still_there);
}
}