//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again). //! //! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`, //! so deleting them live is unsafe. Like a restore, a reset has two phases: //! //! 1. `reset_all_data` (`stage_wipe_impl`) writes `/pending_wipe.json` and touches nothing else. //! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is //! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the //! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the //! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging. //! //! This also deletes the backups (`/backups`, including pre-restore safety copies and pre-migration //! copies); that is the point of a reset. use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME}; use super::raw::write_atomic; use crate::AppState; use std::fs; use std::path::Path; use tauri::State; const WIPE_MARKER_NAME: &str = "pending_wipe.json"; /// The database and its SQLite sidecars, in the data dir. const OWNED_DB_FILES: [&str; 4] = [ DB_ENTRY, "voiced.db-wal", "voiced.db-shm", "voiced.db-journal", ]; /// Marker files of the restore mechanism, in the data dir. const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME]; #[derive(Debug, PartialEq, Eq)] pub enum WipeOutcome { NothingPending, Wiped, /// Something could not be deleted; the marker is kept so the next start retries. Failed { message: String, }, } fn marker(dirs: &DataDirs) -> std::path::PathBuf { dirs.data.join(WIPE_MARKER_NAME) } /// Remove a file or directory tree if it exists. A symlink is unlinked, never followed. fn remove_if_exists(path: &Path) -> Result<(), String> { let meta = match fs::symlink_metadata(path) { Ok(m) => m, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(e) => return Err(format!("Could not delete {}: {e}", path.display())), }; let result = if meta.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; result.map_err(|e| format!("Could not delete {}: {e}", path.display())) } /// Record that the next start must wipe everything. The live data is not touched. pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> { write_atomic(&marker(dirs), b"{}") } /// Call after the data directories exist and before anything else (restore, database) uses them. pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome { if fs::symlink_metadata(marker(dirs)).is_err() { return WipeOutcome::NothingPending; } let mut targets = Vec::new(); // Order matters: the database goes first so a half-finished wipe never leaves data with no settings row. for name in OWNED_DB_FILES { targets.push(dirs.data.join(name)); } targets.push(dirs.backups()); for root in ROOTS { targets.push(dirs.root_dir(root)); } for name in OWNED_MARKERS { targets.push(dirs.data.join(name)); } targets.push(dirs.data.join(STAGING_NAME)); targets.push(dirs.local.join(STAGING_NAME)); let errors: Vec = targets .iter() .filter_map(|p| remove_if_exists(p).err()) .collect(); if !errors.is_empty() { return WipeOutcome::Failed { message: errors.join("; "), }; } match remove_if_exists(&marker(dirs)) { Ok(()) => WipeOutcome::Wiped, Err(message) => WipeOutcome::Failed { message }, } } /// Phase one of a reset: stage the wipe. The app must be restarted to carry it out. #[tauri::command] pub fn reset_all_data(state: State) -> Result<(), String> { stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir)) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn populate(dirs: &DataDirs) { for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) { fs::write(dirs.data.join(name), b"x").unwrap(); } fs::create_dir_all(dirs.backups().join("auto")).unwrap(); fs::write( dirs.backups().join("auto").join("voiced-auto-20260101.zip"), b"x", ) .unwrap(); for root in ROOTS { fs::create_dir_all(dirs.root_dir(root)).unwrap(); fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap(); } for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] { fs::create_dir_all(&staging).unwrap(); fs::write(staging.join("manifest.json"), b"x").unwrap(); } fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap(); fs::create_dir_all(dirs.data.join("other")).unwrap(); fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap(); } fn assert_wiped(dirs: &DataDirs) { for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) { assert!(!dirs.data.join(name).exists(), "{name}"); } assert!(!dirs.backups().exists()); for root in ROOTS { assert!(!dirs.root_dir(root).exists(), "{root}"); } assert!(!dirs.data.join(STAGING_NAME).exists()); assert!(!dirs.local.join(STAGING_NAME).exists()); assert!(!marker(dirs).exists()); } fn assert_unrelated_kept(dirs: &DataDirs) { assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep"); assert!(dirs.data.join("other").join("keep.txt").exists()); } #[test] fn nothing_staged_means_nothing_is_deleted() { let tmp = tempdir().unwrap(); let dirs = DataDirs::new(tmp.path(), tmp.path()); populate(&dirs); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending); assert!(dirs.db().exists()); assert!(dirs.backups().exists()); } #[test] fn staging_alone_does_not_touch_live_data() { let tmp = tempdir().unwrap(); let dirs = DataDirs::new(tmp.path(), tmp.path()); populate(&dirs); stage_wipe_impl(&dirs).unwrap(); assert!(marker(&dirs).exists()); assert!(dirs.db().exists()); assert!(dirs.root_dir("archive").join("f.bin").exists()); } #[test] fn wipe_removes_owned_data_and_keeps_unrelated_files() { let tmp = tempdir().unwrap(); let dirs = DataDirs::new(tmp.path(), tmp.path()); populate(&dirs); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); assert_wiped(&dirs); assert_unrelated_kept(&dirs); assert!(dirs.data.exists()); } #[test] fn wipe_handles_separate_data_and_local_roots() { let tmp = tempdir().unwrap(); let data = tmp.path().join("roaming"); let local = tmp.path().join("local"); fs::create_dir_all(&data).unwrap(); fs::create_dir_all(&local).unwrap(); let dirs = DataDirs::new(&data, &local); populate(&dirs); fs::write(local.join("unrelated-local.txt"), b"keep").unwrap(); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); assert_wiped(&dirs); assert_unrelated_kept(&dirs); assert!(local.join("unrelated-local.txt").exists()); } #[test] fn wipe_is_idempotent_when_things_are_missing() { let tmp = tempdir().unwrap(); let dirs = DataDirs::new(tmp.path(), tmp.path()); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending); populate(&dirs); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); assert_wiped(&dirs); } #[cfg(unix)] #[test] fn a_symlinked_root_is_unlinked_not_followed() { let tmp = tempdir().unwrap(); let outside = tempdir().unwrap(); fs::write(outside.path().join("precious.txt"), b"keep").unwrap(); let dirs = DataDirs::new(tmp.path(), tmp.path()); std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap(); stage_wipe_impl(&dirs).unwrap(); assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped); assert!(!dirs.root_dir("archive").exists()); assert!(outside.path().join("precious.txt").exists()); } }