use std::{ io::ErrorKind, path::{Path, PathBuf}, time::Instant, }; use futures::{StreamExt as _, stream}; use tokio::io::AsyncWriteExt as _; use crate::{ install::intent::{ InstallIntent, LEGACY_INTENT_FILE, LEGACY_INTENT_TMP_FILE, intent_path, write_intent, }, local_games::{is_ignored_game_root_name, legacy_library_index_path}, state_paths::{local_library_index_path, setup_done_path}, }; const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread"; const LEGACY_FIRST_START_DONE_FILE: &str = ".softlan_first_start_done"; const LEGACY_SOFTLAN_INSTALL_MARKER: &str = ".softlan_game_installed"; const MIGRATION_CONCURRENCY: usize = 16; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)] pub struct MigrationReport { pub games_checked: usize, pub library_index_migrated: bool, pub install_intents_migrated: usize, pub setup_markers_migrated: usize, pub legacy_files_deleted: usize, pub unknown_softlan_files: usize, pub failures: usize, } impl MigrationReport { fn merge(&mut self, other: Self) { self.games_checked += other.games_checked; self.library_index_migrated |= other.library_index_migrated; self.install_intents_migrated += other.install_intents_migrated; self.setup_markers_migrated += other.setup_markers_migrated; self.legacy_files_deleted += other.legacy_files_deleted; self.unknown_softlan_files += other.unknown_softlan_files; self.failures += other.failures; } } /// Migrates legacy app-owned files out of the configured game directory. /// /// This is intentionally separate from normal operation: callers should run it /// before starting the peer runtime for a game directory. pub async fn migrate_legacy_state(game_dir: &Path, state_dir: &Path) -> MigrationReport { let started = Instant::now(); let mut report = MigrationReport::default(); report.merge(migrate_library_index(game_dir, state_dir).await); let game_roots = match collect_game_roots(game_dir).await { Ok(game_roots) => game_roots, Err(err) => { if err.kind() != ErrorKind::NotFound { log::warn!( "Failed to enumerate game roots for legacy state migration in {}: {err}", game_dir.display() ); report.failures += 1; } log_migration_report(&report, started); return report; } }; let game_reports = stream::iter(game_roots) .map(|(id, root)| async move { migrate_game_root(state_dir, id, root).await }) .buffer_unordered(MIGRATION_CONCURRENCY) .collect::>() .await; for game_report in game_reports { report.merge(game_report); } log_migration_report(&report, started); report } async fn collect_game_roots(game_dir: &Path) -> std::io::Result> { let mut roots = Vec::new(); let mut entries = tokio::fs::read_dir(game_dir).await?; while let Some(entry) = entries.next_entry().await? { if !entry.file_type().await?.is_dir() { continue; } let Some(id) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if is_ignored_game_root_name(&id) { continue; } roots.push((id, entry.path())); } Ok(roots) } async fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = legacy_library_index_path(game_dir); let target_path = local_library_index_path(state_dir); match migrate_raw_file(&legacy_path, &target_path).await { Ok(MigrationOutcome::Migrated) => { report.library_index_migrated = true; report.legacy_files_deleted += 1; } Ok(MigrationOutcome::TargetAlreadyExists) => { report.legacy_files_deleted += 1; } Ok(MigrationOutcome::SourceMissing) => {} Err(err) => { log::warn!( "Failed to migrate legacy library index {} to {}: {err}", legacy_path.display(), target_path.display() ); report.failures += 1; } } report.merge(delete_if_exists(&library_index_tmp_path(&legacy_path)).await); report.merge(remove_empty_legacy_library_dir(game_dir).await); report } async fn migrate_game_root(state_dir: &Path, id: String, root: PathBuf) -> MigrationReport { let mut report = MigrationReport { games_checked: 1, ..MigrationReport::default() }; report.merge(migrate_install_intent(state_dir, &id, &root).await); report.merge(delete_if_exists(&root.join(LEGACY_INTENT_TMP_FILE)).await); report.merge(migrate_setup_marker(state_dir, &id, &root).await); report.merge(delete_if_exists(&root.join(LEGACY_SOFTLAN_INSTALL_MARKER)).await); report.merge(note_unknown_softlan_files(&root).await); report } async fn migrate_install_intent(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = root.join(LEGACY_INTENT_FILE); let target_path = intent_path(state_dir, id); match path_exists(&legacy_path).await { Ok(false) => return report, Ok(true) => {} Err(err) => { log::warn!( "Failed to inspect legacy install intent {}: {err}", legacy_path.display() ); report.failures += 1; return report; } } match path_exists(&target_path).await { Ok(true) => { report.merge(delete_file(&legacy_path).await); return report; } Ok(false) => {} Err(err) => { log::warn!( "Failed to inspect app-state install intent {}: {err}", target_path.display() ); report.failures += 1; return report; } } let data = match tokio::fs::read_to_string(&legacy_path).await { Ok(data) => data, Err(err) => { log::warn!( "Failed to read legacy install intent {}: {err}", legacy_path.display() ); report.failures += 1; return report; } }; let intent = match serde_json::from_str::(&data) { Ok(intent) if intent.is_current_for(id) => intent, Ok(intent) => { log::warn!( "Leaving legacy install intent {} in place because it belongs to id {} schema {}", legacy_path.display(), intent.id, intent.schema_version ); report.failures += 1; return report; } Err(err) => { log::warn!( "Leaving corrupt legacy install intent {} in place: {err}", legacy_path.display() ); report.failures += 1; return report; } }; if let Err(err) = write_intent(state_dir, id, &intent).await { log::warn!( "Failed to write migrated install intent {}: {err}", target_path.display() ); report.failures += 1; return report; } report.install_intents_migrated += 1; report.merge(delete_file(&legacy_path).await); report } async fn migrate_setup_marker(state_dir: &Path, id: &str, root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); let legacy_path = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); let target_path = setup_done_path(state_dir, id); match migrate_empty_marker(&legacy_path, &target_path).await { Ok(MigrationOutcome::Migrated) => { report.setup_markers_migrated += 1; report.legacy_files_deleted += 1; } Ok(MigrationOutcome::TargetAlreadyExists) => { report.legacy_files_deleted += 1; } Ok(MigrationOutcome::SourceMissing) => {} Err(err) => { log::warn!( "Failed to migrate legacy setup marker {} to {}: {err}", legacy_path.display(), target_path.display() ); report.failures += 1; } } report } async fn note_unknown_softlan_files(root: &Path) -> MigrationReport { let mut report = MigrationReport::default(); report.unknown_softlan_files += count_unknown_softlan_files(root).await; report.unknown_softlan_files += count_unknown_softlan_files(&root.join("local")).await; report } async fn count_unknown_softlan_files(dir: &Path) -> usize { let mut count = 0; let mut entries = match tokio::fs::read_dir(dir).await { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return 0, Err(err) => { log::warn!( "Failed to inspect {} for legacy .softlan files: {err}", dir.display() ); return 0; } }; while let Ok(Some(entry)) = entries.next_entry().await { let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if !name.starts_with(".softlan_") || name == LEGACY_SOFTLAN_INSTALL_MARKER || name == LEGACY_FIRST_START_DONE_FILE { continue; } count += 1; log::info!( "Leaving unknown legacy .softlan file in place: {}", entry.path().display() ); } count } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum MigrationOutcome { SourceMissing, TargetAlreadyExists, Migrated, } async fn migrate_raw_file( legacy_path: &Path, target_path: &Path, ) -> std::io::Result { if !path_exists(legacy_path).await? { return Ok(MigrationOutcome::SourceMissing); } if path_exists(target_path).await? { remove_file_if_exists(legacy_path).await?; return Ok(MigrationOutcome::TargetAlreadyExists); } let data = tokio::fs::read(legacy_path).await?; write_bytes_atomically(target_path, &data).await?; remove_file_if_exists(legacy_path).await?; Ok(MigrationOutcome::Migrated) } async fn migrate_empty_marker( legacy_path: &Path, target_path: &Path, ) -> std::io::Result { if !path_exists(legacy_path).await? { return Ok(MigrationOutcome::SourceMissing); } if path_exists(target_path).await? { remove_file_if_exists(legacy_path).await?; return Ok(MigrationOutcome::TargetAlreadyExists); } if let Some(parent) = target_path.parent() { tokio::fs::create_dir_all(parent).await?; } tokio::fs::File::create(target_path) .await? .sync_all() .await?; remove_file_if_exists(legacy_path).await?; Ok(MigrationOutcome::Migrated) } async fn write_bytes_atomically(path: &Path, data: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } let tmp_path = library_index_tmp_path(path); let mut file = tokio::fs::File::create(&tmp_path).await?; file.write_all(data).await?; file.sync_all().await?; drop(file); tokio::fs::rename(&tmp_path, path).await?; sync_parent_dir(path) } fn library_index_tmp_path(path: &Path) -> PathBuf { let Some(file_name) = path.file_name() else { return path.with_extension("tmp"); }; let mut tmp_name = file_name.to_os_string(); tmp_name.push(".tmp"); path.with_file_name(tmp_name) } async fn path_exists(path: &Path) -> std::io::Result { match tokio::fs::metadata(path).await { Ok(_) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } async fn delete_if_exists(path: &Path) -> MigrationReport { match remove_file_if_exists(path).await { Ok(true) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() }, Ok(false) => MigrationReport::default(), Err(err) => { log::warn!("Failed to delete legacy file {}: {err}", path.display()); MigrationReport { failures: 1, ..MigrationReport::default() } } } } async fn delete_file(path: &Path) -> MigrationReport { match remove_file_if_exists(path).await { Ok(true) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() }, Ok(false) => MigrationReport::default(), Err(err) => { log::warn!("Failed to delete legacy file {}: {err}", path.display()); MigrationReport { failures: 1, ..MigrationReport::default() } } } } async fn remove_file_if_exists(path: &Path) -> std::io::Result { if !path_exists(path).await? { return Ok(false); } match tokio::fs::remove_file(path).await { Ok(()) => Ok(true), Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), Err(err) => Err(err), } } async fn remove_empty_legacy_library_dir(game_dir: &Path) -> MigrationReport { let path = game_dir.join(LEGACY_LIBRARY_INDEX_DIR); let exists = match path_exists(&path).await { Ok(exists) => exists, Err(err) => { log::warn!( "Failed to inspect legacy library index directory {}: {err}", path.display() ); return MigrationReport { failures: 1, ..MigrationReport::default() }; } }; if !exists { return MigrationReport::default(); } match tokio::fs::remove_dir(&path).await { Ok(()) => MigrationReport { legacy_files_deleted: 1, ..MigrationReport::default() }, Err(err) if err.kind() == ErrorKind::NotFound || err.kind() == ErrorKind::DirectoryNotEmpty => { MigrationReport::default() } Err(err) => { log::warn!( "Failed to remove empty legacy library index directory {}: {err}", path.display() ); MigrationReport { failures: 1, ..MigrationReport::default() } } } } fn log_migration_report(report: &MigrationReport, started: Instant) { log::info!( "Legacy state migration finished in {:?}: games_checked={}, library_index_migrated={}, \ install_intents_migrated={}, setup_markers_migrated={}, legacy_files_deleted={}, \ unknown_softlan_files={}, failures={}", started.elapsed(), report.games_checked, report.library_index_migrated, report.install_intents_migrated, report.setup_markers_migrated, report.legacy_files_deleted, report.unknown_softlan_files, report.failures ); } #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::File::open(parent)?.sync_all()?; } Ok(()) } #[cfg(not(unix))] fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::{ install::intent::{InstallIntentState, read_intent}, test_support::TempDir, }; fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("parent dir should be created"); } std::fs::write(path, bytes).expect("file should be written"); } #[tokio::test] async fn migrates_legacy_library_index_to_app_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let legacy_path = legacy_library_index_path(games.path()); let target_path = local_library_index_path(state.path()); let legacy_tmp_path = library_index_tmp_path(&legacy_path); write_file(&legacy_path, br#"{"revision":7,"games":{}}"#); write_file(&legacy_tmp_path, b"tmp"); let report = migrate_legacy_state(games.path(), state.path()).await; assert!(report.library_index_migrated); assert_eq!( std::fs::read_to_string(&target_path).expect("index should migrate"), r#"{"revision":7,"games":{}}"# ); assert!(!legacy_path.exists()); assert!(!legacy_tmp_path.exists()); assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists()); } #[tokio::test] async fn migrates_per_game_intent_and_setup_marker() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); let intent = InstallIntent::new( "game", InstallIntentState::Updating, Some("20250101".to_string()), ); let legacy_intent = root.join(LEGACY_INTENT_FILE); let legacy_tmp = root.join(LEGACY_INTENT_TMP_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); let legacy_marker = root.join(LEGACY_SOFTLAN_INSTALL_MARKER); write_file( &legacy_intent, &serde_json::to_vec_pretty(&intent).expect("intent should serialize"), ); write_file(&legacy_tmp, b"tmp"); write_file(&legacy_setup, b""); write_file(&legacy_marker, b""); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.install_intents_migrated, 1); assert_eq!(report.setup_markers_migrated, 1); let migrated_intent = read_intent(state.path(), "game").await; assert_eq!(migrated_intent.state, InstallIntentState::Updating); assert_eq!(migrated_intent.eti_version.as_deref(), Some("20250101")); assert!(setup_done_path(state.path(), "game").is_file()); assert!(!legacy_intent.exists()); assert!(!legacy_tmp.exists()); assert!(!legacy_setup.exists()); assert!(!legacy_marker.exists()); } #[tokio::test] async fn app_state_wins_over_legacy_per_game_state() { let games = TempDir::new("lanspread-migration-games"); let state = TempDir::new("lanspread-migration-state"); let root = games.path().join("game"); let app_intent = InstallIntent::none("game", Some("app".to_string())); let legacy_intent = InstallIntent::new( "game", InstallIntentState::Installing, Some("legacy".to_string()), ); let legacy_intent_path = root.join(LEGACY_INTENT_FILE); let legacy_setup = root.join("local").join(LEGACY_FIRST_START_DONE_FILE); write_intent(state.path(), "game", &app_intent) .await .expect("app-state intent should be written"); write_file( &legacy_intent_path, &serde_json::to_vec_pretty(&legacy_intent).expect("intent should serialize"), ); write_file(&setup_done_path(state.path(), "game"), b""); write_file(&legacy_setup, b""); let report = migrate_legacy_state(games.path(), state.path()).await; assert_eq!(report.install_intents_migrated, 0); assert_eq!(report.setup_markers_migrated, 0); let intent = read_intent(state.path(), "game").await; assert_eq!(intent.state, InstallIntentState::None); assert_eq!(intent.eti_version.as_deref(), Some("app")); assert!(!legacy_intent_path.exists()); assert!(!legacy_setup.exists()); } }