use crate::system::SystemPath;
use std::path::Path;
use super::{resolve_path_on_disk, VaultPath};
use crate::error::FSError;
const BACKUP_RETENTION_DAYS: i64 = 30;
static LAST_PURGE: std::sync::LazyLock<
std::sync::Mutex<Option<(std::path::PathBuf, chrono::NaiveDate)>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
async fn purge_old_backups(backups_root: &Path) {
let today = chrono::Utc::now().date_naive();
if LAST_PURGE
.lock()
.unwrap()
.as_ref()
.is_some_and(|(root, day)| root == backups_root && *day == today)
{
return;
}
let cutoff = today - chrono::Duration::days(BACKUP_RETENTION_DAYS);
let mut entries = match tokio::fs::read_dir(backups_root).await {
Ok(e) => e,
Err(_) => return,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
if let Ok(date) = chrono::NaiveDate::parse_from_str(&name.to_string_lossy(), "%Y-%m-%d") {
if date < cutoff {
let _ = tokio::fs::remove_dir_all(entry.path()).await;
}
}
}
*LAST_PURGE.lock().unwrap() = Some((backups_root.to_path_buf(), today));
}
async fn reserve_backup_dest(base: &Path) -> Result<std::path::PathBuf, FSError> {
let mut candidate = base.to_path_buf();
let mut attempt: u32 = 0;
loop {
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
.await
{
Ok(_) => return Ok(candidate),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let ts = chrono::Utc::now().format("%H%M%S%6f");
let mut name = base.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{ts}.{attempt}"));
candidate = base.with_file_name(name);
attempt = attempt.wrapping_add(1);
}
Err(e) => return Err(FSError::ReadFileError(e)),
}
}
}
pub(crate) async fn backup_note(
workspace_path: &SystemPath,
path: &VaultPath,
) -> Result<(), FSError> {
let src = resolve_path_on_disk(workspace_path, path).await;
match tokio::fs::try_exists(&src).await {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(e) => return Err(FSError::ReadFileError(e)),
}
let rel = src
.strip_prefix(workspace_path)
.map_err(|_| FSError::InvalidPath {
path: src.to_string_lossy().into_owned(),
message: "note path escapes the workspace".to_string(),
})?;
let backups_root = workspace_path.as_path().join(".kimun").join("backups");
purge_old_backups(&backups_root).await;
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let base = backups_root.join(date).join(rel);
if let Some(parent) = base.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let dest = reserve_backup_dest(&base).await?;
tokio::fs::copy(&src, &dest).await?;
Ok(())
}