use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
pub const CURRENT_VERSION: u32 = 1;
struct Step {
from: u32,
describes: &'static str,
apply: fn(&Path) -> Result<()>,
}
const STEPS: &[Step] = &[];
pub fn run(dir: &Path, from: u32) -> Result<u32> {
if from == CURRENT_VERSION {
return Ok(from);
}
if from > CURRENT_VERSION {
bail!(
"this data directory was written by a newer turnout (schema {from}, this build reads {CURRENT_VERSION}).\n\
Update turnout with `turnout self-update`, or point TURNOUT_DATA_DIR at a different directory."
);
}
let pending: Vec<&Step> = STEPS.iter().filter(|step| step.from >= from).collect();
if pending.len() as u32 != CURRENT_VERSION - from {
bail!(
"cannot migrate this data directory from schema {from} to {CURRENT_VERSION}: no upgrade path.\n\
Back up {} and run `turnout setup` to start fresh.",
dir.display()
);
}
let backup = backup_dir(dir, from);
std::fs::create_dir_all(&backup).with_context(|| format!("cannot create {}", backup.display()))?;
copy_data_files(dir, &backup)?;
eprintln!("Migrating settings from schema {from} to {CURRENT_VERSION}.");
eprintln!(" A copy of the old files is in {}", backup.display());
for step in pending {
(step.apply)(dir).with_context(|| format!("migration {} -> {} failed", step.from, step.from + 1))?;
eprintln!(" {}", step.describes);
}
Ok(from)
}
fn backup_dir(dir: &Path, from: u32) -> PathBuf {
let mut candidate = dir.join(format!("settings-backup-v{from}"));
let mut suffix = 2;
while candidate.exists() {
candidate = dir.join(format!("settings-backup-v{from}-{suffix}"));
suffix += 1;
}
candidate
}
fn copy_data_files(dir: &Path, backup: &Path) -> Result<()> {
for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let is_json = path.extension().and_then(|e| e.to_str()) == Some("json");
if !is_json {
continue;
}
let name = entry.file_name();
std::fs::copy(&path, backup.join(&name)).with_context(|| format!("cannot back up {}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_current_directory_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("apps.json"), "[]").unwrap();
assert_eq!(run(dir.path(), CURRENT_VERSION).unwrap(), CURRENT_VERSION);
let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(entries.len(), 1, "{entries:?}");
}
#[test]
fn a_newer_directory_is_refused() {
let dir = tempfile::tempdir().unwrap();
let err = run(dir.path(), CURRENT_VERSION + 1).unwrap_err().to_string();
assert!(err.contains("newer turnout"), "{err}");
assert!(err.contains("self-update"), "the error must say how to move forward: {err}");
}
#[test]
fn a_gap_in_the_upgrade_path_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let err = run(dir.path(), 0).unwrap_err().to_string();
assert!(err.contains("no upgrade path"), "{err}");
assert!(err.contains("setup"), "the error must offer a way out: {err}");
}
#[test]
fn backups_never_overwrite_each_other() {
let dir = tempfile::tempdir().unwrap();
let first = backup_dir(dir.path(), 1);
std::fs::create_dir_all(&first).unwrap();
let second = backup_dir(dir.path(), 1);
assert_ne!(first, second);
assert!(second.to_string_lossy().ends_with("-2"), "{}", second.display());
}
#[test]
fn only_the_json_turnout_owns_is_backed_up() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("apps.json"), "[]").unwrap();
std::fs::write(dir.path().join("meta.json"), "{}").unwrap();
std::fs::write(dir.path().join("journal.jsonl"), "{}").unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let backup = dir.path().join("backup");
std::fs::create_dir(&backup).unwrap();
copy_data_files(dir.path(), &backup).unwrap();
let mut copied: Vec<String> = std::fs::read_dir(&backup)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into())
.collect();
copied.sort();
assert_eq!(copied, vec!["apps.json", "meta.json"], "journals and directories stay put");
}
}