use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct BackupConfig {
pub destination: PathBuf,
#[serde(default = "default_format")]
pub format: BackupFormat,
#[serde(default = "default_retention")]
pub retention: usize,
#[serde(default = "default_schedule")]
pub schedule: String,
}
fn default_format() -> BackupFormat {
BackupFormat::Timestamp
}
fn default_retention() -> usize {
10
}
fn default_schedule() -> String {
"daily".to_string()
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BackupFormat {
Timestamp,
Date,
}
impl Default for BackupConfig {
fn default() -> Self {
Self {
destination: PathBuf::from("/Volumes/mate-mini/nusy-kanban-backup"),
format: BackupFormat::Timestamp,
retention: 0, schedule: "daily".to_string(),
}
}
}
impl std::fmt::Display for BackupFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackupFormat::Timestamp => write!(f, "timestamp"),
BackupFormat::Date => write!(f, "date"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMetadata {
pub created_at: String,
pub version: String,
pub last_commit_id: Option<String>,
pub commit_count: usize,
}
impl SnapshotMetadata {
pub fn new() -> Self {
let now = chrono::Utc::now();
let commit_id = Self::last_commit_id();
let commit_count = Self::count_commits().unwrap_or(0);
Self {
created_at: now.to_rfc3339(),
version: env!("CARGO_PKG_VERSION").to_string(),
last_commit_id: commit_id,
commit_count,
}
}
fn last_commit_id() -> Option<String> {
let commits_path = Path::new(".nusy-kanban/_commits.json");
if !commits_path.exists() {
return None;
}
let file = File::open(commits_path).ok()?;
let reader = BufReader::new(file);
let commits: Vec<serde_json::Value> = serde_json::from_reader(reader).ok()?;
commits
.last()
.and_then(|v| v.get("id").and_then(|id| id.as_str()))
.map(|s| s.to_string())
}
fn count_commits() -> Option<usize> {
let commits_path = Path::new(".nusy-kanban/_commits.json");
if !commits_path.exists() {
return Some(0);
}
let file = File::open(commits_path).ok()?;
let reader = BufReader::new(file);
let commits: Vec<serde_json::Value> = serde_json::from_reader(reader).ok()?;
Some(commits.len())
}
}
pub fn snapshot_name(format: BackupFormat) -> String {
let now = chrono::Utc::now();
match format {
BackupFormat::Timestamp => now.format("snapshot-%Y-%m-%d_%H%M%S").to_string(),
BackupFormat::Date => now.format("snapshot-%Y-%m-%d").to_string(),
}
}
pub fn parse_snapshot_date(name: &str) -> Option<chrono::NaiveDate> {
let date_str = name.strip_prefix("snapshot-")?;
let date_str = date_str.split('_').next()?;
chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()
}
const SNAPSHOT_FILES: &[&str] = &[
"items.parquet",
"runs.parquet",
"comments.parquet",
"item_comments.parquet",
"relations.parquet",
"proposals.parquet",
"experiment_runs.parquet",
"_commits.json",
];
const SNAPSHOT_FILES_NO_PR: &[&str] = &[
"items.parquet",
"runs.parquet",
"comments.parquet",
"item_comments.parquet",
"relations.parquet",
"experiment_runs.parquet",
"_commits.json",
];
#[derive(Debug, thiserror::Error)]
pub enum BackupError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("destination not found: {0}")]
DestinationNotFound(String),
#[error("snapshot not found: {0}")]
SnapshotNotFound(String),
#[error("invalid snapshot name: {0}")]
InvalidSnapshotName(String),
#[error("restore requires --force flag")]
RestoreRequiresForce,
#[error("cannot restore over live store while server may be writing — stop server first")]
ServerStillRunning,
}
pub type Result<T> = std::result::Result<T, BackupError>;
pub fn create_snapshot(config: &BackupConfig, root: &Path) -> Result<PathBuf> {
let dest = config.destination.canonicalize().unwrap_or_else(|_| {
config.destination.clone()
});
if !dest.exists() {
return Err(BackupError::DestinationNotFound(dest.display().to_string()));
}
let name = snapshot_name(config.format);
let snapshot_dir = dest.join(&name);
fs::create_dir_all(&snapshot_dir)?;
let data_dir = root.join(".nusy-kanban");
let files_to_copy: Vec<&str> = if cfg!(feature = "pr") {
SNAPSHOT_FILES.to_vec()
} else {
SNAPSHOT_FILES_NO_PR.to_vec()
};
for filename in files_to_copy {
let src = data_dir.join(filename);
if src.exists() {
let dst = snapshot_dir.join(filename);
fs::copy(&src, &dst)?;
}
}
let metadata = SnapshotMetadata::new();
let meta_path = snapshot_dir.join("_metadata.json");
let file = File::create(&meta_path)?;
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &metadata)?;
writer.flush()?;
let latest_link = dest.join("latest");
if latest_link.exists() || latest_link.is_symlink() {
fs::remove_file(&latest_link).ok();
}
std::os::unix::fs::symlink(&name, &latest_link).ok();
prune_old_snapshots(&dest, config.retention)?;
Ok(snapshot_dir)
}
pub fn list_snapshots(config: &BackupConfig) -> Result<Vec<SnapshotInfo>> {
let dest = config
.destination
.canonicalize()
.unwrap_or_else(|_| config.destination.clone());
if !dest.exists() {
return Err(BackupError::DestinationNotFound(dest.display().to_string()));
}
let mut snapshots = Vec::new();
for entry in fs::read_dir(&dest)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with("snapshot-") || entry.file_type()?.is_symlink() {
continue;
}
if !entry.file_type()?.is_dir() {
continue;
}
let meta_path = entry.path().join("_metadata.json");
let metadata = if meta_path.exists() {
let file = File::open(&meta_path)?;
let reader = BufReader::new(file);
serde_json::from_reader(reader).ok()
} else {
None
};
let created_at = metadata
.as_ref()
.and_then(|m: &SnapshotMetadata| {
chrono::DateTime::parse_from_rfc3339(&m.created_at)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc))
})
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string());
let version = metadata
.as_ref()
.map(|m: &SnapshotMetadata| m.version.clone())
.unwrap_or_else(|| "unknown".to_string());
let commit_count = metadata
.as_ref()
.map(|m: &SnapshotMetadata| m.commit_count)
.unwrap_or(0);
snapshots.push(SnapshotInfo {
name: name.clone(),
path: entry.path(),
created_at,
version,
commit_count,
});
}
snapshots.sort_by(|a, b| b.name.cmp(&a.name));
Ok(snapshots)
}
#[derive(Debug, Clone, Serialize)]
pub struct SnapshotInfo {
pub name: String,
pub path: PathBuf,
pub created_at: Option<String>,
pub version: String,
pub commit_count: usize,
}
pub fn restore_snapshot(
snapshot_name: &str,
config: &BackupConfig,
root: &Path,
force: bool,
) -> Result<PathBuf> {
if !force {
return Err(BackupError::RestoreRequiresForce);
}
let dest = config
.destination
.canonicalize()
.unwrap_or_else(|_| config.destination.clone());
let snapshot_dir = dest.join(snapshot_name);
if !snapshot_dir.exists() {
return Err(BackupError::SnapshotNotFound(snapshot_name.to_string()));
}
let has_items = snapshot_dir.join("items.parquet").exists();
if !has_items {
return Err(BackupError::InvalidSnapshotName(
"not a valid snapshot: missing items.parquet".to_string(),
));
}
let data_dir = root.join(".nusy-kanban");
let files = [
"items.parquet",
"runs.parquet",
"comments.parquet",
"item_comments.parquet",
"relations.parquet",
"experiment_runs.parquet",
"_commits.json",
];
if cfg!(feature = "pr") {
let files_pr = ["proposals.parquet"];
for filename in files.iter().chain(files_pr.iter()) {
let src = snapshot_dir.join(filename);
if src.exists() {
let dst = data_dir.join(filename);
fs::copy(&src, &dst)?;
}
}
} else {
for filename in files.iter() {
let src = snapshot_dir.join(filename);
if src.exists() {
let dst = data_dir.join(filename);
fs::copy(&src, &dst)?;
}
}
}
Ok(snapshot_dir)
}
pub fn prune_old_snapshots(dest: &Path, retention: usize) -> Result<()> {
if retention == 0 {
return Ok(()); }
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(dest) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("snapshot-")
&& entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
{
dirs.push(entry.path());
}
}
}
dirs.sort_by(|a, b| b.cmp(a));
for old in dirs.into_iter().skip(retention) {
println!(
"Pruning old snapshot: {}",
old.file_name().unwrap_or_default().to_string_lossy()
);
fs::remove_dir_all(&old)?;
}
Ok(())
}
pub fn is_backup_due(config: &BackupConfig) -> Result<bool> {
let latest = config.destination.join("latest");
if !latest.exists() {
return Ok(true); }
if File::open(&latest).is_err() {
return Ok(true); }
let meta_path = latest.join("_metadata.json");
if !meta_path.exists() {
return Ok(true);
}
let file = File::open(&meta_path)?;
let reader = BufReader::new(file);
let metadata: SnapshotMetadata = serde_json::from_reader(reader)?;
let last_backup = chrono::DateTime::parse_from_rfc3339(&metadata.created_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.ok();
let now = chrono::Utc::now();
let schedule = &config.schedule;
if schedule == "hourly" {
if let Some(last) = last_backup {
return Ok((now - last).num_minutes() >= 60);
}
} else if schedule == "daily" {
if let Some(last) = last_backup {
return Ok((now - last).num_hours() >= 24);
}
} else if schedule == "weekly" {
if let Some(last) = last_backup {
return Ok((now - last).num_days() >= 7);
}
} else {
eprintln!(
"Warning: unrecognized schedule '{}', treating as daily",
schedule
);
if let Some(last) = last_backup {
return Ok((now - last).num_hours() >= 24);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_name_timestamp() {
let name = snapshot_name(BackupFormat::Timestamp);
assert!(name.starts_with("snapshot-20"));
assert!(name.len() >= "snapshot-2026-04-07_120000".len());
}
#[test]
fn test_snapshot_name_date() {
let name = snapshot_name(BackupFormat::Date);
assert!(name.starts_with("snapshot-20"));
assert!(!name.contains("_120000")); }
#[test]
fn test_parse_snapshot_date() {
assert!(parse_snapshot_date("snapshot-2026-04-07").is_some());
assert!(parse_snapshot_date("snapshot-2026-04-07_055839").is_some());
assert!(parse_snapshot_date("latest").is_none());
assert!(parse_snapshot_date("notasnapshot").is_none());
}
#[test]
fn test_backup_format_display() {
assert_eq!(format!("{}", BackupFormat::Timestamp), "timestamp");
assert_eq!(format!("{}", BackupFormat::Date), "date");
}
}