From b83e5446580019319433960a5e3d415ea9358093 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Thu, 28 May 2026 22:40:35 -0700 Subject: [PATCH 1/4] feat(semantic): portable backups between Windows and Wine/Proton Add semantic path support so a Windows game's saves can be backed up and restored across Windows and Wine/Proton without per-game redirects. Saves are identified by their portable meaning (e.g. /...) instead of the source machine's username or Wine prefix location. Core module (src/semantic): - SemanticBase/SemanticPath: parse, serialize, storage-path encoding, and case-aware equality for Windows known-folder bases and drive roots - convert: physical<->semantic for native Windows paths and Wine-prefix paths (lexical, symlink-safe), plus manifest-origin derivation - prefix: Wine prefix validation and Wine-user detection - materialize: semantic->physical for the current Windows user or a selected Wine prefix, with drive-mapping fallback and long-path checks - conflict/signals/preview: duplicate-key detection, foreign-platform comparison signals, and dry-run preview analysis Integration: - scan: derive a semantic key per file (manifest origin first, then reverse mapping), keeping the physical path as the copy source - layout: store keys under a versioned `semantic-v1` format with a reserved `__ludusavi_semantic__` storage namespace; force a new full backup when a legacy chain first switches to semantic; materialize keys on restore and surface a per-file restore error instead of writing an invalid path - config: opt-in `backup.semanticPaths` (default off), per-game preferred Wine prefixes, a global restore `winePrefix`, and `driveMappings` - cli/api: `--wine-prefix` for restore with CLI-vs-per-game conflict detection - gui: PORTABLE / NEW FULL BACKUP / CONFLICT / INVALID PREFIX badges, with lazily cached conflict detection Scope is intentionally limited to Windows<->Wine/Proton. Steam userdata and native Linux paths keep their existing absolute-path behavior. --- examples/api.rs | 2 +- src/api.rs | 69 ++- src/cli.rs | 90 +++- src/cli/parse.rs | 9 + src/gui/app.rs | 34 ++ src/gui/file_tree.rs | 8 + src/gui/game_list.rs | 45 ++ src/lang.rs | 116 ++++++ src/lib.rs | 1 + src/main.rs | 2 +- src/prelude.rs | 5 + src/report.rs | 118 ++++++ src/resource/config.rs | 51 +++ src/scan.rs | 275 +++++++++++- src/scan/duplicate.rs | 12 + src/scan/layout.rs | 812 +++++++++++++++++++++++++++++++++--- src/scan/preview.rs | 50 ++- src/scan/saves.rs | 32 ++ src/semantic.rs | 377 +++++++++++++++++ src/semantic/conflict.rs | 151 +++++++ src/semantic/convert.rs | 767 ++++++++++++++++++++++++++++++++++ src/semantic/materialize.rs | 667 +++++++++++++++++++++++++++++ src/semantic/prefix.rs | 358 ++++++++++++++++ src/semantic/preview.rs | 321 ++++++++++++++ src/semantic/signals.rs | 154 +++++++ 25 files changed, 4441 insertions(+), 85 deletions(-) create mode 100644 src/semantic.rs create mode 100644 src/semantic/conflict.rs create mode 100644 src/semantic/convert.rs create mode 100644 src/semantic/materialize.rs create mode 100644 src/semantic/prefix.rs create mode 100644 src/semantic/preview.rs create mode 100644 src/semantic/signals.rs diff --git a/examples/api.rs b/examples/api.rs index f33de85e..078e27ad 100644 --- a/examples/api.rs +++ b/examples/api.rs @@ -3,7 +3,7 @@ use ludusavi::api::*; fn main() { let mut ludusavi = Ludusavi::load().unwrap(); - let games = vec![std::env::args().skip(1).next().unwrap_or_else(|| "Celeste".to_string())]; + let games = vec![std::env::args().nth(1).unwrap_or_else(|| "Celeste".to_string())]; let backups = ludusavi .list_backups(parameters::ListBackups { games: games.clone() }) diff --git a/src/api.rs b/src/api.rs index 23a72031..9de7273e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,11 +7,16 @@ use crate::{ prelude::{Error, app_dir}, report, scan::{ - BackupId, DuplicateDetector, Launchers, OperationStepDecision, ScanKind, SteamShortcuts, TitleFinder, + BackupId, DuplicateDetector, Launchers, OperationStepDecision, ScanInfo, ScanKind, SteamShortcuts, TitleFinder, TitleMatch, layout::BackupLayout, prepare_backup_target, scan_game_for_backup, }, + semantic::materialize::{MaterializeTarget, resolve_wine_prefix_for_game}, + semantic::preview::{SemanticPreviewAnalysis, will_start_new_semantic_full_backup}, }; +#[cfg(target_os = "windows")] +use crate::semantic::materialize::known_folders_from_common_path; + pub use crate::{ path::StrictPath, prelude::{Finality, SyncDirection}, @@ -230,7 +235,13 @@ impl Ludusavi { self.config.restore.reverse_redirects, &self.steam_shortcuts, self.config.backup.only_constructive, + self.config.backup.semantic_paths, ); + let mut scan_info = scan_info; + if finality.preview() { + scan_info.will_start_new_semantic_full_backup = + will_start_new_semantic_full_backup(&self.layout, &scan_info); + } let ignored = !&self.config.is_game_enabled_for_backup(name) && !games_specified && !include_disabled; let decision = if ignored { OperationStepDecision::Ignored @@ -291,16 +302,23 @@ impl Ludusavi { ); } - for (name, scan_info, backup_info, decision) in info { + for (name, scan_info, backup_info, decision) in &info { reporter.add_game( name, - &scan_info, + scan_info, backup_info.as_ref(), - &decision, + decision, &duplicate_detector, false, ); } + if finality.preview() { + let analysis_inputs: Vec<_> = info.iter().map(|(name, scan_info, _, _)| (*name, scan_info)).collect(); + let analysis = SemanticPreviewAnalysis::from_backup_preview(&self.config, &self.layout, &analysis_inputs); + if !analysis.is_empty() { + reporter.add_semantic_preview(analysis); + } + } self.refresh(); reporter.json_output().ok_or(Error::SomeEntriesFailed) @@ -314,6 +332,7 @@ impl Ludusavi { finality, backup, resolve_cloud_conflict, + wine_prefix, include_disabled, skip_downgrade, }: parameters::Restore, @@ -367,9 +386,46 @@ impl Ludusavi { } } + // Pre-build Windows materialization state for semantic backup restoration. + #[cfg(target_os = "windows")] + let kf = known_folders_from_common_path(); + #[cfg(target_os = "windows")] + let win_target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let step = |i, name| { log::trace!("step {i} / {}: {name}", games.len()); let mut layout = self.layout.game_layout(name); + + #[cfg(target_os = "windows")] + let materialize_target: Option<&MaterializeTarget> = Some(&win_target); + #[cfg(not(target_os = "windows"))] + let linux_wine_prefix = match resolve_wine_prefix_for_game(&self.config, name, wine_prefix.as_ref()) { + Ok(prefix) => prefix, + Err(error) => { + log::trace!("step {i} completed (wine prefix conflict)"); + let display_title = self.config.display_name(name); + return Some(( + display_title, + ScanInfo { + game_name: name.to_string(), + has_backups: layout.has_backups(), + ..Default::default() + }, + Default::default(), + OperationStepDecision::Processed, + Some(error), + )); + } + }; + #[cfg(not(target_os = "windows"))] + let linux_wine_target = linux_wine_prefix.as_ref().map(|prefix| MaterializeTarget::WinePrefix { + prefix, + wine_user: &prefix.wine_user, + drive_mappings: &self.config.restore.drive_mappings, + }); + #[cfg(not(target_os = "windows"))] + let materialize_target: Option<&MaterializeTarget> = linux_wine_target.as_ref(); + let scan_info = layout.scan_for_restoration( name, backup_id.as_ref().unwrap_or(&BackupId::Latest), @@ -377,6 +433,7 @@ impl Ludusavi { self.config.restore.reverse_redirects, &self.config.restore.toggled_paths, &self.config.restore.toggled_registry, + materialize_target, ); let ignored = !&self.config.is_game_enabled_for_restore(name) && !games_specified && !include_disabled; let decision = if ignored { @@ -414,7 +471,7 @@ impl Ludusavi { None } else { let display_title = self.config.display_name(name); - Some((display_title, scan_info, restore_info, decision, None)) + Some((display_title, scan_info, restore_info, decision, None::)) } }; @@ -558,6 +615,8 @@ pub mod parameters { pub backup: Option, /// Automatically resolve cloud conflicts by performing an upload or download. pub resolve_cloud_conflict: Option, + /// Wine/Proton prefix for restoring portable Windows saves on Linux. + pub wine_prefix: Option, /// Process disabled games. pub include_disabled: bool, /// Skip a game when its backup is newer than the live data. diff --git a/src/cli.rs b/src/cli.rs index 9d3884ec..6525de98 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,12 +22,20 @@ use crate::{ report::{self, Reporter, report_cloud_changes}, resource::{ResourceFile, SaveableResourceFile, cache::Cache, config::Config, manifest::Manifest}, scan::{ - BackupId, DuplicateDetector, Launchers, OperationStepDecision, ScanKind, SteamShortcuts, TitleFinder, + BackupId, DuplicateDetector, Launchers, OperationStepDecision, ScanInfo, ScanKind, SteamShortcuts, TitleFinder, TitleQuery, layout::BackupLayout, prepare_backup_target, scan_game_for_backup, }, + semantic::{ + materialize::{MaterializeTarget, discover_wine_prefix, resolve_wine_prefix_for_game}, + prefix::{ValidatedPrefix, validate_prefix}, + }, + semantic::preview::{SemanticPreviewAnalysis, will_start_new_semantic_full_backup}, wrap, }; +#[cfg(target_os = "windows")] +use crate::semantic::materialize::known_folders_from_common_path; + const PROGRESS_BAR_REFRESH_INTERVAL: Duration = Duration::from_millis(50); pub fn show_error(games: &[String], error: &Error, gui: bool, force: bool) { @@ -317,7 +325,13 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) config.restore.reverse_redirects, &steam_shortcuts, config.backup.only_constructive, + config.backup.semantic_paths, ); + let mut scan_info = scan_info; + if preview { + scan_info.will_start_new_semantic_full_backup = + will_start_new_semantic_full_backup(&layout, &scan_info); + } let ignored = !&config.is_game_enabled_for_backup(name) && !games_specified && !include_disabled; let decision = if ignored { OperationStepDecision::Ignored @@ -444,18 +458,25 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) info.reverse(); } - for (name, scan_info, backup_info, decision) in info { + for (name, scan_info, backup_info, decision) in &info { if !reporter.add_game( name, - &scan_info, + scan_info, backup_info.as_ref(), - &decision, + decision, &duplicate_detector, dump_registry, ) { failed = true; } } + if preview { + let analysis_inputs: Vec<_> = info.iter().map(|(name, scan_info, _, _)| (*name, scan_info)).collect(); + let analysis = SemanticPreviewAnalysis::from_backup_preview(&config, &layout, &analysis_inputs); + if !analysis.is_empty() { + eprint!("{}", TRANSLATOR.semantic_preview(&analysis)); + } + } reporter.print(&backup_dir); } Subcommand::Restore { @@ -463,6 +484,7 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) path, force, no_force_cloud_conflict, + wine_prefix, api, gui, sort, @@ -566,9 +588,59 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) } } + // Pre-build Windows materialization state for semantic backup restoration. + #[cfg(target_os = "windows")] + let kf = known_folders_from_common_path(); + #[cfg(target_os = "windows")] + let win_target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + #[cfg(not(target_os = "windows"))] + let linux_wine_prefix: Option = if let Some(ref wp) = wine_prefix { + validate_prefix(wp) + } else { + let roots: Vec = config.roots.iter().map(|r| r.path().clone()).collect(); + discover_wine_prefix(&roots) + }; + #[cfg(not(target_os = "windows"))] + let _linux_wine_target = linux_wine_prefix.as_ref().map(|prefix| MaterializeTarget::WinePrefix { + prefix, + wine_user: &prefix.wine_user, + drive_mappings: &config.restore.drive_mappings, + }); + let step = |i, name| { log::trace!("step {i} / {}: {name}", games.len()); let mut layout = layout.game_layout(name); + + #[cfg(target_os = "windows")] + let materialize_target: Option<&MaterializeTarget> = Some(&win_target); + #[cfg(not(target_os = "windows"))] + let linux_wine_prefix = match resolve_wine_prefix_for_game(&config, name, wine_prefix.as_ref()) { + Ok(prefix) => prefix, + Err(error) => { + log::trace!("step {i} completed (wine prefix conflict)"); + let display_title = config.display_name(name); + return Some(( + display_title, + ScanInfo { + game_name: name.to_string(), + has_backups: layout.has_backups(), + ..Default::default() + }, + Default::default(), + OperationStepDecision::Processed, + Some(Err(error)), + )); + } + }; + #[cfg(not(target_os = "windows"))] + let linux_wine_target = linux_wine_prefix.as_ref().map(|prefix| MaterializeTarget::WinePrefix { + prefix, + wine_user: &prefix.wine_user, + drive_mappings: &config.restore.drive_mappings, + }); + #[cfg(not(target_os = "windows"))] + let materialize_target: Option<&MaterializeTarget> = linux_wine_target.as_ref(); + let scan_info = layout.scan_for_restoration( name, backup_id.as_ref().unwrap_or(&BackupId::Latest), @@ -576,6 +648,7 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) config.restore.reverse_redirects, &config.restore.toggled_paths, &config.restore.toggled_registry, + materialize_target, ); let ignored = !&config.is_game_enabled_for_restore(name) && !games_specified && !include_disabled; let decision = if ignored { @@ -621,7 +694,13 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) None } else { let display_title = config.display_name(name); - Some((display_title, scan_info, restore_info, decision, None)) + Some(( + display_title, + scan_info, + restore_info, + decision, + None::>, + )) } }; @@ -1179,6 +1258,7 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool) games: vec![game_name.clone()], force: true, no_force_cloud_conflict: no_force_cloud_conflict || !force, + wine_prefix: Default::default(), preview, path: path.clone(), api: Default::default(), diff --git a/src/cli/parse.rs b/src/cli/parse.rs index a8455876..7f43de35 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -262,6 +262,12 @@ pub enum Subcommand { #[clap(long)] no_force_cloud_conflict: bool, + /// Wine/Proton prefix for restoring Windows saves on Linux. + /// This should be a folder with a "drive_c" subfolder. + /// Required for restoring semantic (portable) Windows backups on Linux. + #[clap(long, value_parser = parse_strict_path)] + wine_prefix: Option, + /// Print information to stdout in machine-readable JSON. /// This replaces the default, human-readable output. #[clap(long)] @@ -1139,6 +1145,7 @@ mod tests { path: None, force: false, no_force_cloud_conflict: false, + wine_prefix: None, api: false, gui: false, sort: None, @@ -1190,6 +1197,7 @@ mod tests { )), force: true, no_force_cloud_conflict: true, + wine_prefix: None, api: true, gui: false, sort: Some(CliSort::Name), @@ -1235,6 +1243,7 @@ mod tests { path: None, force: false, no_force_cloud_conflict: false, + wine_prefix: None, api: false, gui: false, sort: Some(sort), diff --git a/src/gui/app.rs b/src/gui/app.rs index f14ca928..fd38ea30 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -37,8 +37,13 @@ use crate::{ BackupId, Launchers, ScanKind, SteamShortcuts, TitleFinder, game_filter, layout::BackupLayout, prepare_backup_target, registry::RegistryItem, scan_game_for_backup, }, + semantic::materialize::{MaterializeTarget, resolve_wine_prefix_without_cli}, + semantic::preview::will_start_new_semantic_full_backup, }; +#[cfg(target_os = "windows")] +use crate::semantic::materialize::known_folders_from_common_path; + pub struct Executor(tokio::runtime::Runtime); impl iced::Executor for Executor { @@ -529,7 +534,13 @@ impl App { config.restore.reverse_redirects, &steam_shortcuts, config.backup.only_constructive, + config.backup.semantic_paths, ); + let mut scan_info = scan_info; + if preview { + scan_info.will_start_new_semantic_full_backup = + will_start_new_semantic_full_backup(&layout, &scan_info); + } if !config.is_game_enabled_for_backup(&key) && !single { return (Some(scan_info), None); } @@ -853,12 +864,16 @@ impl App { let config = std::sync::Arc::new(self.config.clone()); let layout = std::sync::Arc::new(layout); + #[cfg(target_os = "windows")] + let kf = known_folders_from_common_path(); for name in restorables { let config = config.clone(); let layout = layout.clone(); let cancel_flag = self.operation_should_cancel.clone(); let backup_id = self.backups_to_restore.get(&name).cloned().unwrap_or(BackupId::Latest); + #[cfg(target_os = "windows")] + let kf = kf.clone(); self.operation_steps.push(OperationStep { title: name.clone(), task: Task::perform( @@ -869,6 +884,24 @@ impl App { return (None, None, layout); } + #[cfg(target_os = "windows")] + let win_target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + #[cfg(target_os = "windows")] + let materialize_target: Option<&MaterializeTarget> = Some(&win_target); + #[cfg(not(target_os = "windows"))] + let linux_wine_prefix = resolve_wine_prefix_without_cli(&config, &name); + #[cfg(not(target_os = "windows"))] + let empty_drive_mappings = std::collections::HashMap::new(); + #[cfg(not(target_os = "windows"))] + let linux_wine_target = + linux_wine_prefix.as_ref().map(|prefix| MaterializeTarget::WinePrefix { + prefix, + wine_user: &prefix.wine_user, + drive_mappings: &empty_drive_mappings, + }); + #[cfg(not(target_os = "windows"))] + let materialize_target: Option<&MaterializeTarget> = linux_wine_target.as_ref(); + let scan_info = layout.scan_for_restoration( &name, &backup_id, @@ -876,6 +909,7 @@ impl App { config.restore.reverse_redirects, &config.restore.toggled_paths, &config.restore.toggled_registry, + materialize_target, ); if !config.is_game_enabled_for_restore(&name) && !single { return (Some(scan_info), None, layout); diff --git a/src/gui/file_tree.rs b/src/gui/file_tree.rs index efb73e4e..907aec74 100644 --- a/src/gui/file_tree.rs +++ b/src/gui/file_tree.rs @@ -199,6 +199,14 @@ impl FileTreeNode { }) }) }) + .push({ + self.scanned_file.as_ref().and_then(|(_, scanned)| { + scanned + .semantic_key + .as_ref() + .map(|semantic| Badge::new(&TRANSLATOR.badge_portable(&semantic.serialize())).view()) + }) + }) .push({ self.scanned_file.as_ref().map(|(_, f)| { let size = TRANSLATOR.adjusted_size(f.size); diff --git a/src/gui/game_list.rs b/src/gui/game_list.rs index e2c58e3a..7104fa6a 100644 --- a/src/gui/game_list.rs +++ b/src/gui/game_list.rs @@ -31,6 +31,7 @@ use crate::{ scan::{ BackupInfo, DuplicateDetector, OperationStatus, ScanChange, ScanInfo, ScanKind, game_filter, layout::GameLayout, }, + semantic::preview, }; #[derive(Default)] @@ -71,6 +72,11 @@ impl GameListEntry { let changes = self.scan_info.overall_change(); let duplication = duplicate_detector.is_game_duplicated(&self.scan_info.game_name); let display_name = config.display_name(&self.scan_info.game_name); + let invalid_prefixes = if scan_kind.is_backup() { + preview::invalid_configured_prefixes_for_scan(config, display_name, &self.scan_info) + } else { + Vec::new() + }; Container::new( Column::new() @@ -165,6 +171,45 @@ impl GameListEntry { .view() }) .push_if(!successful, || Badge::new(&TRANSLATOR.badge_failed()).view()) + .push_if(self.scan_info.has_semantic_keys(), || { + Badge::new(&TRANSLATOR.portable_label().to_uppercase()).view() + }) + .push_if(self.scan_info.will_start_new_semantic_full_backup, || { + Badge::new(&TRANSLATOR.new_full_backup_label().to_uppercase()) + .tooltip(TRANSLATOR.semantic_format_switch_notice()) + .view() + }) + .push_if(!self.scan_info.semantic_conflicts().is_empty(), || { + Badge::new(&TRANSLATOR.semantic_conflict_label().to_uppercase()) + .tooltip( + self.scan_info + .semantic_conflicts() + .iter() + .map(|conflict| { + TRANSLATOR.semantic_key_conflict(&conflict.semantic_key.serialize()) + }) + .collect::>() + .join("\n"), + ) + .view() + }) + .push_if(!invalid_prefixes.is_empty(), || { + Badge::new(&TRANSLATOR.invalid_prefix_label().to_uppercase()) + .tooltip( + invalid_prefixes + .iter() + .map(|prefix| { + format!( + "{} ({})", + TRANSLATOR.semantic_prefix_invalid(&prefix.path), + prefix.reason + ) + }) + .collect::>() + .join("\n"), + ) + .view() + }) .push({ self.scan_info .backup diff --git a/src/lang.rs b/src/lang.rs index c4c028c7..dd998294 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -13,6 +13,7 @@ use crate::{ manifest::Store, }, scan::{BackupError, OperationStatus, OperationStepDecision, ScanChange, game_filter}, + semantic::preview::SemanticPreviewAnalysis, }; const PATH: &str = "path"; @@ -30,6 +31,10 @@ const MESSAGE: &str = "message"; const APP: &str = "app"; const GAME: &str = "game"; const VERSION: &str = "version"; +const DRIVE: &str = "drive"; +const LEGACY: &str = "legacy"; +const SEMANTIC: &str = "semantic"; +const KEY: &str = "key"; pub const TRANSLATOR: Translator = Translator {}; pub const ADD_SYMBOL: &str = "+"; @@ -402,6 +407,7 @@ impl Translator { Error::CliUnableToRequestConfirmation => self.cli_unable_to_request_confirmation(), Error::CliBackupIdWithMultipleGames => self.cli_backup_id_with_multiple_games(), Error::CliInvalidBackupId => self.cli_invalid_backup_id(), + Error::WinePrefixConflict { game, cli, configured } => self.wine_prefix_conflict(game, cli, configured), Error::NoSaveDataFound => self.notify_single_game_status(false), Error::GameIsUnrecognized => self.game_is_unrecognized(), Error::SomeEntriesFailed => self.some_entries_failed(), @@ -485,6 +491,17 @@ impl Translator { translate("cli-invalid-backup-id") } + pub fn wine_prefix_conflict(&self, game: &str, cli: &StrictPath, configured: &StrictPath) -> String { + let mut args = FluentArgs::new(); + args.set(GAME, game); + let primary = translate_args("wine-prefix-conflict", &args); + args.set(PATH, cli.render()); + let cli = translate_args("wine-prefix-conflict-cli", &args); + args.set(PATH, configured.render()); + let configured = translate_args("wine-prefix-conflict-configured", &args); + format!("{}\n{}\n{}", self.prefix_error(&primary), cli, configured) + } + pub fn cloud_not_configured(&self) -> String { translate("cloud-not-configured") } @@ -561,6 +578,28 @@ impl Translator { translate_args("badge-redirecting-to", &args) } + pub fn badge_portable(&self, path: &str) -> String { + let mut args = FluentArgs::new(); + args.set(PATH, path); + translate_args("badge-portable", &args) + } + + pub fn portable_label(&self) -> String { + translate("label-portable") + } + + pub fn new_full_backup_label(&self) -> String { + translate("label-new-full-backup") + } + + pub fn semantic_conflict_label(&self) -> String { + translate("label-portable-conflict") + } + + pub fn invalid_prefix_label(&self) -> String { + translate("label-invalid-prefix") + } + pub fn cli_game_header( &self, name: &str, @@ -639,10 +678,87 @@ impl Translator { format!(" - {}", translate_args("cli-game-line-item-redirecting", &args),) } + pub fn cli_game_line_item_portable(&self, item: &str) -> String { + let mut args = FluentArgs::new(); + args.set(PATH, item); + format!(" - {}", translate_args("cli-game-line-item-portable", &args),) + } + pub fn cli_game_line_item_error(&self, error: &BackupError) -> String { format!(" - {}", error.message()) } + pub fn semantic_format_switch_notice(&self) -> String { + translate("semantic-format-switch-notice") + } + + pub fn semantic_prefix_invalid(&self, path: &str) -> String { + let mut args = FluentArgs::new(); + args.set(PATH, path); + translate_args("semantic-prefix-invalid", &args) + } + + pub fn semantic_drive_missing(&self, drive: char) -> String { + let mut args = FluentArgs::new(); + args.set(DRIVE, drive.to_string()); + translate_args("semantic-drive-missing", &args) + } + + pub fn semantic_key_conflict(&self, key: &str) -> String { + let mut args = FluentArgs::new(); + args.set(KEY, key); + translate_args("semantic-key-conflict", &args) + } + + pub fn semantic_preview_would_become(&self, legacy: &str, semantic: &str) -> String { + let mut args = FluentArgs::new(); + args.set(LEGACY, legacy); + args.set(SEMANTIC, semantic); + translate_args("semantic-preview-would-become", &args) + } + + pub fn semantic_preview(&self, analysis: &SemanticPreviewAnalysis) -> String { + let mut lines = Vec::new(); + + for game in &analysis.new_full_chains { + lines.push(self.prefix_warning(&format!("{game}: {}", self.semantic_format_switch_notice()))); + } + + for migration in &analysis.migrations { + lines.push(format!( + "{}: {}", + migration.game_name, + self.semantic_preview_would_become(&migration.legacy_key, &migration.semantic_key) + )); + } + + for prefix in &analysis.invalid_prefixes { + lines.push(self.prefix_warning(&format!( + "{}: {} ({})", + prefix.game_name, + self.semantic_prefix_invalid(&prefix.path), + prefix.reason + ))); + } + + for conflict in &analysis.conflicts { + lines.push(self.prefix_error(&format!( + "{}: {}", + conflict.game_name, + self.semantic_key_conflict(&conflict.semantic_key) + ))); + for path in &conflict.physical_paths { + lines.push(format!(" - {path}")); + } + } + + if lines.is_empty() { + "".to_string() + } else { + format!("{}\n", lines.join("\n")) + } + } + pub fn cli_summary(&self, status: &OperationStatus, location: &StrictPath) -> String { let new_games = if status.changed_games.new > 0 { format!(" [{}{}]", crate::lang::ADD_SYMBOL, status.changed_games.new) diff --git a/src/lib.rs b/src/lib.rs index 406cb9fb..43058b9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod prelude; pub mod report; pub mod resource; pub mod scan; +pub mod semantic; pub mod serialization; pub mod wrap; diff --git a/src/main.rs b/src/main.rs index eb95a728..37b9c794 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use ludusavi::{ lang::{self, TRANSLATOR}, metadata, path, prelude::{self, CONFIG_DIR, VERSION, app_dir}, - report, resource, scan, wrap, + report, resource, scan, semantic, wrap, }; /// The logger handle must be retained until the application closes. diff --git a/src/prelude.rs b/src/prelude.rs index 2518eebc..97811cc8 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -119,6 +119,11 @@ pub enum Error { UnableToConfigureCloud(CommandError), UnableToSynchronizeCloud(CommandError), CloudConflict, + WinePrefixConflict { + game: String, + cli: Box, + configured: Box, + }, GameDidNotLaunch { why: String, }, diff --git a/src/report.rs b/src/report.rs index 649a8107..e1b016fb 100644 --- a/src/report.rs +++ b/src/report.rs @@ -11,6 +11,7 @@ use crate::{ BackupError, BackupInfo, DuplicateDetector, OperationStatus, OperationStepDecision, ScanChange, ScanInfo, TitleMatch, compare_ranked_titles_ref, layout::Backup, registry, }, + semantic::preview::SemanticPreviewAnalysis, }; #[derive(Debug, Default, serde::Serialize, schemars::JsonSchema)] @@ -95,6 +96,9 @@ pub struct ApiFile { /// then this is its location within the backup. #[serde(skip_serializing_if = "Option::is_none")] pub redirected_path: Option, + /// Portable semantic identity used for cross-platform backups, if available. + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic_key: Option, /// Any other games that also have the same file path. #[serde(skip_serializing_if = "BTreeSet::is_empty")] pub duplicated_by: BTreeSet, @@ -212,6 +216,9 @@ pub struct ApiOutput { /// Populated by the `cloud` commands. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub cloud: BTreeMap, + /// Portable backup changes detected during preview. + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic_preview: Option, } #[derive(Debug, Default, serde::Serialize, schemars::JsonSchema)] @@ -250,6 +257,7 @@ impl Reporter { overall: Some(Default::default()), games: Default::default(), cloud: Default::default(), + semantic_preview: None, }, } } @@ -293,6 +301,21 @@ impl Reporter { }); } + pub fn add_semantic_preview(&mut self, analysis: SemanticPreviewAnalysis) { + match self { + Self::Standard { parts, .. } => { + if !analysis.is_empty() { + parts.push(TRANSLATOR.semantic_preview(&analysis)); + } + } + Self::Json { output } => { + if !analysis.is_empty() { + output.semantic_preview = Some(analysis); + } + } + } + } + pub fn suppress_overall(&mut self) { match self { Self::Standard { status, .. } => { @@ -357,6 +380,10 @@ impl Reporter { } } + if let Some(semantic) = &entry.semantic_key { + parts.push(TRANSLATOR.cli_game_line_item_portable(&semantic.serialize())); + } + if let Some(error) = backup_info.as_ref().and_then(|x| x.failed_files.get(scan_key)) { parts.push(TRANSLATOR.cli_game_line_item_error(error)); } @@ -429,6 +456,7 @@ impl Reporter { .and_then(|x| x.failed_files.get(scan_key).map(SaveError::from)), ignored: entry.ignored, change: entry.change(), + semantic_key: entry.semantic_key.as_ref().map(|x| x.serialize()), ..Default::default() }; if !duplicate_detector.is_file_duplicated(scan_key, entry).resolved() { @@ -666,6 +694,7 @@ pub fn report_cloud_changes(changes: &[CloudChange], api: bool) { overall: None, games: Default::default(), cloud: Default::default(), + semantic_preview: None, }; output.cloud = changes @@ -693,6 +722,7 @@ mod tests { use super::*; use crate::{ scan::{BackupError, ScannedFile, ScannedRegistry}, + semantic::SemanticPath, testing::s, }; @@ -736,6 +766,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, "/file2".into(): ScannedFile { size: 51_200, @@ -745,6 +778,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! { @@ -805,6 +841,9 @@ Overall: change: ScanChange::Same, container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! {}, @@ -828,6 +867,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! {}, @@ -873,6 +915,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, "/backup/file2".into(): ScannedFile { size: 51_200, @@ -882,6 +927,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! {}, @@ -1197,6 +1245,70 @@ Overall: ); } + #[test] + fn json_mode_includes_semantic_key_separately_from_physical_path() { + let mut reporter = Reporter::json(); + + reporter.add_game( + "foo", + &ScanInfo { + game_name: s("foo"), + found_files: hash_map! { + "/home/deck/prefix/drive_c/users/steamuser/Documents/Game/save.dat".into(): ScannedFile { + size: 4, + hash: "hash".to_string(), + semantic_key: Some(SemanticPath::parse("/Game/save.dat").unwrap()), + ..Default::default() + }, + }, + ..Default::default() + }, + None, + &OperationStepDecision::Processed, + &DuplicateDetector::default(), + false, + ); + let output: serde_json::Value = + serde_json::from_str(&reporter.render(&StrictPath::new(s("/dev/null")))).unwrap(); + + assert_eq!( + "/Game/save.dat", + output["games"]["foo"]["files"]["/home/deck/prefix/drive_c/users/steamuser/Documents/Game/save.dat"] + ["semanticKey"] + .as_str() + .unwrap() + ); + } + + #[test] + fn standard_mode_shows_semantic_key_separately_from_physical_path() { + let mut reporter = Reporter::standard(); + + reporter.add_game( + "foo", + &ScanInfo { + game_name: s("foo"), + found_files: hash_map! { + "/home/deck/prefix/drive_c/users/steamuser/Documents/Game/save.dat".into(): ScannedFile { + size: 4, + hash: "hash".to_string(), + semantic_key: Some(SemanticPath::parse("/Game/save.dat").unwrap()), + ..Default::default() + }, + }, + ..Default::default() + }, + None, + &OperationStepDecision::Processed, + &DuplicateDetector::default(), + false, + ); + + let output = reporter.render(&StrictPath::new(s("/dev/null"))); + assert!(output.contains("/home/deck/prefix/drive_c/users/steamuser/Documents/Game/save.dat")); + assert!(output.contains("Stored as portable save location: /Game/save.dat")); + } + #[test] fn can_render_in_json_mode_with_one_game_in_restore_mode() { let mut reporter = Reporter::json(); @@ -1214,6 +1326,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, "/backup/file2".into(): ScannedFile { size: 50, @@ -1223,6 +1338,9 @@ Overall: change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! {}, diff --git a/src/resource/config.rs b/src/resource/config.rs index eb24d0f4..e45bf65e 100644 --- a/src/resource/config.rs +++ b/src/resource/config.rs @@ -1125,6 +1125,9 @@ pub struct BackupConfig { pub format: BackupFormats, /// Don't create a new backup if there are only removed saves and no new/edited ones. pub only_constructive: bool, + /// Use portable semantic paths for Windows/Wine saves instead of absolute paths. + /// When enabled, backups are cross-platform between Windows and Wine. + pub semantic_paths: bool, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] @@ -1134,10 +1137,33 @@ pub struct RestoreConfig { pub path: StrictPath, /// Names of games to skip when restoring. pub ignored_games: BTreeSet, + /// Preferred Wine/Proton prefixes for restoring portable Windows saves on non-Windows systems. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub preferred_wine_prefixes: BTreeMap, pub toggled_paths: ToggledPaths, pub toggled_registry: ToggledRegistry, pub sort: Sort, pub reverse_redirects: bool, + /// Global Wine prefix for restoring Windows semantic backups on Linux. + /// Used as a fallback when no game-specific prefix is configured. + pub wine_prefix: Option, + /// Manual drive letter mappings for WinDrive semantic keys + /// when dosdevices symlinks are not available. Keys are lowercase + /// drive letters (a-z), values are target paths. + pub drive_mappings: HashMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(default, rename_all = "camelCase")] +pub struct GameWinePrefixPreference { + /// Wine/Proton prefix to use for this game when restoring portable Windows saves. + pub path: StrictPath, + /// Preferred Wine user inside the prefix, if the prefix has multiple user profiles. + #[serde(skip_serializing_if = "Option::is_none")] + pub wine_user: Option, + /// Optional non-C drive mappings for this game. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub drive_mappings: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] @@ -1362,6 +1388,7 @@ impl Default for BackupConfig { retention: Retention::default(), format: Default::default(), only_constructive: Default::default(), + semantic_paths: false, } } } @@ -1371,10 +1398,13 @@ impl Default for RestoreConfig { Self { path: default_backup_dir(), ignored_games: BTreeSet::new(), + preferred_wine_prefixes: Default::default(), toggled_paths: Default::default(), toggled_registry: Default::default(), sort: Default::default(), reverse_redirects: false, + wine_prefix: None, + drive_mappings: HashMap::new(), } } } @@ -1811,6 +1841,12 @@ impl Config { let cwd = StrictPath::cwd(); self.backup.path.rebase(&cwd); self.restore.path.rebase(&cwd); + for preference in self.restore.preferred_wine_prefixes.values_mut() { + preference.path.rebase(&cwd); + for path in preference.drive_mappings.values_mut() { + path.rebase(&cwd); + } + } } } @@ -2124,14 +2160,18 @@ mod tests { retention: Retention::default(), format: Default::default(), only_constructive: false, + semantic_paths: false, }, restore: RestoreConfig { path: StrictPath::relative(s("~/restore"), Some(StrictPath::cwd().render())), ignored_games: BTreeSet::new(), + preferred_wine_prefixes: Default::default(), toggled_paths: Default::default(), toggled_registry: Default::default(), sort: Default::default(), reverse_redirects: false, + wine_prefix: None, + drive_mappings: HashMap::new(), }, scan: Default::default(), apps: Apps { @@ -2255,6 +2295,7 @@ mod tests { retention: Retention::default(), format: Default::default(), only_constructive: true, + semantic_paths: false, }, restore: RestoreConfig { path: StrictPath::relative(s("~/restore"), Some(StrictPath::cwd().render())), @@ -2262,10 +2303,13 @@ mod tests { s("Restore Game 1"), s("Restore Game 2"), }, + preferred_wine_prefixes: Default::default(), toggled_paths: Default::default(), toggled_registry: Default::default(), sort: Default::default(), reverse_redirects: false, + wine_prefix: None, + drive_mappings: HashMap::new(), }, scan: Scan { show_deselected_games: false, @@ -2381,6 +2425,7 @@ backup: zstd: level: 10 onlyConstructive: false + semanticPaths: false restore: path: ~/restore ignoredGames: @@ -2393,6 +2438,8 @@ restore: key: status reversed: false reverseRedirects: false + winePrefix: ~ + driveMappings: {} scan: showDeselectedGames: false showUnchangedGames: false @@ -2474,6 +2521,7 @@ customGames: retention: Retention::default(), format: Default::default(), only_constructive: false, + semantic_paths: false, }, restore: RestoreConfig { path: StrictPath::new(s("~/restore")), @@ -2482,10 +2530,13 @@ customGames: s("Restore Game 1"), s("Restore Game 2"), }, + preferred_wine_prefixes: Default::default(), toggled_paths: Default::default(), toggled_registry: Default::default(), sort: Default::default(), reverse_redirects: false, + wine_prefix: None, + drive_mappings: HashMap::new(), }, scan: Scan { show_deselected_games: false, diff --git a/src/scan.rs b/src/scan.rs index b1e40d91..8789d3bd 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -39,11 +39,22 @@ use crate::{ manifest::{Game, GameFileEntry, IdSet, Os, Store}, }, scan::layout::LatestBackup, + scan::saves::ScanOrigin, + semantic::{ + convert::{derive_from_manifest_origin, expected_base_from_manifest, wine_physical_to_semantic}, + prefix::{ValidatedPrefix, validate_prefix}, + }, }; #[cfg(target_os = "windows")] use crate::scan::registry::RegistryItem; +#[cfg(target_os = "windows")] +use crate::semantic::{ + convert::{KnownFolders, windows_physical_to_semantic}, + materialize::known_folders_from_common_path, +}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScanKind { Backup, @@ -519,6 +530,7 @@ pub fn scan_game_for_backup( reverse_redirects_on_restore: bool, steam_shortcuts: &SteamShortcuts, only_constructive_backups: bool, + semantic_paths_enabled: bool, ) -> ScanInfo { log::trace!("[{name}] beginning scan for backup"); @@ -529,7 +541,44 @@ pub fn scan_game_for_backup( let mut dumped_registry = None; let has_backups = previous.is_some(); - let mut paths_to_check = HashSet::<(StrictPath, Option)>::new(); + // Collect Wine prefixes for semantic key derivation. + let wine_prefixes: Vec = { + let mut prefixes = Vec::new(); + for wp in &game.wine_prefix { + if wp.trim().is_empty() { + continue; + } + if let Some(prefix) = validate_prefix(&StrictPath::new(wp)) { + prefixes.push(prefix); + } + } + if let Some(prefix) = wine_prefix.and_then(validate_prefix) { + prefixes.push(prefix); + } + for root in roots { + for wp in launchers.get_game(root, name).filter_map(|x| x.prefix.as_ref()) { + if let Some(prefix) = validate_prefix(wp) { + prefixes.push(prefix); + } + let pfx = wp.joined("pfx"); + if let Some(prefix) = validate_prefix(&pfx) { + prefixes.push(prefix); + } + } + } + prefixes + }; + + // Cache known folders on Windows for semantic key derivation. + #[cfg(target_os = "windows")] + let known_folders: Option = if semantic_paths_enabled { + Some(known_folders_from_common_path()) + } else { + None + }; + + #[allow(clippy::type_complexity)] + let mut paths_to_check: HashMap, Vec<(String, Store)>)> = HashMap::new(); // Add a dummy root for checking paths without ``. let mut roots_to_check: Vec = vec![Root::new(SKIP, Store::Other)]; @@ -630,38 +679,52 @@ pub fn scan_game_for_backup( for (candidate, case_sensitive) in candidates { log::trace!("[{name}] parsed candidate: {candidate:?}"); - paths_to_check.insert((candidate, Some(case_sensitive))); + paths_to_check + .entry(candidate) + .or_insert((Some(case_sensitive), Vec::new())) + .1 + .push((raw_path.clone(), root.store())); } } if root.store() == Store::Steam { for id in all_ids.steam(steam_shortcut.map(|x| x.id)) { // Cloud saves: - paths_to_check.insert(( - StrictPath::relative( + paths_to_check + .entry(StrictPath::relative( format!("{}/userdata/*/{}/remote/", &root_globbable, id), Some(manifest_dir_globbable.clone()), - ), - None, - )); + )) + .or_insert((None, Vec::new())) + .1 + .push(( + "/userdata///remote".to_string(), + Store::Steam, + )); // Screenshots: if !filter.exclude_store_screenshots { - paths_to_check.insert(( - StrictPath::relative( + paths_to_check + .entry(StrictPath::relative( format!("{}/userdata/*/760/remote/{}/screenshots/*.*", &root_globbable, id), Some(manifest_dir_globbable.clone()), - ), - None, - )); + )) + .or_insert((None, Vec::new())) + .1 + .push(( + "/userdata//760/remote//screenshots/*.*".to_string(), + Store::Steam, + )); } // Registry: if !game.registry.is_empty() { let prefix = format!("{}/steamapps/compatdata/{}/pfx", &root_globbable, id); - paths_to_check.insert(( - StrictPath::relative(format!("{prefix}/*.reg"), Some(manifest_dir_globbable.clone())), - None, - )); + paths_to_check + .entry(StrictPath::relative( + format!("{prefix}/*.reg"), + Some(manifest_dir_globbable.clone()), + )) + .or_insert((None, Vec::new())); } } } @@ -679,7 +742,7 @@ pub fn scan_game_for_backup( }) .unwrap_or_default(); - for (path, case_sensitive) in paths_to_check { + for (path, (case_sensitive, origins)) in paths_to_check { log::trace!("[{name}] checking: {path:?}"); if filter.is_path_ignored(&path) { log::debug!("[{name}] excluded: {path:?}"); @@ -705,6 +768,70 @@ pub fn scan_game_for_backup( let redirected = game_file_target(&scan_key, redirects, reverse_redirects_on_restore, ScanKind::Backup); let change = ScanChange::evaluate_backup(&hash, previous_files.get(redirected.as_ref().unwrap_or(&scan_key))); + let (semantic_key, scan_origin) = if semantic_paths_enabled { + let mut key = None; + let mut origin = None; + // 1. Try manifest-derived first + for (manifest_path, store) in &origins { + if let Some(expected_base) = expected_base_from_manifest(manifest_path, *store) { + #[allow(unused_mut)] + let mut tail: Option = None; + #[cfg(target_os = "windows")] + { + if let Some(ref kf) = known_folders { + tail = windows_physical_to_semantic(&scan_key, kf) + .filter(|s| s.base == expected_base) + .map(|s| s.tail); + } + } + let tail = tail.or_else(|| { + for prefix in &wine_prefixes { + if let Some(semantic) = + wine_physical_to_semantic(&scan_key, &prefix.path, &prefix.wine_user) + && semantic.base == expected_base + { + return Some(semantic.tail); + } + } + None + }); + if let Some(tail) = tail { + let scan_origin = ScanOrigin { + manifest_path: manifest_path.clone(), + store: *store, + expanded_prefix: "".to_string(), + matched_prefix_len: 0, + tail: tail.clone(), + }; + if let Some(derived_key) = derive_from_manifest_origin(&scan_origin) { + key = Some(derived_key); + origin = Some(scan_origin); + break; + } + } + } + } + // 2. Fall back to reverse mapping + if key.is_none() { + #[cfg(target_os = "windows")] + { + if let Some(ref kf) = known_folders { + key = windows_physical_to_semantic(&scan_key, kf); + } + } + if key.is_none() { + for prefix in &wine_prefixes { + if let Some(k) = wine_physical_to_semantic(&scan_key, &prefix.path, &prefix.wine_user) { + key = Some(k); + break; + } + } + } + } + (key, origin) + } else { + (None, None) + }; found_files.insert( scan_key, ScannedFile { @@ -715,6 +842,9 @@ pub fn scan_game_for_backup( original_path: None, ignored, container: None, + origin: scan_origin, + semantic_key, + restore_error: None, }, ); } else if p.is_dir() { @@ -750,6 +880,72 @@ pub fn scan_game_for_backup( &hash, previous_files.get(redirected.as_ref().unwrap_or(&scan_key)), ); + let (semantic_key, scan_origin) = if semantic_paths_enabled { + let mut key = None; + let mut origin = None; + // 1. Try manifest-derived first + for (manifest_path, store) in &origins { + if let Some(expected_base) = expected_base_from_manifest(manifest_path, *store) { + #[allow(unused_mut)] + let mut tail: Option = None; + #[cfg(target_os = "windows")] + { + if let Some(ref kf) = known_folders { + tail = windows_physical_to_semantic(&scan_key, kf) + .filter(|s| s.base == expected_base) + .map(|s| s.tail); + } + } + let tail = tail.or_else(|| { + for prefix in &wine_prefixes { + if let Some(semantic) = + wine_physical_to_semantic(&scan_key, &prefix.path, &prefix.wine_user) + && semantic.base == expected_base + { + return Some(semantic.tail); + } + } + None + }); + if let Some(tail) = tail { + let scan_origin = ScanOrigin { + manifest_path: manifest_path.clone(), + store: *store, + expanded_prefix: "".to_string(), + matched_prefix_len: 0, + tail: tail.clone(), + }; + if let Some(derived_key) = derive_from_manifest_origin(&scan_origin) { + key = Some(derived_key); + origin = Some(scan_origin); + break; + } + } + } + } + // 2. Fall back to reverse mapping + if key.is_none() { + #[cfg(target_os = "windows")] + { + if let Some(ref kf) = known_folders { + key = windows_physical_to_semantic(&scan_key, kf); + } + } + if key.is_none() { + for prefix in &wine_prefixes { + if let Some(k) = + wine_physical_to_semantic(&scan_key, &prefix.path, &prefix.wine_user) + { + key = Some(k); + break; + } + } + } + } + (key, origin) + } else { + (None, None) + }; found_files.insert( scan_key, ScannedFile { @@ -760,6 +956,9 @@ pub fn scan_game_for_backup( original_path: None, ignored, container: None, + origin: scan_origin, + semantic_key, + restore_error: None, }, ); } @@ -786,6 +985,13 @@ pub fn scan_game_for_backup( if !current_files.contains(&previous_file_interpreted) && !current_files_with_redirects.contains(&previous_file_interpreted) { + // Preserve semantic key from previous backup if available. + let previous_semantic_key = previous.and_then(|prev| { + prev.scan + .found_files + .get(previous_file) + .and_then(|f| f.semantic_key.clone()) + }); found_files.insert( previous_file.to_owned(), ScannedFile { @@ -796,6 +1002,9 @@ pub fn scan_game_for_backup( original_path: None, ignored: ignored_paths.is_ignored(name, previous_file), container: None, + origin: None, + semantic_key: previous_semantic_key, + restore_error: None, }, ); } @@ -894,18 +1103,22 @@ pub fn scan_game_for_backup( has_backups, dumped_registry, only_constructive_backups, + will_start_new_semantic_full_backup: false, + ..Default::default() } } +type PathToCheck = HashMap, Vec<(String, Store)>)>; + fn scan_game_for_backup_add_prefix( roots_to_check: &mut Vec, - paths_to_check: &mut HashSet<(StrictPath, Option)>, + paths_to_check: &mut PathToCheck, wp: &StrictPath, has_registry: bool, ) { roots_to_check.push(Root::new(wp.clone(), Store::OtherWine)); if has_registry { - paths_to_check.insert((wp.joined("*.reg"), None)); + paths_to_check.entry(wp.joined("*.reg")).or_insert((None, Vec::new())); } } @@ -1191,6 +1404,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); @@ -1218,6 +1432,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1249,6 +1464,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1268,6 +1484,9 @@ mod tests { change: ScanChange::New, container: None, redirected: Some(StrictPath::new(format!("{}/tests/root3/game5/data-symlink/file1.txt", repo()))), + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: hash_map! {}, @@ -1292,6 +1511,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1323,6 +1543,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1368,6 +1589,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1403,6 +1625,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1437,6 +1660,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1467,6 +1691,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1497,6 +1722,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1535,6 +1761,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1575,6 +1802,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1615,6 +1843,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1664,6 +1893,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1722,6 +1952,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1853,6 +2084,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1890,6 +2122,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1926,6 +2159,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } @@ -1963,7 +2197,7 @@ mod tests { &title, roots, &StrictPath::new(repo()), - &Launchers::scan_dirs(roots, &manifest, &[title.clone()]), + &Launchers::scan_dirs(roots, &manifest, std::slice::from_ref(&title)), &BackupFilter::default(), None, &ToggledPaths::default(), @@ -1973,6 +2207,7 @@ mod tests { false, &Default::default(), ONLY_CONSTRUCTIVE, + false, ), ); } diff --git a/src/scan/duplicate.rs b/src/scan/duplicate.rs index 25add875..2532a2e9 100644 --- a/src/scan/duplicate.rs +++ b/src/scan/duplicate.rs @@ -464,6 +464,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }; let scan_key_1b = StrictPath::from("file1b.txt"); let file1b = ScannedFile { @@ -474,6 +477,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }; detector.add_game( @@ -516,6 +522,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, } ) ); @@ -543,6 +552,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, } ) ); diff --git a/src/scan/layout.rs b/src/scan/layout.rs index fcee3270..1534b217 100644 --- a/src/scan/layout.rs +++ b/src/scan/layout.rs @@ -18,6 +18,8 @@ use crate::{ BackupError, BackupId, BackupInfo, ScanChange, ScanInfo, ScanKind, ScannedFile, game_file_target, prepare_backup_target, registry, }, + semantic::SemanticPath, + semantic::materialize::{MaterializeTarget, materialize_semantic}, }; #[cfg_attr(not(target_os = "windows"), allow(unused))] @@ -233,6 +235,28 @@ impl ToString for Backup { } } +/// Format of the file paths stored in a backup chain. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathFormat { + /// Legacy absolute paths (default for existing backups). + #[default] + Legacy, + /// Semantic portable paths (cross-platform compatible). + SemanticV1, +} + +/// Format of registry data stored in a backup chain. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RegistryFormat { + /// Default/legacy behavior. + #[default] + Default, + /// Cross-platform registry transfer is not yet supported. + Unsupported, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct FullBackup { @@ -245,11 +269,25 @@ pub struct FullBackup { /// Locked backups do not count toward retention limits and are never deleted. #[serde(skip_serializing_if = "std::ops::Not::not")] pub locked: bool, + /// Path format for this backup chain. + #[serde(skip_serializing_if = "is_default_path_format")] + pub path_format: PathFormat, + /// Registry format for this backup chain. + #[serde(skip_serializing_if = "is_default_registry_format")] + pub registry_format: RegistryFormat, pub files: BTreeMap, pub registry: IndividualMappingRegistry, pub children: VecDeque, } +fn is_default_path_format(f: &PathFormat) -> bool { + *f == PathFormat::Legacy +} + +fn is_default_registry_format(f: &RegistryFormat) -> bool { + *f == RegistryFormat::Default +} + impl FullBackup { pub fn label(&self) -> String { chrono::DateTime::::from(self.when) @@ -611,6 +649,7 @@ impl GameLayout { reverse_redirects_on_restore: bool, toggled_paths: &ToggledPaths, only_constructive_backups: bool, + materialize_target: Option<&MaterializeTarget>, ) -> Option { if self.mapping.backups.is_empty() { None @@ -623,6 +662,7 @@ impl GameLayout { redirects, reverse_redirects_on_restore, toggled_paths, + materialize_target, ), // Registry is handled separately. found_registry_keys: Default::default(), @@ -632,6 +672,8 @@ impl GameLayout { // Registry is handled separately. dumped_registry: None, only_constructive_backups, + will_start_new_semantic_full_backup: false, + ..Default::default() }) } } @@ -656,6 +698,7 @@ impl GameLayout { redirects: &[RedirectConfig], reverse_redirects_on_restore: bool, toggled_paths: &ToggledPaths, + materialize_target: Option<&MaterializeTarget>, ) -> HashMap { let mut files = HashMap::new(); @@ -668,15 +711,18 @@ impl GameLayout { redirects, reverse_redirects_on_restore, toggled_paths, + materialize_target, )); } Some((full, Some(diff))) => { files.extend(self.restorable_files_from_diff_backup( + full.path_format, diff, scan_kind, redirects, reverse_redirects_on_restore, toggled_paths, + materialize_target, )); for (scan_key, full_file) in self.restorable_files_from_full_backup( @@ -685,9 +731,14 @@ impl GameLayout { redirects, reverse_redirects_on_restore, toggled_paths, + materialize_target, ) { - let original_path = full_file.original_path.as_ref().unwrap().render(); - if diff.file(original_path) == BackupInclusion::Inherited { + let mapping_key = full_file + .semantic_key + .as_ref() + .map(|semantic| semantic.serialize()) + .unwrap_or_else(|| full_file.original_path.as_ref().unwrap().render()); + if diff.file(mapping_key) == BackupInclusion::Inherited { files.insert(scan_key, full_file); } } @@ -704,11 +755,36 @@ impl GameLayout { redirects: &[RedirectConfig], reverse_redirects_on_restore: bool, toggled_paths: &ToggledPaths, + materialize_target: Option<&MaterializeTarget>, ) -> HashMap { let mut restorables = HashMap::new(); + let is_semantic = backup.path_format == PathFormat::SemanticV1; for (mapping_key, v) in &backup.files { - let original_path = StrictPath::new(mapping_key.to_string()); + let semantic_key = if is_semantic { + SemanticPath::parse(mapping_key).ok() + } else { + None + }; + + let mut restore_error = None; + let original_path = if let Some(ref sk) = semantic_key { + if let Some(target) = materialize_target { + match materialize_semantic(sk, target) { + Ok(physical) => physical, + Err(error) => { + restore_error = Some(error.to_string()); + StrictPath::new(sk.serialize()) + } + } + } else { + restore_error = Some("No semantic restore target is available".to_string()); + StrictPath::new(sk.serialize()) + } + } else { + StrictPath::new(mapping_key.to_string()) + }; + let redirected = game_file_target( &original_path, redirects, @@ -718,9 +794,18 @@ impl GameLayout { let ignorable_path = redirected.as_ref().unwrap_or(&original_path); match backup.format() { BackupFormat::Simple => { - let scan_key = self - .mapping - .game_file_immutable(&self.path, &original_path, &backup.name); + let scan_key = if is_semantic { + // For semantic backups, use the storage path as the scan key + if let Some(ref sk) = semantic_key { + self.path.joined(&backup.name).joined(sk.storage_path()) + } else { + self.mapping + .game_file_immutable(&self.path, &original_path, &backup.name) + } + } else { + self.mapping + .game_file_immutable(&self.path, &original_path, &backup.name) + }; restorables.insert( scan_key, @@ -737,11 +822,22 @@ impl GameLayout { redirected, original_path: Some(original_path), container: None, + origin: None, + semantic_key, + restore_error: restore_error.clone(), }, ); } BackupFormat::Zip => { - let scan_key = StrictPath::new(self.mapping.game_file_for_zip_immutable(&original_path)); + let scan_key = if is_semantic { + if let Some(ref sk) = semantic_key { + StrictPath::new(sk.storage_path()) + } else { + StrictPath::new(self.mapping.game_file_for_zip_immutable(&original_path)) + } + } else { + StrictPath::new(self.mapping.game_file_for_zip_immutable(&original_path)) + }; restorables.insert( scan_key, @@ -758,6 +854,9 @@ impl GameLayout { redirected, original_path: Some(original_path), container: Some(self.path.joined(&backup.name)), + origin: None, + semantic_key, + restore_error, }, ); } @@ -769,17 +868,42 @@ impl GameLayout { fn restorable_files_from_diff_backup( &self, + path_format: PathFormat, backup: &DifferentialBackup, scan_kind: ScanKind, redirects: &[RedirectConfig], reverse_redirects_on_restore: bool, toggled_paths: &ToggledPaths, + materialize_target: Option<&MaterializeTarget>, ) -> HashMap { let mut restorables = HashMap::new(); + let is_semantic = path_format == PathFormat::SemanticV1; for (mapping_key, v) in &backup.files { let v = some_or_continue!(v); - let original_path = StrictPath::new(mapping_key.to_string()); + let semantic_key = if is_semantic { + SemanticPath::parse(mapping_key).ok() + } else { + None + }; + + let mut restore_error = None; + let original_path = if let Some(ref sk) = semantic_key { + if let Some(target) = materialize_target { + match materialize_semantic(sk, target) { + Ok(physical) => physical, + Err(error) => { + restore_error = Some(error.to_string()); + StrictPath::new(sk.serialize()) + } + } + } else { + restore_error = Some("No semantic restore target is available".to_string()); + StrictPath::new(sk.serialize()) + } + } else { + StrictPath::new(mapping_key.to_string()) + }; let redirected = game_file_target( &original_path, redirects, @@ -789,9 +913,12 @@ impl GameLayout { let ignorable_path = redirected.as_ref().unwrap_or(&original_path); match backup.format() { BackupFormat::Simple => { - let scan_key = self - .mapping - .game_file_immutable(&self.path, &original_path, &backup.name); + let scan_key = if let Some(ref sk) = semantic_key { + self.path.joined(&backup.name).joined(sk.storage_path()) + } else { + self.mapping + .game_file_immutable(&self.path, &original_path, &backup.name) + }; restorables.insert( scan_key, @@ -808,11 +935,18 @@ impl GameLayout { redirected, original_path: Some(original_path), container: None, + origin: None, + semantic_key, + restore_error: restore_error.clone(), }, ); } BackupFormat::Zip => { - let scan_key = StrictPath::new(self.mapping.game_file_for_zip_immutable(&original_path)); + let scan_key = if let Some(ref sk) = semantic_key { + StrictPath::new(sk.storage_path()) + } else { + StrictPath::new(self.mapping.game_file_for_zip_immutable(&original_path)) + }; restorables.insert( scan_key, @@ -829,6 +963,9 @@ impl GameLayout { redirected, original_path: Some(original_path), container: Some(self.path.joined(&backup.name)), + origin: None, + semantic_key, + restore_error, }, ); } @@ -878,6 +1015,9 @@ impl GameLayout { ignored: false, container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, ); } @@ -984,7 +1124,7 @@ impl GameLayout { return None; } - let kind = self.plan_backup_kind(retention); + let kind = self.plan_backup_kind(retention, scan); let backup = match kind { BackupKind::Full => Backup::Full(self.plan_full_backup(scan, now, format, retention)), @@ -996,11 +1136,19 @@ impl GameLayout { backup.needed().then_some(backup) } - fn plan_backup_kind(&self, retention: Retention) -> BackupKind { + fn plan_backup_kind(&self, retention: Retention, scan: &ScanInfo) -> BackupKind { if retention.force_new_full { return BackupKind::Full; } + // If existing chain is legacy but scan has semantic keys, force a new full backup. + if scan.has_semantic_keys() + && let Some(last_full) = self.mapping.backups.back() + && last_full.path_format == PathFormat::Legacy + { + return BackupKind::Full; + } + let fulls = self.mapping.backups.iter().filter(|full| !full.locked).count() as u8; let diffs = self .mapping @@ -1026,8 +1174,16 @@ impl GameLayout { let mut files = BTreeMap::new(); #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] let mut registry = IndividualMappingRegistry::default(); + let semantic_conflicts = scan.semantic_conflicts(); for (scan_key, file) in scan.found_files.iter().filter(|(_, x)| !x.ignored) { + if file.semantic_key.as_ref().is_some_and(|semantic| { + semantic_conflicts + .iter() + .any(|conflict| conflict.semantic_key.eq_semantic(semantic)) + }) { + continue; + } match file.change() { ScanChange::New | ScanChange::Different | ScanChange::Same => { files.insert( @@ -1056,6 +1212,12 @@ impl GameLayout { os: Some(Os::HOST), comment: None, locked: false, + path_format: if scan.has_semantic_keys() { + PathFormat::SemanticV1 + } else { + PathFormat::Legacy + }, + registry_format: RegistryFormat::Default, files, registry, children: VecDeque::new(), @@ -1072,8 +1234,16 @@ impl GameLayout { let mut files = BTreeMap::new(); #[cfg_attr(not(target_os = "windows"), allow(unused_mut))] let mut registry = Some(IndividualMappingRegistry::default()); + let semantic_conflicts = scan.semantic_conflicts(); for (scan_key, file) in &scan.found_files { + if file.semantic_key.as_ref().is_some_and(|semantic| { + semantic_conflicts + .iter() + .any(|conflict| conflict.semantic_key.eq_semantic(semantic)) + }) { + continue; + } match file.change() { ScanChange::New | ScanChange::Different | ScanChange::Same => { files.insert( @@ -1144,9 +1314,12 @@ impl GameLayout { continue; } - let target_file = self - .mapping - .game_file(&self.path, file.effective(scan_key), backup.name()); + let target_file = match &file.semantic_key { + Some(semantic) => self.path.joined(backup.name()).joined(semantic.storage_path()), + None => self + .mapping + .game_file(&self.path, file.effective(scan_key), backup.name()), + }; if scan_key.same_content(&target_file) { log::info!( "[{}] already matches: {:?} -> {:?}", @@ -1232,7 +1405,10 @@ impl GameLayout { continue; } - let target_file_id = self.mapping.game_file_for_zip(file.effective(scan_key)); + let target_file_id = match &file.semantic_key { + Some(semantic) => semantic.storage_path(), + None => self.mapping.game_file_for_zip(file.effective(scan_key)), + }; let mtime = match scan_key.get_mtime_zip() { Ok(x) => x, @@ -1402,13 +1578,27 @@ impl GameLayout { } fn execute_backup(&mut self, backup: &Backup, scan: &ScanInfo, format: &BackupFormats) -> BackupInfo { - if backup.only_inherits_and_overrides() { + let mut backup_info = if backup.only_inherits_and_overrides() { BackupInfo::default() } else { match format.chosen { BackupFormat::Simple => self.execute_backup_as_simple(backup, scan), BackupFormat::Zip => self.execute_backup_as_zip(backup, scan, format), } + }; + self.add_semantic_conflict_failures(scan, &mut backup_info); + backup_info + } + + fn add_semantic_conflict_failures(&self, scan: &ScanInfo, backup_info: &mut BackupInfo) { + for conflict in scan.semantic_conflicts() { + let error = BackupError::Raw(format!( + "Multiple files map to the same portable save location: {}", + conflict.semantic_key.serialize() + )); + for physical in &conflict.physical_paths { + backup_info.failed_files.insert(physical.clone(), error.clone()); + } } } @@ -1582,6 +1772,11 @@ impl GameLayout { !self.mapping.backups.is_empty() } + /// Return the path format of the latest full backup chain. + pub fn latest_full_path_format(&self) -> Option { + self.mapping.latest_backup().map(|(full, _)| full.path_format) + } + pub fn scan_for_restoration( &mut self, name: &str, @@ -1590,6 +1785,7 @@ impl GameLayout { reverse_redirects_on_restore: bool, toggled_paths: &ToggledPaths, #[cfg_attr(not(target_os = "windows"), allow(unused))] toggled_registry: &ToggledRegistry, + materialize_target: Option<&MaterializeTarget>, ) -> ScanInfo { log::trace!("[{name}] beginning scan for restore"); @@ -1611,6 +1807,7 @@ impl GameLayout { redirects, reverse_redirects_on_restore, toggled_paths, + materialize_target, ); available_backups = self.restorable_backups_flattened(); backup = self.find_by_id_flattened(&id); @@ -1685,6 +1882,8 @@ impl GameLayout { has_backups, dumped_registry, only_constructive_backups: false, + will_start_new_semantic_full_backup: false, + ..Default::default() } } @@ -1717,6 +1916,18 @@ impl GameLayout { continue; } + if let Some(error) = &file.restore_error { + log::error!( + "[{}] cannot restore semantic path: {:?} -> {:?} | {}", + self.mapping.name, + scan_key, + &target, + error + ); + failed_files.insert(scan_key.clone(), BackupError::Raw(error.clone())); + continue; + } + if let Some(container) = file.container.as_ref() { if let Some(e) = failed_containers.get(container) { log::warn!( @@ -2029,16 +2240,40 @@ impl GameLayout { self.modify_backup(id, |x| x.locked = locked, |x| x.locked = locked); } + fn stored_simple_file_for_validation(&self, backup: &FullBackup, file: &str) -> Option { + match backup.path_format { + PathFormat::Legacy => { + let original_path = StrictPath::new(file.to_string()); + Some( + self.mapping + .game_file_immutable(&self.path, &original_path, &backup.name), + ) + } + PathFormat::SemanticV1 => SemanticPath::parse(file) + .ok() + .map(|semantic| self.path.joined(&backup.name).joined(semantic.storage_path())), + } + } + + fn stored_zip_file_for_validation(&self, backup: &FullBackup, file: &str) -> Option { + match backup.path_format { + PathFormat::Legacy => { + let original_path = StrictPath::new(file.to_string()); + Some(self.mapping.game_file_for_zip_immutable(&original_path)) + } + PathFormat::SemanticV1 => SemanticPath::parse(file).ok().map(|semantic| semantic.storage_path()), + } + } + /// Returns whether the backup is valid. pub fn validate(&self, backup_id: BackupId) -> bool { if let Some((backup, diff)) = self.find_by_id(&backup_id) { match backup.format() { BackupFormat::Simple => { for file in backup.files.keys() { - let original_path = StrictPath::new(file.to_string()); - let stored = self - .mapping - .game_file_immutable(&self.path, &original_path, &backup.name); + let Some(stored) = self.stored_simple_file_for_validation(backup, file) else { + return false; + }; if !stored.is_file() { #[cfg(test)] eprintln!("can't find {}", stored.render()); @@ -2055,8 +2290,9 @@ impl GameLayout { }; for file in backup.files.keys() { - let original_path = StrictPath::new(file.to_string()); - let stored = self.mapping.game_file_for_zip_immutable(&original_path); + let Some(stored) = self.stored_zip_file_for_validation(backup, file) else { + return false; + }; if archive.by_name(&stored).is_err() { #[cfg(test)] eprintln!("can't find {stored}"); @@ -2066,19 +2302,23 @@ impl GameLayout { } } - if let Some(backup) = diff { - match backup.format() { + if let Some(diff_backup) = diff { + match diff_backup.format() { BackupFormat::Simple => { - for (file, data) in &backup.files { + for (file, data) in &diff_backup.files { if data.is_none() { // File is deliberately omitted. continue; } - let original_path = StrictPath::new(file.to_string()); - let stored = self - .mapping - .game_file_immutable(&self.path, &original_path, &backup.name); + let diff_as_full = FullBackup { + name: diff_backup.name.clone(), + path_format: backup.path_format, + ..Default::default() + }; + let Some(stored) = self.stored_simple_file_for_validation(&diff_as_full, file) else { + return false; + }; if !stored.is_file() { #[cfg(test)] eprintln!("can't find {}", stored.render()); @@ -2087,21 +2327,26 @@ impl GameLayout { } } BackupFormat::Zip => { - let Ok(handle) = self.path.joined(&backup.name).open() else { + let Ok(handle) = self.path.joined(&diff_backup.name).open() else { return false; }; let Ok(mut archive) = zip::ZipArchive::new(handle) else { return false; }; - for (file, data) in &backup.files { + for (file, data) in &diff_backup.files { if data.is_none() { // File is deliberately omitted. continue; } - let original_path = StrictPath::new(file.to_string()); - let stored = self.mapping.game_file_for_zip_immutable(&original_path); + let diff_as_full = FullBackup { + path_format: backup.path_format, + ..Default::default() + }; + let Some(stored) = self.stored_zip_file_for_validation(&diff_as_full, file) else { + return false; + }; if archive.by_name(&stored).is_err() { #[cfg(test)] eprintln!("can't find {stored}"); @@ -2233,6 +2478,7 @@ impl BackupLayout { reverse_redirects_on_restore, toggled_paths, only_constructive, + None, ); scan.map(|scan| LatestBackup { scan, @@ -2433,7 +2679,10 @@ mod tests { #[test] fn can_plan_backup_kind_when_first_time() { let layout = GameLayout::default(); - assert_eq!(BackupKind::Full, layout.plan_backup_kind(Retention::default())); + assert_eq!( + BackupKind::Full, + layout.plan_backup_kind(Retention::default(), &ScanInfo::default()) + ); } #[test] @@ -2445,7 +2694,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Full, layout.plan_backup_kind(Retention::new(1, 0))); + assert_eq!( + BackupKind::Full, + layout.plan_backup_kind(Retention::new(1, 0), &ScanInfo::default()) + ); } #[test] @@ -2460,7 +2712,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Full, layout.plan_backup_kind(Retention::new(1, 0))); + assert_eq!( + BackupKind::Full, + layout.plan_backup_kind(Retention::new(1, 0), &ScanInfo::default()) + ); } #[test] @@ -2472,7 +2727,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Full, layout.plan_backup_kind(Retention::new(2, 0))); + assert_eq!( + BackupKind::Full, + layout.plan_backup_kind(Retention::new(2, 0), &ScanInfo::default()) + ); } #[test] @@ -2484,7 +2742,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Differential, layout.plan_backup_kind(Retention::new(1, 1))); + assert_eq!( + BackupKind::Differential, + layout.plan_backup_kind(Retention::new(1, 1), &ScanInfo::default()) + ); } #[test] @@ -2499,7 +2760,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Differential, layout.plan_backup_kind(Retention::new(1, 1))); + assert_eq!( + BackupKind::Differential, + layout.plan_backup_kind(Retention::new(1, 1), &ScanInfo::default()) + ); } #[test] @@ -2523,7 +2787,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Differential, layout.plan_backup_kind(Retention::new(2, 2))); + assert_eq!( + BackupKind::Differential, + layout.plan_backup_kind(Retention::new(2, 2), &ScanInfo::default()) + ); } #[test] @@ -2550,7 +2817,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Full, layout.plan_backup_kind(Retention::new(2, 2))); + assert_eq!( + BackupKind::Full, + layout.plan_backup_kind(Retention::new(2, 2), &ScanInfo::default()) + ); } #[test] @@ -2571,7 +2841,10 @@ mod tests { }, ..Default::default() }; - assert_eq!(BackupKind::Differential, layout.plan_backup_kind(Retention::new(1, 2))); + assert_eq!( + BackupKind::Differential, + layout.plan_backup_kind(Retention::new(1, 2), &ScanInfo::default()) + ); } #[test] @@ -2603,6 +2876,293 @@ mod tests { ); } + #[test] + fn execute_simple_semantic_backup_uses_semantic_storage_path() { + let temp = tempfile::tempdir().unwrap(); + let source = StrictPath::new(temp.path().join("source/save.dat").to_string_lossy().to_string()); + source.parent().unwrap().create_dirs().unwrap(); + std::fs::write(source.as_std_path_buf().unwrap(), "save").unwrap(); + + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let scan = ScanInfo { + found_files: hash_map! { + source.clone(): ScannedFile { + size: 4, + hash: source.sha1(), + change: ScanChange::New, + semantic_key: Some(semantic_key.clone()), + ..Default::default() + }, + }, + ..Default::default() + }; + let mut layout = GameLayout { + path: StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()), + mapping: IndividualMapping::new("game".to_string()), + }; + + let backup = + Backup::Full(layout.plan_full_backup(&scan, &now(), &BackupFormats::default(), Retention::default())); + let info = layout.execute_backup(&backup, &scan, &BackupFormats::default()); + + assert!(info.failed_files.is_empty()); + assert!( + layout + .path + .joined(backup.name()) + .joined(semantic_key.storage_path()) + .is_file() + ); + } + + #[test] + fn execute_zip_semantic_backup_uses_semantic_storage_path() { + let temp = tempfile::tempdir().unwrap(); + let source = StrictPath::new(temp.path().join("source/save.dat").to_string_lossy().to_string()); + source.parent().unwrap().create_dirs().unwrap(); + std::fs::write(source.as_std_path_buf().unwrap(), "save").unwrap(); + + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let scan = ScanInfo { + found_files: hash_map! { + source.clone(): ScannedFile { + size: 4, + hash: source.sha1(), + change: ScanChange::New, + semantic_key: Some(semantic_key.clone()), + ..Default::default() + }, + }, + ..Default::default() + }; + let mut layout = GameLayout { + path: StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()), + mapping: IndividualMapping::new("game".to_string()), + }; + layout.path.create_dirs().unwrap(); + let format = BackupFormats { + chosen: BackupFormat::Zip, + ..Default::default() + }; + + let backup = Backup::Full(layout.plan_full_backup(&scan, &now(), &format, Retention::default())); + let info = layout.execute_backup(&backup, &scan, &format); + let archive_file = layout.path.joined(backup.name()).open().unwrap(); + let mut archive = zip::ZipArchive::new(archive_file).unwrap(); + + assert!(info.failed_files.is_empty()); + assert!(archive.by_name(&semantic_key.storage_path()).is_ok()); + } + + #[test] + fn semantic_conflict_is_reported_as_backup_failure() { + let temp = tempfile::tempdir().unwrap(); + let source_a = StrictPath::new(temp.path().join("source-a/save.dat").to_string_lossy().to_string()); + let source_b = StrictPath::new(temp.path().join("source-b/save.dat").to_string_lossy().to_string()); + source_a.parent().unwrap().create_dirs().unwrap(); + source_b.parent().unwrap().create_dirs().unwrap(); + std::fs::write(source_a.as_std_path_buf().unwrap(), "a").unwrap(); + std::fs::write(source_b.as_std_path_buf().unwrap(), "b").unwrap(); + + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let scan = ScanInfo { + found_files: hash_map! { + source_a.clone(): ScannedFile { + size: 1, + hash: source_a.sha1(), + change: ScanChange::New, + semantic_key: Some(semantic_key.clone()), + ..Default::default() + }, + source_b.clone(): ScannedFile { + size: 1, + hash: source_b.sha1(), + change: ScanChange::New, + semantic_key: Some(semantic_key), + ..Default::default() + }, + }, + ..Default::default() + }; + let mut layout = GameLayout { + path: StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()), + mapping: IndividualMapping::new("game".to_string()), + }; + + let backup = + Backup::Full(layout.plan_full_backup(&scan, &now(), &BackupFormats::default(), Retention::default())); + let info = layout.execute_backup(&backup, &scan, &BackupFormats::default()); + + assert!(matches!(&backup, Backup::Full(full) if full.files.is_empty())); + assert_eq!(2, info.failed_files.len()); + assert!(info.failed_files.contains_key(&source_a)); + assert!(info.failed_files.contains_key(&source_b)); + } + + #[test] + fn restore_simple_semantic_backup_reads_from_semantic_storage_path() { + let temp = tempfile::tempdir().unwrap(); + let backup_root = StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()); + let prefix_root = StrictPath::new(temp.path().join("prefix").to_string_lossy().to_string()); + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let backup_name = "backup-1"; + let stored = backup_root.joined(backup_name).joined(semantic_key.storage_path()); + stored.parent().unwrap().create_dirs().unwrap(); + std::fs::write(stored.as_std_path_buf().unwrap(), "save").unwrap(); + + let layout = GameLayout { + path: backup_root, + mapping: IndividualMapping { + name: "game".to_string(), + backups: VecDeque::from(vec![FullBackup { + name: backup_name.to_string(), + when: past(), + path_format: PathFormat::SemanticV1, + files: btree_map! { + semantic_key.serialize(): IndividualMappingFile { + hash: stored.sha1(), + size: 4, + }, + }, + ..Default::default() + }]), + ..Default::default() + }, + }; + let prefix = crate::semantic::prefix::ValidatedPrefix { + path: prefix_root.clone(), + wine_user: "steamuser".to_string(), + has_drive_c: true, + drive_mappings: HashMap::new(), + }; + let empty_drive_mappings: HashMap = HashMap::new(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &empty_drive_mappings, + }; + let scan = ScanInfo { + game_name: "game".to_string(), + found_files: layout.restorable_files( + &BackupId::Latest, + ScanKind::Restore, + &[], + false, + &Default::default(), + Some(&target), + ), + backup: Some(Backup::Full(layout.mapping.backups[0].clone())), + has_backups: true, + ..Default::default() + }; + let restore_info = layout.restore(&scan, &Default::default()); + let restored = prefix_root.joined("drive_c/users/steamuser/Documents/Game/save.dat"); + + assert!(restore_info.failed_files.is_empty()); + assert_eq!( + "save", + std::fs::read_to_string(restored.as_std_path_buf().unwrap()).unwrap() + ); + } + + #[test] + fn semantic_restore_materialization_error_is_reported_as_failed_file() { + let temp = tempfile::tempdir().unwrap(); + let backup_root = StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()); + let prefix_root = StrictPath::new(temp.path().join("prefix").to_string_lossy().to_string()); + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let backup_name = "backup-1"; + let stored = backup_root.joined(backup_name).joined(semantic_key.storage_path()); + stored.parent().unwrap().create_dirs().unwrap(); + std::fs::write(stored.as_std_path_buf().unwrap(), "save").unwrap(); + + let layout = GameLayout { + path: backup_root, + mapping: IndividualMapping { + name: "game".to_string(), + backups: VecDeque::from(vec![FullBackup { + name: backup_name.to_string(), + when: past(), + path_format: PathFormat::SemanticV1, + files: btree_map! { + semantic_key.serialize(): IndividualMappingFile { + hash: stored.sha1(), + size: 4, + }, + }, + ..Default::default() + }]), + ..Default::default() + }, + }; + let prefix = crate::semantic::prefix::ValidatedPrefix { + path: prefix_root, + wine_user: "steamuser".to_string(), + has_drive_c: true, + drive_mappings: HashMap::new(), + }; + let empty_drive_mappings: HashMap = HashMap::new(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &empty_drive_mappings, + }; + let scan = ScanInfo { + game_name: "game".to_string(), + found_files: layout.restorable_files( + &BackupId::Latest, + ScanKind::Restore, + &[], + false, + &Default::default(), + Some(&target), + ), + backup: Some(Backup::Full(layout.mapping.backups[0].clone())), + has_backups: true, + ..Default::default() + }; + let restore_info = layout.restore(&scan, &Default::default()); + + assert_eq!(1, restore_info.failed_files.len()); + assert_eq!( + "Drive d is not available on the target", + restore_info.failed_files.values().next().unwrap().message() + ); + } + + #[test] + fn validate_simple_semantic_backup_checks_semantic_storage_path() { + let temp = tempfile::tempdir().unwrap(); + let backup_root = StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()); + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let backup_name = "backup-1"; + let stored = backup_root.joined(backup_name).joined(semantic_key.storage_path()); + stored.parent().unwrap().create_dirs().unwrap(); + std::fs::write(stored.as_std_path_buf().unwrap(), "save").unwrap(); + + let layout = GameLayout { + path: backup_root, + mapping: IndividualMapping { + name: "game".to_string(), + backups: VecDeque::from(vec![FullBackup { + name: backup_name.to_string(), + when: past(), + path_format: PathFormat::SemanticV1, + files: btree_map! { + semantic_key.serialize(): IndividualMappingFile { + hash: stored.sha1(), + size: 4, + }, + }, + ..Default::default() + }]), + ..Default::default() + }, + }; + + assert!(layout.validate(BackupId::Latest)); + } + #[test] #[cfg(target_os = "windows")] fn can_plan_full_backup_with_registry() { @@ -3138,6 +3698,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path("backup-1", "file2.txt"): ScannedFile { size: 2, @@ -3147,9 +3710,19 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, - layout.restorable_files(&BackupId::Latest, ScanKind::Backup, &[], false, &Default::default()), + layout.restorable_files( + &BackupId::Latest, + ScanKind::Backup, + &[], + false, + &Default::default(), + None + ), ); } @@ -3181,6 +3754,9 @@ mod tests { change: Default::default(), container: Some(make_path("backup-1.zip")), redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path_zip("file2.txt"): ScannedFile { size: 2, @@ -3190,9 +3766,19 @@ mod tests { change: Default::default(), container: Some(make_path("backup-1.zip")), redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, - layout.restorable_files(&BackupId::Latest, ScanKind::Backup, &[], false, &Default::default()), + layout.restorable_files( + &BackupId::Latest, + ScanKind::Backup, + &[], + false, + &Default::default(), + None + ), ); } @@ -3235,6 +3821,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path("backup-2", "changed.txt"): ScannedFile { size: 2, @@ -3244,6 +3833,9 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path("backup-2", "added.txt"): ScannedFile { size: 5, @@ -3253,9 +3845,19 @@ mod tests { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, - layout.restorable_files(&BackupId::Latest, ScanKind::Backup, &[], false, &Default::default()), + layout.restorable_files( + &BackupId::Latest, + ScanKind::Backup, + &[], + false, + &Default::default(), + None + ), ); } @@ -3298,6 +3900,9 @@ mod tests { change: Default::default(), container: Some(make_path("backup-1.zip")), redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path_zip("changed.txt"): ScannedFile { size: 2, @@ -3307,6 +3912,9 @@ mod tests { change: Default::default(), container: Some(make_path("backup-2.zip")), redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, make_restorable_path_zip("added.txt"): ScannedFile { size: 5, @@ -3316,9 +3924,19 @@ mod tests { change: Default::default(), container: Some(make_path("backup-2.zip")), redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, - layout.restorable_files(&BackupId::Latest, ScanKind::Backup, &[], false, &Default::default()), + layout.restorable_files( + &BackupId::Latest, + ScanKind::Backup, + &[], + false, + &Default::default(), + None + ), ); } } @@ -3349,6 +3967,81 @@ mod tests { ) } + #[test] + fn can_scan_semantic_differential_restore_with_materialized_target() { + let temp = tempfile::tempdir().unwrap(); + let backup_root = StrictPath::new(temp.path().join("backup/game").to_string_lossy().to_string()); + let prefix_root = StrictPath::new(temp.path().join("prefix").to_string_lossy().to_string()); + let semantic_key = SemanticPath::parse("/Game/save.dat").unwrap(); + let full_name = "backup-full"; + let diff_name = "backup-diff"; + let stored = backup_root.joined(diff_name).joined(semantic_key.storage_path()); + stored.parent().unwrap().create_dirs().unwrap(); + std::fs::write(stored.as_std_path_buf().unwrap(), "new").unwrap(); + + let mut layout = GameLayout { + path: backup_root.clone(), + mapping: IndividualMapping { + name: "game".to_string(), + backups: VecDeque::from(vec![FullBackup { + name: full_name.to_string(), + when: now(), + path_format: PathFormat::SemanticV1, + files: btree_map! { + semantic_key.serialize(): IndividualMappingFile { + hash: "old".to_string(), + size: 3, + }, + }, + children: VecDeque::from(vec![DifferentialBackup { + name: diff_name.to_string(), + when: now(), + files: btree_map! { + semantic_key.serialize(): Some(IndividualMappingFile { + hash: stored.sha1(), + size: 3, + }), + }, + ..Default::default() + }]), + ..Default::default() + }]), + ..Default::default() + }, + }; + let prefix = crate::semantic::prefix::ValidatedPrefix { + path: prefix_root.clone(), + wine_user: "steamuser".to_string(), + has_drive_c: true, + drive_mappings: HashMap::new(), + }; + let empty_drive_mappings: HashMap = HashMap::new(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &empty_drive_mappings, + }; + + let scan = layout.scan_for_restoration( + "game", + &BackupId::Named(diff_name.to_string()), + &[], + false, + &Default::default(), + &Default::default(), + Some(&target), + ); + + let restored = prefix_root.joined("drive_c/users/steamuser/Documents/Game/save.dat"); + let stored_key = backup_root.joined(diff_name).joined(semantic_key.storage_path()); + assert_eq!(1, scan.found_files.len()); + assert!(scan.found_files.contains_key(&stored_key)); + let scanned = scan.found_files.get(&stored_key).unwrap(); + assert_eq!(Some(semantic_key), scanned.semantic_key); + assert_eq!(Some(&restored), scanned.original_path.as_ref()); + assert_eq!(None, scanned.restore_error); + } + #[test] fn can_scan_game_for_restoration_with_files() { let mut layout = GameLayout::new( @@ -3395,6 +4088,9 @@ mod tests { change: ScanChange::New, container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, restorable_file_simple(SOLO, "file2.txt"): ScannedFile { size: 2, @@ -3404,6 +4100,9 @@ mod tests { change: ScanChange::New, container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, }, }, found_registry_keys: Default::default(), @@ -3412,6 +4111,8 @@ mod tests { has_backups: true, dumped_registry: None, only_constructive_backups: false, + will_start_new_semantic_full_backup: false, + ..Default::default() }, layout.scan_for_restoration( "game1", @@ -3419,7 +4120,8 @@ mod tests { &[], false, &Default::default(), - &Default::default() + &Default::default(), + None, ), ); } @@ -3472,6 +4174,8 @@ mod tests { }) })), only_constructive_backups: false, + will_start_new_semantic_full_backup: false, + ..Default::default() }, layout.scan_for_restoration( "game3", @@ -3479,7 +4183,8 @@ mod tests { &[], false, &Default::default(), - &Default::default() + &Default::default(), + None, ), ); } else { @@ -3507,6 +4212,8 @@ mod tests { has_backups: true, dumped_registry: None, only_constructive_backups: false, + will_start_new_semantic_full_backup: false, + ..Default::default() }, layout.scan_for_restoration( "game3", @@ -3514,7 +4221,8 @@ mod tests { &[], false, &Default::default(), - &Default::default() + &Default::default(), + None, ), ); } diff --git a/src/scan/preview.rs b/src/scan/preview.rs index 45fc4503..7cebf2f6 100644 --- a/src/scan/preview.rs +++ b/src/scan/preview.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::OnceLock; use crate::{ path::StrictPath, @@ -8,9 +9,10 @@ use crate::{ layout::Backup, registry::{self, RegistryItem}, }, + semantic::{self, SemanticPath}, }; -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default)] pub struct ScanInfo { pub game_name: String, /// The key is the actual location on disk. @@ -28,8 +30,28 @@ pub struct ScanInfo { pub dumped_registry: Option, /// Last known configuration. pub only_constructive_backups: bool, + /// Preview-only notice that this game will start a portable full backup chain. + pub will_start_new_semantic_full_backup: bool, + /// Lazily computed semantic conflicts. + pub cached_semantic_conflicts: OnceLock>, } +impl PartialEq for ScanInfo { + fn eq(&self, other: &Self) -> bool { + self.game_name == other.game_name + && self.found_files == other.found_files + && self.found_registry_keys == other.found_registry_keys + && self.available_backups == other.available_backups + && self.backup == other.backup + && self.has_backups == other.has_backups + && self.dumped_registry == other.dumped_registry + && self.only_constructive_backups == other.only_constructive_backups + && self.will_start_new_semantic_full_backup == other.will_start_new_semantic_full_backup + } +} + +impl Eq for ScanInfo {} + impl ScanInfo { pub fn sum_bytes(&self, backup_info: Option<&BackupInfo>) -> u64 { let successful_bytes = self @@ -74,6 +96,28 @@ impl ScanInfo { !self.found_files.is_empty() || !self.found_registry_keys.is_empty() } + /// Returns true if any found file has a semantic key derived. + pub fn has_semantic_keys(&self) -> bool { + self.found_files.values().any(|f| f.semantic_key.is_some()) + } + + pub fn semantic_conflicts(&self) -> &[semantic::conflict::SemanticConflict] { + self.cached_semantic_conflicts.get_or_init(|| { + let files = self + .found_files + .iter() + .map(|(path, file)| (path.clone(), file.semantic_key.clone())) + .collect(); + semantic::conflict::detect_conflicts(&files) + }) + } + + pub fn has_semantic_conflict(&self, semantic: &SemanticPath) -> bool { + self.semantic_conflicts() + .iter() + .any(|conflict| conflict.semantic_key.eq_semantic(semantic)) + } + pub fn found_anything_processable(&self) -> bool { match self.overall_change() { ScanChange::New => true, @@ -462,6 +506,8 @@ mod tests { found_files: hash_map! { "/new".into(): ScannedFile { redirected: Some(StrictPath::new("/old")), + origin: None, + semantic_key: None, change: ScanChange::New, ..Default::default() }, @@ -476,6 +522,8 @@ mod tests { found_files: hash_map! { "/new".into(): ScannedFile { redirected: Some(StrictPath::new("/old")), + origin: None, + semantic_key: None, change: ScanChange::New, ..Default::default() }, diff --git a/src/scan/saves.rs b/src/scan/saves.rs index 2c7923ea..82618023 100644 --- a/src/scan/saves.rs +++ b/src/scan/saves.rs @@ -2,9 +2,26 @@ use std::collections::BTreeMap; use crate::{ prelude::StrictPath, + resource::manifest::Store, scan::{ScanChange, ScanKind}, + semantic::SemanticPath, }; +/// Records the manifest origin of a scanned file, used for semantic key derivation. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct ScanOrigin { + /// The manifest placeholder path, e.g. `/Remedy/Alan Wake`. + pub manifest_path: String, + /// The store/root kind that provided this match. + pub store: Store, + /// The expanded prefix that was stripped to find the tail. + pub expanded_prefix: String, + /// The matched prefix length (characters stripped from the expanded path). + pub matched_prefix_len: usize, + /// The remaining tail after the matched prefix. + pub tail: String, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct ScannedFile { pub size: u64, @@ -16,6 +33,12 @@ pub struct ScannedFile { /// An enclosing archive file, if any, depending on the `BackupFormat`. pub container: Option, pub redirected: Option, + /// Origin metadata from manifest, used for semantic key derivation. + pub origin: Option, + /// The semantic key for this file, if derived. Used as the portable backup identity. + pub semantic_key: Option, + /// A restoration error detected while planning, before copying starts. + pub restore_error: Option, } impl ScannedFile { @@ -29,6 +52,9 @@ impl ScannedFile { change: Default::default(), container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, } } @@ -42,6 +68,9 @@ impl ScannedFile { change, container: None, redirected: None, + origin: None, + semantic_key: None, + restore_error: None, } } @@ -80,6 +109,9 @@ impl ScannedFile { /// This is stored in the mapping file. pub fn mapping_key(&self, scan_key: &StrictPath) -> String { + if let Some(ref semantic) = self.semantic_key { + return semantic.serialize(); + } self.effective(scan_key).render() } diff --git a/src/semantic.rs b/src/semantic.rs new file mode 100644 index 00000000..59c58f07 --- /dev/null +++ b/src/semantic.rs @@ -0,0 +1,377 @@ +pub mod conflict; +pub mod convert; +pub mod materialize; +pub mod prefix; +pub mod preview; +pub mod signals; + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Represents a portable semantic location category. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SemanticBase { + WinHome, + WinDocuments, + WinAppData, + WinLocalAppData, + WinLocalAppDataLow, + WinSavedGames, + WinPublic, + WinProgramData, + WinDir, + WinDrive(char), +} + +impl SemanticBase { + /// Whether equality comparisons for this base should be case-sensitive. + /// All Win* bases are case-insensitive; future Linux bases will be case-sensitive. + pub fn case_sensitive(&self) -> bool { + match self { + Self::WinHome + | Self::WinDocuments + | Self::WinAppData + | Self::WinLocalAppData + | Self::WinLocalAppDataLow + | Self::WinSavedGames + | Self::WinPublic + | Self::WinProgramData + | Self::WinDir + | Self::WinDrive(_) => false, + } + } + + /// Canonical display name for this base, used in serialization. + fn display_name(&self) -> String { + match self { + Self::WinHome => "winHome".to_string(), + Self::WinDocuments => "winDocuments".to_string(), + Self::WinAppData => "winAppData".to_string(), + Self::WinLocalAppData => "winLocalAppData".to_string(), + Self::WinLocalAppDataLow => "winLocalAppDataLow".to_string(), + Self::WinSavedGames => "winSavedGames".to_string(), + Self::WinPublic => "winPublic".to_string(), + Self::WinProgramData => "winProgramData".to_string(), + Self::WinDir => "winDir".to_string(), + Self::WinDrive(c) => format!("winDrive-{}", c.to_ascii_lowercase()), + } + } + + fn parse_name(s: &str) -> Option { + match s { + "winHome" => Some(Self::WinHome), + "winDocuments" => Some(Self::WinDocuments), + "winAppData" => Some(Self::WinAppData), + "winLocalAppData" => Some(Self::WinLocalAppData), + "winLocalAppDataLow" => Some(Self::WinLocalAppDataLow), + "winSavedGames" => Some(Self::WinSavedGames), + "winPublic" => Some(Self::WinPublic), + "winProgramData" => Some(Self::WinProgramData), + "winDir" => Some(Self::WinDir), + other => { + if let Some(rest) = other.strip_prefix("winDrive-") { + let chars: Vec = rest.chars().collect(); + if chars.len() == 1 && chars[0].is_ascii_alphabetic() { + return Some(Self::WinDrive(chars[0].to_ascii_lowercase())); + } + } + None + } + } + } +} + +impl Serialize for SemanticBase { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.display_name()) + } +} + +impl<'de> Deserialize<'de> for SemanticBase { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + SemanticBase::parse_name(&s) + .ok_or_else(|| serde::de::Error::custom(format!("unrecognized semantic base: {}", s))) + } +} + +/// Error type for semantic path parsing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SemanticPathError { + /// The string does not start with a recognized `` token. + MissingBase, + /// The tail is empty. + EmptyTail, + /// The tail contains a `.` or `..` component. + InvalidTailComponent, + /// The string is not a semantic key (e.g., it's a raw OS path). + NotSemanticKey, +} + +impl fmt::Display for SemanticPathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBase => write!(f, "missing recognized semantic base"), + Self::EmptyTail => write!(f, "empty tail path"), + Self::InvalidTailComponent => write!(f, "tail contains '.' or '..' component"), + Self::NotSemanticKey => write!(f, "not a semantic key"), + } + } +} + +impl std::error::Error for SemanticPathError {} + +/// A portable save-file identity. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SemanticPath { + pub base: SemanticBase, + /// Forward-slash separated, no leading slash. + pub tail: String, +} + +impl SemanticPath { + /// Parse a semantic path from the `/tail/path` format. + pub fn parse(s: &str) -> Result { + if !s.starts_with('<') { + return Err(SemanticPathError::NotSemanticKey); + } + + let end = s.find('>').ok_or(SemanticPathError::MissingBase)?; + let base_name = &s[1..end]; + let base = SemanticBase::parse_name(base_name).ok_or(SemanticPathError::MissingBase)?; + + let rest = &s[end + 1..]; + let tail = if rest.is_empty() { + return Err(SemanticPathError::EmptyTail); + } else { + rest.strip_prefix('/') + .ok_or(SemanticPathError::MissingBase)? + .to_string() + }; + + if tail.is_empty() { + return Err(SemanticPathError::EmptyTail); + } + + for component in tail.split('/') { + if component == "." || component == ".." { + return Err(SemanticPathError::InvalidTailComponent); + } + } + + Ok(Self { base, tail }) + } + + /// Canonical string form: `/tail/path`. + pub fn serialize(&self) -> String { + format!("<{}>/{}", self.base.display_name(), self.tail) + } + + /// Returns the safe backup storage path: `__ludusavi_semantic__//tail`. + pub fn storage_path(&self) -> String { + let base_name = self.base.display_name(); + let safe_tail = self.tail.replace('\\', "/"); + format!("__ludusavi_semantic__/{}/{}", base_name, safe_tail) + } + + /// Semantic equality that respects case policy of the base. + pub fn eq_semantic(&self, other: &Self) -> bool { + if self.base != other.base { + return false; + } + if self.base.case_sensitive() { + self.tail == other.tail + } else { + self.tail.eq_ignore_ascii_case(&other.tail) + } + } +} + +impl Serialize for SemanticPath { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.serialize()) + } +} + +impl<'de> Deserialize<'de> for SemanticPath { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + SemanticPath::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for SemanticPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.serialize()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_win_documents() { + let path = SemanticPath::parse("/Game/save.dat").unwrap(); + assert_eq!(path.base, SemanticBase::WinDocuments); + assert_eq!(path.tail, "Game/save.dat"); + assert_eq!(path.serialize(), "/Game/save.dat"); + } + + #[test] + fn round_trip_parse_serialize() { + let inputs = [ + "/Game/save.dat", + "/Game/config.ini", + "/Game/cache", + "/Game/data", + "/Game/profile", + "/Game/shared", + "/Game/telemetry", + "/System32/config", + "/MyGames/save.dat", + "/Games/save.dat", + ]; + for input in inputs { + let parsed = SemanticPath::parse(input).unwrap(); + let serialized = parsed.serialize(); + assert_eq!(serialized, input, "round-trip failed for: {}", input); + } + } + + #[test] + fn parse_rejects_without_base_prefix() { + assert_eq!( + SemanticPath::parse("C:/Users/Alice/Documents/Game/save.dat"), + Err(SemanticPathError::NotSemanticKey) + ); + } + + #[test] + fn parse_rejects_unrecognized_base() { + assert_eq!( + SemanticPath::parse("/Game/save.dat"), + Err(SemanticPathError::MissingBase) + ); + } + + #[test] + fn parse_rejects_empty_tail() { + assert_eq!(SemanticPath::parse(""), Err(SemanticPathError::EmptyTail)); + assert_eq!( + SemanticPath::parse("/"), + Err(SemanticPathError::EmptyTail) + ); + } + + #[test] + fn parse_rejects_dot_components() { + assert_eq!( + SemanticPath::parse("/../etc/passwd"), + Err(SemanticPathError::InvalidTailComponent) + ); + assert_eq!( + SemanticPath::parse("/./save.dat"), + Err(SemanticPathError::InvalidTailComponent) + ); + assert_eq!( + SemanticPath::parse("/Game/../save.dat"), + Err(SemanticPathError::InvalidTailComponent) + ); + } + + #[test] + fn storage_path_never_uses_backslash() { + let path = SemanticPath::parse("/Game/save.dat").unwrap(); + let storage = path.storage_path(); + assert!(!storage.contains('\\'), "storage path contains backslash: {}", storage); + assert_eq!(storage, "__ludusavi_semantic__/winDocuments/Game/save.dat"); + } + + #[test] + fn storage_path_for_all_bases() { + let cases = [ + ("/x", "__ludusavi_semantic__/winHome/x"), + ("/x", "__ludusavi_semantic__/winDocuments/x"), + ("/x", "__ludusavi_semantic__/winAppData/x"), + ("/x", "__ludusavi_semantic__/winLocalAppData/x"), + ("/x", "__ludusavi_semantic__/winLocalAppDataLow/x"), + ("/x", "__ludusavi_semantic__/winSavedGames/x"), + ("/x", "__ludusavi_semantic__/winPublic/x"), + ("/x", "__ludusavi_semantic__/winProgramData/x"), + ("/x", "__ludusavi_semantic__/winDir/x"), + ("/x", "__ludusavi_semantic__/winDrive-d/x"), + ]; + for (input, expected) in cases { + let path = SemanticPath::parse(input).unwrap(); + assert_eq!(path.storage_path(), expected, "storage_path failed for: {}", input); + } + } + + #[test] + fn eq_semantic_case_insensitive_for_win_documents() { + let a = SemanticPath::parse("/Game/Save.dat").unwrap(); + let b = SemanticPath::parse("/game/save.dat").unwrap(); + assert!(a.eq_semantic(&b)); + } + + #[test] + fn eq_semantic_case_insensitive_for_win_appdata() { + let a = SemanticPath::parse("/Game/Config.INI").unwrap(); + let b = SemanticPath::parse("/game/config.ini").unwrap(); + assert!(a.eq_semantic(&b)); + } + + #[test] + fn eq_semantic_different_bases_not_equal() { + let a = SemanticPath::parse("/Game/save.dat").unwrap(); + let b = SemanticPath::parse("/Game/save.dat").unwrap(); + assert!(!a.eq_semantic(&b)); + } + + #[test] + fn win_drive_serializes_with_lowercase_letter() { + let base = SemanticBase::WinDrive('D'); + assert_eq!(base.display_name(), "winDrive-d"); + } + + #[test] + fn win_drive_parses_case_insensitive() { + let base = SemanticBase::parse_name("winDrive-D").unwrap(); + assert_eq!(base, SemanticBase::WinDrive('d')); + } + + #[test] + fn serde_round_trip_all_variants() { + let variants = [ + SemanticBase::WinHome, + SemanticBase::WinDocuments, + SemanticBase::WinAppData, + SemanticBase::WinLocalAppData, + SemanticBase::WinLocalAppDataLow, + SemanticBase::WinSavedGames, + SemanticBase::WinPublic, + SemanticBase::WinProgramData, + SemanticBase::WinDir, + SemanticBase::WinDrive('d'), + SemanticBase::WinDrive('c'), + ]; + for variant in variants { + let json = serde_json::to_string(&variant).unwrap(); + let deserialized: SemanticBase = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, deserialized, "serde round-trip failed for: {:?}", variant); + } + } + + #[test] + fn semantic_path_serde_round_trip() { + let path = SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }; + let json = serde_json::to_string(&path).unwrap(); + let deserialized: SemanticPath = serde_json::from_str(&json).unwrap(); + assert_eq!(path, deserialized); + } +} diff --git a/src/semantic/conflict.rs b/src/semantic/conflict.rs new file mode 100644 index 00000000..39e81489 --- /dev/null +++ b/src/semantic/conflict.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; + +use crate::path::StrictPath; +use crate::semantic::SemanticPath; + +/// Represents a conflict where multiple physical files map to the same semantic key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SemanticConflict { + /// The semantic key that has multiple physical sources. + pub semantic_key: SemanticPath, + /// The physical paths that all map to this semantic key. + pub physical_paths: Vec, +} + +/// Detect duplicate semantic keys from distinct physical files. +/// Same file reached through multiple aliases (symlinks) is collapsed. +/// Distinct physical files with the same semantic key are reported as conflicts. +pub fn detect_conflicts(files: &HashMap>) -> Vec { + // Group files by semantic key using semantic equality + let mut groups: Vec<(SemanticPath, Vec)> = Vec::new(); + + for (physical, semantic) in files { + if let Some(sk) = semantic { + let mut found = false; + for (key, paths) in &mut groups { + if key.eq_semantic(sk) { + paths.push(physical.clone()); + found = true; + break; + } + } + if !found { + groups.push((sk.clone(), vec![physical.clone()])); + } + } + } + + // Find groups with multiple distinct physical files + let mut conflicts = Vec::new(); + for (semantic_key, paths) in groups { + if paths.len() > 1 { + // Check if they are the same file (by rendered path) or truly distinct. + // Use render() instead of interpret() to avoid filesystem dependency. + let unique: std::collections::HashSet = paths.iter().map(|p| p.render()).collect(); + + if unique.len() > 1 { + conflicts.push(SemanticConflict { + semantic_key, + physical_paths: paths, + }); + } + } + } + + conflicts +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantic::{SemanticBase, SemanticPath}; + + #[test] + fn no_conflict_with_single_file() { + let mut files = HashMap::new(); + files.insert( + StrictPath::new("/path/to/file1"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + let conflicts = detect_conflicts(&files); + assert!(conflicts.is_empty()); + } + + #[test] + fn conflict_with_two_distinct_files() { + let mut files = HashMap::new(); + files.insert( + StrictPath::new("/path/to/file1"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + files.insert( + StrictPath::new("/other/path/to/file2"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + let conflicts = detect_conflicts(&files); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].physical_paths.len(), 2); + } + + #[test] + fn no_conflict_with_same_file_via_alias() { + // If both paths resolve to the same file, no conflict + let mut files = HashMap::new(); + files.insert( + StrictPath::new("/path/to/./file1"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + files.insert( + StrictPath::new("/path/to/file1"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + let conflicts = detect_conflicts(&files); + // Both resolve to the same path, so no conflict + assert!(conflicts.is_empty()); + } + + #[test] + fn no_conflict_with_different_semantic_keys() { + let mut files = HashMap::new(); + files.insert( + StrictPath::new("/path/to/file1"), + Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail: "Game/save.dat".to_string(), + }), + ); + files.insert( + StrictPath::new("/path/to/file2"), + Some(SemanticPath { + base: SemanticBase::WinAppData, + tail: "Game/config.ini".to_string(), + }), + ); + let conflicts = detect_conflicts(&files); + assert!(conflicts.is_empty()); + } + + #[test] + fn no_conflict_for_files_without_semantic_key() { + let mut files = HashMap::new(); + files.insert(StrictPath::new("/path/to/file1"), None); + files.insert(StrictPath::new("/path/to/file2"), None); + let conflicts = detect_conflicts(&files); + assert!(conflicts.is_empty()); + } +} diff --git a/src/semantic/convert.rs b/src/semantic/convert.rs new file mode 100644 index 00000000..b1030aef --- /dev/null +++ b/src/semantic/convert.rs @@ -0,0 +1,767 @@ +use crate::path::StrictPath; +use crate::resource::manifest::Store; +use crate::semantic::{SemanticBase, SemanticPath}; + +/// Holds the physical paths of Windows known folders for semantic path derivation. +/// All paths should use `/` separators and not have trailing slashes. +#[derive(Clone, Debug, Default)] +pub struct KnownFolders { + pub saved_games: Option, + pub documents: Option, + pub local_app_data: Option, + pub app_data: Option, + pub public: Option, + pub program_data: Option, + pub windows: Option, + pub user_profile: Option, +} + +fn normalize_path(path: &str) -> String { + let p = path.replace('\\', "/"); + p.trim_end_matches('/').to_string() +} + +fn strip_prefix_case_insensitive(path: &str, prefix: &str) -> Option { + let path_norm = normalize_path(path); + let prefix_norm = normalize_path(prefix); + + if path_norm.len() > prefix_norm.len() + && path_norm.as_bytes()[prefix_norm.len()] == b'/' + && path_norm[..prefix_norm.len()].eq_ignore_ascii_case(&prefix_norm) + { + let tail = &path_norm[prefix_norm.len() + 1..]; + if !tail.is_empty() { + return Some(tail.to_string()); + } + } + None +} + +/// Convert a physical Windows path to a semantic path for the current user. +/// Returns None if the path cannot be semantically classified. +pub fn windows_physical_to_semantic(physical: &StrictPath, known_folders: &KnownFolders) -> Option { + let rendered = physical.render(); + + // Reject UNC paths + if rendered.starts_with("//") || rendered.starts_with(r"\\") { + return None; + } + + // Priority order per plan: + // 1. Saved Games + if let Some(ref sg) = known_folders.saved_games + && let Some(tail) = strip_prefix_case_insensitive(&rendered, sg) + { + return Some(SemanticPath { + base: SemanticBase::WinSavedGames, + tail, + }); + } + + // 2. Documents + if let Some(ref docs) = known_folders.documents + && let Some(tail) = strip_prefix_case_insensitive(&rendered, docs) + { + return Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail, + }); + } + + // 3. LocalAppData/Low (check before LocalAppData) + if let Some(ref local) = known_folders.local_app_data { + let low_path = format!("{}/Low", local); + if let Some(tail) = strip_prefix_case_insensitive(&rendered, &low_path) { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppDataLow, + tail, + }); + } + } + + // 4. LocalAppData + if let Some(ref local) = known_folders.local_app_data + && let Some(tail) = strip_prefix_case_insensitive(&rendered, local) + { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppData, + tail, + }); + } + + // 5. AppData (Roaming) + if let Some(ref roaming) = known_folders.app_data + && let Some(tail) = strip_prefix_case_insensitive(&rendered, roaming) + { + return Some(SemanticPath { + base: SemanticBase::WinAppData, + tail, + }); + } + + // 6. Public + if let Some(ref public) = known_folders.public + && let Some(tail) = strip_prefix_case_insensitive(&rendered, public) + { + return Some(SemanticPath { + base: SemanticBase::WinPublic, + tail, + }); + } + + // 7. ProgramData + if let Some(ref pd) = known_folders.program_data + && let Some(tail) = strip_prefix_case_insensitive(&rendered, pd) + { + return Some(SemanticPath { + base: SemanticBase::WinProgramData, + tail, + }); + } + + // 8. Windows directory + if let Some(ref win) = known_folders.windows + && let Some(tail) = strip_prefix_case_insensitive(&rendered, win) + { + return Some(SemanticPath { + base: SemanticBase::WinDir, + tail, + }); + } + + // 9. User profile home + if let Some(ref home) = known_folders.user_profile + && let Some(tail) = strip_prefix_case_insensitive(&rendered, home) + { + // Don't let broad user profile swallow paths that should have been + // handled by more specific bases. Check that the first tail component + // is not a known folder alias. + let first_component = tail.split('/').next().unwrap_or(""); + let lower = first_component.to_ascii_lowercase(); + if matches!( + lower.as_str(), + "documents" + | "my documents" + | "appdata" + | "application data" + | "local settings" + | "saved games" + | "desktop" + ) { + // Only swallow if the relevant KnownFolder check already proved + // this directory is NOT that semantic location. Since we already + // checked above and they didn't match, we should still NOT use + // WinHome for these known aliases. + // Fall through to drive-based classification. + } else { + return Some(SemanticPath { + base: SemanticBase::WinHome, + tail, + }); + } + } + + // 10. Drive-root classification + extract_drive_path(&rendered) +} + +fn extract_drive_path(rendered: &str) -> Option { + let norm = normalize_path(rendered); + + // Match patterns like "C:/..." or "C:\..." + let bytes = norm.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' { + let drive_letter = (bytes[0] as char).to_ascii_lowercase(); + let tail = &norm[3..]; // skip "C:/" + if !tail.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinDrive(drive_letter), + tail: tail.to_string(), + }); + } + } + None +} + +/// Convert a physical path inside a validated Wine prefix to a semantic path. +/// `prefix_path` is the validated prefix root (parent of drive_c). +/// `wine_user` is the detected Wine username for this prefix. +/// Uses the **lexical** prefix-relative path, NOT realpath. +pub fn wine_physical_to_semantic( + physical: &StrictPath, + prefix_path: &StrictPath, + wine_user: &str, +) -> Option { + let rendered = physical.render(); + let prefix_rendered = normalize_path(&prefix_path.render()); + let rendered_norm = normalize_path(&rendered); + + // Strip prefix to get prefix-relative path + let relative = if rendered_norm.len() > prefix_rendered.len() + && rendered_norm.as_bytes()[prefix_rendered.len()] == b'/' + && rendered_norm[..prefix_rendered.len()].eq_ignore_ascii_case(&prefix_rendered) + { + &rendered_norm[prefix_rendered.len() + 1..] + } else { + return None; + }; + + // Check if path is under drive_c/users// + let user_prefix = format!("drive_c/users/{}", wine_user.to_ascii_lowercase()); + let relative_lower = relative.to_ascii_lowercase(); + + if relative_lower.starts_with(&user_prefix) { + let after_user = &relative[user_prefix.len()..]; + if after_user.is_empty() || !after_user.starts_with('/') { + return None; + } + let sub_path = &after_user[1..]; // skip the '/' + + return classify_windows_user_subpath(sub_path); + } + + // Check if path is under drive_c/users/Public + let public_prefix = "drive_c/users/public"; + if relative_lower.starts_with(public_prefix) { + let after = &relative[public_prefix.len()..]; + if after.is_empty() || !after.starts_with('/') { + return None; + } + let tail = &after[1..]; + if !tail.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinPublic, + tail: tail.to_string(), + }); + } + return None; + } + + // Check if path is under drive_c/ProgramData + let pd_prefix = "drive_c/programdata"; + if relative_lower.starts_with(pd_prefix) { + let after = &relative[pd_prefix.len()..]; + if after.is_empty() || !after.starts_with('/') { + return None; + } + let tail = &after[1..]; + if !tail.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinProgramData, + tail: tail.to_string(), + }); + } + return None; + } + + // Check if path is under drive_c/windows + let win_prefix = "drive_c/windows"; + if relative_lower.starts_with(win_prefix) { + let after = &relative[win_prefix.len()..]; + if after.is_empty() || !after.starts_with('/') { + return None; + } + let tail = &after[1..]; + if !tail.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinDir, + tail: tail.to_string(), + }); + } + return None; + } + + // Check if path is under drive_ (not drive_c) + if let Some(after_drive) = relative_lower.strip_prefix("drive_") + && after_drive.len() >= 2 + && after_drive.as_bytes()[0].is_ascii_alphabetic() + && after_drive.as_bytes()[1] == b'/' + { + let letter = (after_drive.as_bytes()[0] as char).to_ascii_lowercase(); + if letter != 'c' { + let tail = &relative[8..]; // skip "drive_x/" + if !tail.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinDrive(letter), + tail: tail.to_string(), + }); + } + } + } + + // If path is under drive_c but not matched above, use WinDrive('c') + let dc_prefix = "drive_c"; + if relative_lower.starts_with(dc_prefix) { + let after = &relative[dc_prefix.len()..]; + if let Some(tail) = after.strip_prefix('/') + && !tail.is_empty() + { + return Some(SemanticPath { + base: SemanticBase::WinDrive('c'), + tail: tail.to_string(), + }); + } + } + + None +} + +/// Classify a sub-path under the Wine user's profile directory. +fn classify_windows_user_subpath(sub_path: &str) -> Option { + let lower = sub_path.to_ascii_lowercase(); + + // Saved Games + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "saved games") { + return Some(SemanticPath { + base: SemanticBase::WinSavedGames, + tail, + }); + } + + // Documents / My Documents + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "my documents") { + return Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail, + }); + } + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "documents") { + return Some(SemanticPath { + base: SemanticBase::WinDocuments, + tail, + }); + } + + // AppData/Local/Low or Local Settings/Application Data/Low + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "appdata/local/low") { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppDataLow, + tail, + }); + } + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "local settings/application data/low") { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppDataLow, + tail, + }); + } + + // AppData/Local or Local Settings/Application Data + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "appdata/local") { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppData, + tail, + }); + } + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "local settings/application data") { + return Some(SemanticPath { + base: SemanticBase::WinLocalAppData, + tail, + }); + } + + // AppData/Roaming or Application Data + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "appdata/roaming") { + return Some(SemanticPath { + base: SemanticBase::WinAppData, + tail, + }); + } + if let Some(tail) = strip_known_folder_alias(&lower, sub_path, "application data") { + return Some(SemanticPath { + base: SemanticBase::WinAppData, + tail, + }); + } + + // Default: WinHome + if !sub_path.is_empty() { + return Some(SemanticPath { + base: SemanticBase::WinHome, + tail: sub_path.to_string(), + }); + } + + None +} + +/// Try to strip a known folder alias prefix from the sub-path. +/// `alias` should be lowercase. Returns the tail if the alias matches. +fn strip_known_folder_alias(lower: &str, original: &str, alias: &str) -> Option { + if lower == alias { + // Exact match with no tail - not valid + return None; + } + if lower.starts_with(alias) && lower.as_bytes().get(alias.len()) == Some(&b'/') { + let tail_start = alias.len() + 1; + if tail_start < original.len() { + return Some(original[tail_start..].to_string()); + } + } + None +} + +use crate::scan::saves::ScanOrigin; + +/// Mapping from manifest placeholder strings to semantic bases. +const PLACEHOLDER_TO_BASE: &[(&str, SemanticBase)] = &[ + ("", SemanticBase::WinDocuments), + ("", SemanticBase::WinAppData), + ("", SemanticBase::WinLocalAppData), + ("", SemanticBase::WinLocalAppDataLow), + ("", SemanticBase::WinSavedGames), + ("", SemanticBase::WinPublic), + ("", SemanticBase::WinProgramData), + ("", SemanticBase::WinDir), + ("", SemanticBase::WinHome), +]; + +/// Determine the expected semantic base from a manifest path. +/// Returns None if the manifest path does not use a known semantic placeholder. +pub fn expected_base_from_manifest(manifest_path: &str, _store: Store) -> Option { + for (placeholder, base) in PLACEHOLDER_TO_BASE { + if manifest_path.starts_with(placeholder) { + return Some(base.clone()); + } + } + None +} + +/// Derive a semantic key from manifest origin metadata. +/// Returns None if the origin does not support semantic derivation. +/// +/// Source precedence: manifest-derived keys are called FIRST. +/// Only if this returns None should the caller invoke reverse mapping. +pub fn derive_from_manifest_origin(origin: &ScanOrigin) -> Option { + let manifest_path = &origin.manifest_path; + let tail = &origin.tail; + + // Check if the manifest placeholder maps to a recognized semantic base + for (placeholder, base) in PLACEHOLDER_TO_BASE { + if manifest_path.starts_with(placeholder) { + if tail.is_empty() { + return None; + } + return Some(SemanticPath { + base: base.clone(), + tail: tail.clone(), + }); + } + } + + // Generic or with non-portable root → None (fall through to reverse mapping) + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resource::manifest::Store; + + fn make_known_folders() -> KnownFolders { + KnownFolders { + saved_games: Some("C:/Users/Alice/Saved Games".to_string()), + documents: Some("C:/Users/Alice/Documents".to_string()), + local_app_data: Some("C:/Users/Alice/AppData/Local".to_string()), + app_data: Some("C:/Users/Alice/AppData/Roaming".to_string()), + public: Some("C:/Users/Public".to_string()), + program_data: Some("C:/ProgramData".to_string()), + windows: Some("C:/Windows".to_string()), + user_profile: Some("C:/Users/Alice".to_string()), + } + } + + #[test] + fn windows_documents() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Users/Alice/Documents/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_appdata_roaming() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Users/Alice/AppData/Roaming/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinAppData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_local_appdata() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Users/Alice/AppData/Local/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinLocalAppData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_local_appdata_low() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Users/Alice/AppData/Local/Low/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinLocalAppDataLow); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_saved_games() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Users/Alice/Saved Games/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinSavedGames); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_relocated_documents() { + let mut kf = make_known_folders(); + kf.documents = Some("D:/MyDocs".to_string()); + let path = StrictPath::new("D:/MyDocs/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_drive_root_classification() { + let kf = make_known_folders(); + let path = StrictPath::new("D:/Games/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinDrive('d')); + assert_eq!(result.tail, "Games/save.dat"); + } + + #[test] + fn windows_home_not_swallowing_documents() { + let kf = make_known_folders(); + // If Documents is at C:/Users/Alice/Documents, then C:/Users/Alice/MyGames + // should map to WinHome, not WinDocuments. + let path = StrictPath::new("C:/Users/Alice/MyGames/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinHome); + assert_eq!(result.tail, "MyGames/save.dat"); + } + + #[test] + fn windows_case_insensitive() { + let kf = make_known_folders(); + let path = StrictPath::new("c:/users/alice/documents/game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "game/save.dat"); + } + + #[test] + fn windows_rejects_unc() { + let kf = make_known_folders(); + let path = StrictPath::new("//server/share/file.dat"); + assert!(windows_physical_to_semantic(&path, &kf).is_none()); + } + + #[test] + fn windows_programdata() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/ProgramData/Game/save.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinProgramData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn windows_windir() { + let kf = make_known_folders(); + let path = StrictPath::new("C:/Windows/System32/config.dat"); + let result = windows_physical_to_semantic(&path, &kf).unwrap(); + assert_eq!(result.base, SemanticBase::WinDir); + assert_eq!(result.tail, "System32/config.dat"); + } + + // Wine conversion tests + + #[test] + fn wine_documents() { + let prefix = StrictPath::new("/home/deck/Prefixes/Alan Wake"); + let physical = StrictPath::new( + "/home/deck/Prefixes/Alan Wake/drive_c/users/steamuser/Documents/Remedy/Alan Wake/save.dat", + ); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Remedy/Alan Wake/save.dat"); + } + + #[test] + fn wine_appdata_roaming() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/deck/AppData/Roaming/Game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "deck").unwrap(); + assert_eq!(result.base, SemanticBase::WinAppData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn wine_xp_alias_application_data() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = + StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/Application Data/Game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinAppData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn wine_xp_alias_local_settings() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new( + "/home/deck/Prefixes/Game/drive_c/users/steamuser/Local Settings/Application Data/Game/save.dat", + ); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinLocalAppData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn wine_xp_alias_my_documents() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/My Documents/Game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn wine_programdata() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/drive_c/ProgramData/Game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinProgramData); + assert_eq!(result.tail, "Game/save.dat"); + } + + #[test] + fn wine_drive_d() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/drive_d/Games/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinDrive('d')); + assert_eq!(result.tail, "Games/save.dat"); + } + + #[test] + fn wine_case_insensitive() { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/DRIVE_C/users/steamuser/documents/game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "game/save.dat"); + } + + #[test] + fn wine_uses_lexical_path_not_realpath() { + // Even if the Documents directory is a symlink to somewhere else, + // we should use the lexical path for classification. + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let physical = StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat"); + let result = wine_physical_to_semantic(&physical, &prefix, "steamuser").unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Game/save.dat"); + } + + // Manifest derivation tests + + #[test] + fn manifest_derive_win_documents() { + let origin = ScanOrigin { + manifest_path: "/Remedy/Alan Wake".to_string(), + store: Store::Other, + expanded_prefix: "C:/Users/Alice/Documents".to_string(), + matched_prefix_len: "C:/Users/Alice/Documents".len(), + tail: "Remedy/Alan Wake/save.dat".to_string(), + }; + let result = derive_from_manifest_origin(&origin).unwrap(); + assert_eq!(result.base, SemanticBase::WinDocuments); + assert_eq!(result.tail, "Remedy/Alan Wake/save.dat"); + } + + #[test] + fn manifest_derive_win_appdata() { + let origin = ScanOrigin { + manifest_path: "/Game".to_string(), + store: Store::Other, + expanded_prefix: "C:/Users/Alice/AppData/Roaming".to_string(), + matched_prefix_len: "C:/Users/Alice/AppData/Roaming".len(), + tail: "Game/config.ini".to_string(), + }; + let result = derive_from_manifest_origin(&origin).unwrap(); + assert_eq!(result.base, SemanticBase::WinAppData); + assert_eq!(result.tail, "Game/config.ini"); + } + + #[test] + fn manifest_derive_steam_userdata_falls_back_to_legacy() { + // Steam userdata is a native Windows/Linux concern, not Windows/Wine, + // so it is intentionally out of scope and falls back to legacy + // absolute-path behavior (None here). + let origin = ScanOrigin { + manifest_path: "/userdata///remote".to_string(), + store: Store::Steam, + expanded_prefix: "C:/Program Files (x86)/Steam".to_string(), + matched_prefix_len: "C:/Program Files (x86)/Steam".len(), + tail: "userdata/12345/67890/remote/save.dat".to_string(), + }; + assert!(derive_from_manifest_origin(&origin).is_none()); + } + + #[test] + fn manifest_derive_generic_base_returns_none() { + let origin = ScanOrigin { + manifest_path: "/saves".to_string(), + store: Store::Other, + expanded_prefix: "/home/user/game".to_string(), + matched_prefix_len: "/home/user/game".len(), + tail: "saves/file.dat".to_string(), + }; + assert!(derive_from_manifest_origin(&origin).is_none()); + } + + #[test] + fn manifest_derive_empty_tail_returns_none() { + let origin = ScanOrigin { + manifest_path: "/Game".to_string(), + store: Store::Other, + expanded_prefix: "C:/Users/Alice/Documents".to_string(), + matched_prefix_len: "C:/Users/Alice/Documents".len(), + tail: "".to_string(), + }; + assert!(derive_from_manifest_origin(&origin).is_none()); + } + + #[test] + fn manifest_derive_all_win_bases() { + let cases = [ + ("", SemanticBase::WinDocuments), + ("", SemanticBase::WinAppData), + ("", SemanticBase::WinLocalAppData), + ("", SemanticBase::WinLocalAppDataLow), + ("", SemanticBase::WinSavedGames), + ("", SemanticBase::WinPublic), + ("", SemanticBase::WinProgramData), + ("", SemanticBase::WinDir), + ("", SemanticBase::WinHome), + ]; + for (placeholder, expected_base) in cases { + let origin = ScanOrigin { + manifest_path: format!("{}/Game", placeholder), + store: Store::Other, + expanded_prefix: "some/prefix".to_string(), + matched_prefix_len: 11, + tail: "Game/save.dat".to_string(), + }; + let result = derive_from_manifest_origin(&origin).unwrap(); + assert_eq!(result.base, expected_base, "failed for: {}", placeholder); + } + } +} diff --git a/src/semantic/materialize.rs b/src/semantic/materialize.rs new file mode 100644 index 00000000..7decba73 --- /dev/null +++ b/src/semantic/materialize.rs @@ -0,0 +1,667 @@ +use crate::path::{CommonPath, StrictPath}; +use crate::prelude::Error; +use crate::resource::config::Config; +use crate::semantic::SemanticBase; +use crate::semantic::SemanticPath; +use crate::semantic::convert::KnownFolders; +use crate::semantic::prefix::{ValidatedPrefix, validate_prefix}; + +/// Target platform for materialization. +pub enum MaterializeTarget<'a> { + CurrentWindows { + known_folders: &'a KnownFolders, + }, + WinePrefix { + prefix: &'a ValidatedPrefix, + wine_user: &'a str, + /// Fallback drive mappings when dosdevices are unavailable. + drive_mappings: &'a std::collections::HashMap, + }, +} + +/// Build `KnownFolders` from the current platform's `CommonPath` values. +/// On Windows, this uses the Windows API for known folders. +/// On non-Windows, all values are `None`. +pub fn known_folders_from_common_path() -> KnownFolders { + #[cfg(target_os = "windows")] + let program_data = known_folders::get_known_folder_path(known_folders::KnownFolder::ProgramData) + .map(|p| p.to_string_lossy().trim_end_matches(['/', '\\']).to_string()); + #[cfg(not(target_os = "windows"))] + let program_data = None; + + #[cfg(target_os = "windows")] + let windows = known_folders::get_known_folder_path(known_folders::KnownFolder::Windows) + .map(|p| p.to_string_lossy().trim_end_matches(['/', '\\']).to_string()); + #[cfg(not(target_os = "windows"))] + let windows = None; + + KnownFolders { + saved_games: CommonPath::SavedGames.get().map(|s| s.to_string()), + documents: CommonPath::Document.get().map(|s| s.to_string()), + local_app_data: CommonPath::DataLocal.get().map(|s| s.to_string()), + app_data: CommonPath::Data.get().map(|s| s.to_string()), + public: CommonPath::Public.get().map(|s| s.to_string()), + program_data, + windows, + user_profile: CommonPath::Home.get().map(|s| s.to_string()), + } +} + +/// Discover a Wine prefix from a list of candidate paths. +/// Returns the first valid prefix found, or None. +pub fn discover_wine_prefix(candidates: &[StrictPath]) -> Option { + for candidate in candidates { + if let Some(prefix) = validate_prefix(candidate) { + return Some(prefix); + } + } + None +} + +fn preferred_wine_prefix_for_game<'a>( + config: &'a Config, + game: &str, +) -> Option<&'a crate::resource::config::GameWinePrefixPreference> { + config.restore.preferred_wine_prefixes.get(game).or_else(|| { + let display_name = config.display_name(game); + if display_name == game { + None + } else { + config.restore.preferred_wine_prefixes.get(display_name) + } + }) +} + +/// Resolve the Wine prefix to use for a game's semantic restore. +pub fn resolve_wine_prefix_for_game( + config: &Config, + game: &str, + cli_wine_prefix: Option<&StrictPath>, +) -> Result, Error> { + if let Some(cli) = cli_wine_prefix { + if let Some(preference) = preferred_wine_prefix_for_game(config, game) + && !preference.path.equivalent(cli) + { + return Err(Error::WinePrefixConflict { + game: config.display_name(game).to_string(), + cli: Box::new(cli.clone()), + configured: Box::new(preference.path.clone()), + }); + } + return Ok(validate_prefix(cli)); + } + + Ok(resolve_wine_prefix_without_cli(config, game)) +} + +/// Resolve the configured or discovered Wine prefix for a game when no CLI override is involved. +pub fn resolve_wine_prefix_without_cli(config: &Config, game: &str) -> Option { + if let Some(preference) = preferred_wine_prefix_for_game(config, game) { + if let Some(mut prefix) = validate_prefix(&preference.path) { + if let Some(wine_user) = &preference.wine_user { + prefix.wine_user.clone_from(wine_user); + } + for (drive, target) in &preference.drive_mappings { + prefix + .drive_mappings + .insert(drive.to_ascii_lowercase(), target.render()); + } + return Some(prefix); + } + return None; + } + + let roots: Vec = config.roots.iter().map(|root| root.path().clone()).collect(); + discover_wine_prefix(&roots) +} + +/// Error type for materialization failures. +#[derive(Clone, Debug)] +pub enum MaterializeError { + /// The drive letter does not exist on the target. + MissingDrive(char), + /// The known folder is not available. + MissingKnownFolder(String), + /// The path would exceed Windows long-path limits. + PathTooLong, + /// The target configuration is invalid. + InvalidTarget(String), +} + +impl std::fmt::Display for MaterializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingDrive(c) => write!(f, "Drive {} is not available on the target", c), + Self::MissingKnownFolder(name) => { + write!(f, "Known folder '{}' is not available", name) + } + Self::PathTooLong => write!(f, "Path exceeds maximum length"), + Self::InvalidTarget(msg) => write!(f, "Invalid target: {}", msg), + } + } +} + +impl std::error::Error for MaterializeError {} + +/// Materialize a semantic path to a physical path on the current platform. +pub fn materialize_semantic( + semantic: &SemanticPath, + target: &MaterializeTarget, +) -> Result { + match target { + MaterializeTarget::CurrentWindows { known_folders } => materialize_to_windows(semantic, known_folders), + MaterializeTarget::WinePrefix { prefix, wine_user, drive_mappings } => { + materialize_to_wine(semantic, prefix, wine_user, drive_mappings) + } + } +} + +fn materialize_to_windows( + semantic: &SemanticPath, + known_folders: &KnownFolders, +) -> Result { + let base_path = match &semantic.base { + SemanticBase::WinDocuments => known_folders + .documents + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("documents".to_string()))?, + SemanticBase::WinAppData => known_folders + .app_data + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("app_data".to_string()))?, + SemanticBase::WinLocalAppData => known_folders + .local_app_data + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("local_app_data".to_string()))?, + SemanticBase::WinLocalAppDataLow => { + let local = known_folders + .local_app_data + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("local_app_data".to_string()))?; + return Ok(StrictPath::new(format!("{}/Low/{}", local, semantic.tail))); + } + SemanticBase::WinSavedGames => known_folders + .saved_games + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("saved_games".to_string()))?, + SemanticBase::WinPublic => known_folders + .public + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("public".to_string()))?, + SemanticBase::WinProgramData => known_folders + .program_data + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("program_data".to_string()))?, + SemanticBase::WinDir => known_folders + .windows + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("windows".to_string()))?, + SemanticBase::WinHome => known_folders + .user_profile + .as_ref() + .ok_or_else(|| MaterializeError::MissingKnownFolder("user_profile".to_string()))?, + SemanticBase::WinDrive(c) => { + let drive_str = format!("{}:/", c.to_ascii_uppercase()); + // On Windows, check if the drive exists + #[cfg(target_os = "windows")] + { + let drive_path = format!("{}\\", drive_str); + if !std::path::Path::new(&drive_path).exists() { + return Err(MaterializeError::MissingDrive(*c)); + } + } + return Ok(StrictPath::new(format!("{}{}", drive_str, semantic.tail))); + } + }; + + let result = format!("{}/{}", base_path, semantic.tail); + + // Check Windows long path limits (260 chars without extended-length prefix) + #[cfg(target_os = "windows")] + if result.len() > 260 { + return Err(MaterializeError::PathTooLong); + } + + Ok(StrictPath::new(result)) +} + +fn materialize_to_wine( + semantic: &SemanticPath, + prefix: &ValidatedPrefix, + wine_user: &str, + drive_mappings: &std::collections::HashMap, +) -> Result { + let prefix_path = prefix.path.render(); + + let base_path = match &semantic.base { + SemanticBase::WinDocuments => { + format!("{}/drive_c/users/{}/Documents", prefix_path, wine_user) + } + SemanticBase::WinAppData => { + format!("{}/drive_c/users/{}/AppData/Roaming", prefix_path, wine_user) + } + SemanticBase::WinLocalAppData => { + format!("{}/drive_c/users/{}/AppData/Local", prefix_path, wine_user) + } + SemanticBase::WinLocalAppDataLow => { + format!("{}/drive_c/users/{}/AppData/Local/Low", prefix_path, wine_user) + } + SemanticBase::WinSavedGames => { + format!("{}/drive_c/users/{}/Saved Games", prefix_path, wine_user) + } + SemanticBase::WinPublic => { + format!("{}/drive_c/users/Public", prefix_path) + } + SemanticBase::WinProgramData => { + format!("{}/drive_c/ProgramData", prefix_path) + } + SemanticBase::WinDir => { + format!("{}/drive_c/windows", prefix_path) + } + SemanticBase::WinHome => { + format!("{}/drive_c/users/{}", prefix_path, wine_user) + } + SemanticBase::WinDrive(c) => { + if *c == 'c' { + format!("{}/drive_c", prefix_path) + } else { + // Check dosdevices mapping first + if let Some(target) = prefix.drive_mappings.get(c) { + return Ok(StrictPath::new(format!("{}/{}", target, semantic.tail))); + } + // Fall back to config drive_mappings + if let Some(target) = drive_mappings.get(c) { + return Ok(StrictPath::new(format!("{}/{}", target, semantic.tail))); + } + return Err(MaterializeError::MissingDrive(*c)); + } + } + }; + + Ok(StrictPath::new(format!("{}/{}", base_path, semantic.tail))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resource::config::{Config, GameWinePrefixPreference}; + use std::collections::HashMap; + + fn make_known_folders() -> KnownFolders { + KnownFolders { + saved_games: Some("C:/Users/Alice/Saved Games".to_string()), + documents: Some("C:/Users/Alice/Documents".to_string()), + local_app_data: Some("C:/Users/Alice/AppData/Local".to_string()), + app_data: Some("C:/Users/Alice/AppData/Roaming".to_string()), + public: Some("C:/Users/Public".to_string()), + program_data: Some("C:/ProgramData".to_string()), + windows: Some("C:/Windows".to_string()), + user_profile: Some("C:/Users/Alice".to_string()), + } + } + + fn make_wine_prefix() -> ValidatedPrefix { + ValidatedPrefix { + path: StrictPath::new("/home/deck/Prefixes/Game"), + wine_user: "steamuser".to_string(), + has_drive_c: true, + drive_mappings: HashMap::new(), + } + } + + fn make_valid_prefix(root: &StrictPath, user: &str) { + root.joined("drive_c/users").joined(user).create_dirs().unwrap(); + root.joined("drive_c/users/Public").create_dirs().unwrap(); + root.joined("system.reg").write_with_content("").unwrap(); + } + + #[test] + fn win_documents_to_windows() { + let kf = make_known_folders(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "C:/Users/Alice/Documents/Game/save.dat"); + } + + #[test] + fn resolve_wine_prefix_uses_per_game_preference() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + let discovered = StrictPath::new(temp.path().join("discovered").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "alice"); + make_valid_prefix(&discovered, "steamuser"); + + let mut config = Config::default(); + config.roots.push(crate::resource::config::Root::new( + discovered.clone(), + crate::resource::manifest::Store::OtherWine, + )); + config.restore.preferred_wine_prefixes.insert( + "Game".to_string(), + GameWinePrefixPreference { + path: preferred.clone(), + wine_user: Some("alice".to_string()), + ..Default::default() + }, + ); + + let resolved = resolve_wine_prefix_without_cli(&config, "Game").unwrap(); + assert_eq!(preferred.render(), resolved.path.render()); + assert_eq!("alice", resolved.wine_user); + } + + #[test] + fn resolve_wine_prefix_uses_display_alias_preference() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "steamuser"); + + let mut config = Config::default(); + config.custom_games.push(crate::resource::config::CustomGame { + name: "Display Game".to_string(), + alias: Some("Game".to_string()), + prefer_alias: true, + ..Default::default() + }); + config.restore.preferred_wine_prefixes.insert( + "Display Game".to_string(), + GameWinePrefixPreference { + path: preferred.clone(), + ..Default::default() + }, + ); + + let resolved = resolve_wine_prefix_without_cli(&config, "Game").unwrap(); + assert_eq!(preferred.render(), resolved.path.render()); + } + + #[test] + fn resolve_wine_prefix_rejects_conflicting_cli_override() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + let cli = StrictPath::new(temp.path().join("cli").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "steamuser"); + make_valid_prefix(&cli, "steamuser"); + + let mut config = Config::default(); + config.restore.preferred_wine_prefixes.insert( + "Game".to_string(), + GameWinePrefixPreference { + path: preferred.clone(), + ..Default::default() + }, + ); + + let error = resolve_wine_prefix_for_game(&config, "Game", Some(&cli)).unwrap_err(); + assert_eq!( + Error::WinePrefixConflict { + game: "Game".to_string(), + cli: Box::new(cli), + configured: Box::new(preferred), + }, + error + ); + } + + #[test] + fn resolve_wine_prefix_rejects_conflicting_alias_cli_override() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + let cli = StrictPath::new(temp.path().join("cli").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "steamuser"); + make_valid_prefix(&cli, "steamuser"); + + let mut config = Config::default(); + config.custom_games.push(crate::resource::config::CustomGame { + name: "Display Game".to_string(), + alias: Some("Game".to_string()), + prefer_alias: true, + ..Default::default() + }); + config.restore.preferred_wine_prefixes.insert( + "Display Game".to_string(), + GameWinePrefixPreference { + path: preferred.clone(), + ..Default::default() + }, + ); + + let error = resolve_wine_prefix_for_game(&config, "Game", Some(&cli)).unwrap_err(); + assert_eq!( + Error::WinePrefixConflict { + game: "Display Game".to_string(), + cli: Box::new(cli), + configured: Box::new(preferred), + }, + error + ); + } + + #[test] + fn resolve_wine_prefix_allows_matching_cli_override() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "steamuser"); + + let mut config = Config::default(); + config.restore.preferred_wine_prefixes.insert( + "Game".to_string(), + GameWinePrefixPreference { + path: preferred.clone(), + ..Default::default() + }, + ); + + let resolved = resolve_wine_prefix_for_game(&config, "Game", Some(&preferred)) + .unwrap() + .unwrap(); + assert_eq!(preferred.render(), resolved.path.render()); + } + + #[test] + fn resolve_wine_prefix_applies_preferred_drive_mappings() { + let temp = tempfile::tempdir().unwrap(); + let preferred = StrictPath::new(temp.path().join("preferred").to_string_lossy().to_string()); + let drive = StrictPath::new(temp.path().join("drive-d").to_string_lossy().to_string()); + make_valid_prefix(&preferred, "steamuser"); + + let mut config = Config::default(); + config.restore.preferred_wine_prefixes.insert( + "Game".to_string(), + GameWinePrefixPreference { + path: preferred, + drive_mappings: [('D', drive.clone())].into_iter().collect(), + ..Default::default() + }, + ); + + let resolved = resolve_wine_prefix_without_cli(&config, "Game").unwrap(); + assert_eq!(Some(&drive.render()), resolved.drive_mappings.get(&'d')); + } + + #[test] + fn win_appdata_to_windows() { + let kf = make_known_folders(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "C:/Users/Alice/AppData/Roaming/Game/save.dat"); + } + + #[test] + fn win_local_appdata_to_windows() { + let kf = make_known_folders(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "C:/Users/Alice/AppData/Local/Game/save.dat"); + } + + #[test] + fn win_local_appdata_low_to_windows() { + let kf = make_known_folders(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "C:/Users/Alice/AppData/Local/Low/Game/save.dat"); + } + + #[test] + fn win_drive_d_to_windows() { + let kf = make_known_folders(); + let semantic = SemanticPath::parse("/Games/save.dat").unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "D:/Games/save.dat"); + } + + #[test] + fn win_documents_to_wine() { + let prefix = make_wine_prefix(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &std::collections::HashMap::new(), + }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!( + result.render(), + "/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat" + ); + } + + #[test] + fn win_appdata_to_wine() { + let prefix = make_wine_prefix(); + let semantic = SemanticPath::parse("/Game/save.dat").unwrap(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &std::collections::HashMap::new(), + }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!( + result.render(), + "/home/deck/Prefixes/Game/drive_c/users/steamuser/AppData/Roaming/Game/save.dat" + ); + } + + #[test] + fn win_drive_d_to_wine_with_mapping() { + let mut prefix = make_wine_prefix(); + prefix.drive_mappings.insert('d', "/mnt/data".to_string()); + let semantic = SemanticPath::parse("/Games/save.dat").unwrap(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &std::collections::HashMap::new(), + }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), "/mnt/data/Games/save.dat"); + } + + #[test] + fn win_drive_d_to_wine_without_mapping() { + let prefix = make_wine_prefix(); + let semantic = SemanticPath::parse("/Games/save.dat").unwrap(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &std::collections::HashMap::new(), + }; + let result = materialize_semantic(&semantic, &target); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), MaterializeError::MissingDrive('d'))); + } + + #[test] + fn round_trip_windows() { + let kf = make_known_folders(); + let original = "C:/Users/Alice/Documents/Game/save.dat"; + let sp = StrictPath::new(original); + let semantic = crate::semantic::convert::windows_physical_to_semantic(&sp, &kf).unwrap(); + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), original); + } + + #[test] + fn round_trip_wine() { + let prefix = make_wine_prefix(); + let original = "/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat"; + let sp = StrictPath::new(original); + let semantic = crate::semantic::convert::wine_physical_to_semantic(&sp, &prefix.path, "steamuser").unwrap(); + let target = MaterializeTarget::WinePrefix { + prefix: &prefix, + wine_user: "steamuser", + drive_mappings: &std::collections::HashMap::new(), + }; + let result = materialize_semantic(&semantic, &target).unwrap(); + assert_eq!(result.render(), original); + } + + #[test] + fn integration_wine_backup_windows_restore() { + // Simulates: scan in Wine → serialize to mapping.yaml → restore on Windows + let prefix = make_wine_prefix(); + let kf = make_known_folders(); + + // 1. Scan in Wine: physical path → semantic key + let wine_physical = + StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/MyGame/save.dat"); + let semantic = + crate::semantic::convert::wine_physical_to_semantic(&wine_physical, &prefix.path, "steamuser").unwrap(); + assert_eq!(semantic.base, SemanticBase::WinDocuments); + assert_eq!(semantic.tail, "MyGame/save.dat"); + + // 2. Serialize to mapping.yaml key + let mapping_key = semantic.serialize(); + assert_eq!(mapping_key, "/MyGame/save.dat"); + + // 3. Parse back from mapping.yaml + let parsed = SemanticPath::parse(&mapping_key).unwrap(); + assert_eq!(parsed, semantic); + + // 4. Materialize on Windows + let win_target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let win_physical = materialize_semantic(&parsed, &win_target).unwrap(); + assert_eq!(win_physical.render(), "C:/Users/Alice/Documents/MyGame/save.dat"); + + // 5. Materialize back to a different Wine prefix + let mut prefix2 = make_wine_prefix(); + prefix2.path = StrictPath::new("/home/alice/Games/Prefixes/MyGame"); + let wine_target = MaterializeTarget::WinePrefix { + prefix: &prefix2, + wine_user: "alice", + drive_mappings: &std::collections::HashMap::new(), + }; + let wine_physical2 = materialize_semantic(&parsed, &wine_target).unwrap(); + assert_eq!( + wine_physical2.render(), + "/home/alice/Games/Prefixes/MyGame/drive_c/users/alice/Documents/MyGame/save.dat" + ); + } + + #[test] + fn integration_format_switch_forces_full_backup() { + // This test verifies the logic at the type level. + // A legacy backup has path_format: Legacy. + // If a scan produces semantic keys, has_semantic_keys() returns true. + // plan_backup_kind should then force Full when the last backup is Legacy. + use crate::scan::layout::{FullBackup, PathFormat}; + + let legacy_full = FullBackup { + path_format: PathFormat::Legacy, + ..Default::default() + }; + assert_eq!(legacy_full.path_format, PathFormat::Legacy); + + let semantic_full = FullBackup { + path_format: PathFormat::SemanticV1, + ..Default::default() + }; + assert_eq!(semantic_full.path_format, PathFormat::SemanticV1); + } +} diff --git a/src/semantic/prefix.rs b/src/semantic/prefix.rs new file mode 100644 index 00000000..af66f149 --- /dev/null +++ b/src/semantic/prefix.rs @@ -0,0 +1,358 @@ +use std::collections::HashMap; + +use crate::path::StrictPath; + +/// System user directories that should be excluded from Wine user detection. +const SYSTEM_USERS: &[&str] = &["public", "default", "default user", "all users"]; + +/// A validated Wine prefix with detected metadata. +#[derive(Clone, Debug)] +pub struct ValidatedPrefix { + pub path: StrictPath, + pub wine_user: String, + pub has_drive_c: bool, + /// Drive letter mappings from dosdevices (lowercase letter → target path). + pub drive_mappings: HashMap, +} + +/// Validate a candidate prefix path. +/// Returns None if validation fails. +/// +/// Validation rules: +/// 1. `candidate/drive_c` must exist as a directory. +/// 2. At least one of `candidate/system.reg`, `candidate/user.reg`, or +/// `candidate/dosdevices` must exist. +/// 3. `candidate/drive_c/users` must exist as a directory. +pub fn validate_prefix(candidate: &StrictPath) -> Option { + let candidate_rendered = candidate.render(); + + // 1. Check drive_c exists + let drive_c = format!("{}/drive_c", candidate_rendered); + let drive_c_path = StrictPath::new(&drive_c); + if !drive_c_path.is_dir() { + return None; + } + + // 2. Check for Wine state markers + let system_reg = format!("{}/system.reg", candidate_rendered); + let user_reg = format!("{}/user.reg", candidate_rendered); + let dosdevices = format!("{}/dosdevices", candidate_rendered); + + let has_marker = StrictPath::new(&system_reg).exists() + || StrictPath::new(&user_reg).exists() + || StrictPath::new(&dosdevices).is_dir(); + + if !has_marker { + return None; + } + + // 3. Check drive_c/users exists + let users_dir = format!("{}/drive_c/users", candidate_rendered); + let users_path = StrictPath::new(&users_dir); + if !users_path.is_dir() { + return None; + } + + // 4. Detect Wine user + let wine_user = detect_wine_user(&users_dir)?; + + // 5. Scan dosdevices for drive mappings + let drive_mappings = scan_dosdevices(&dosdevices); + + Some(ValidatedPrefix { + path: candidate.clone(), + wine_user, + has_drive_c: true, + drive_mappings, + }) +} + +/// Detect the Wine user from the users directory. +/// Returns None if no valid user is found. +fn detect_wine_user(users_dir: &str) -> Option { + let users_path = StrictPath::new(users_dir); + let entries = match std::fs::read_dir(users_path.interpret().ok()?) { + Ok(e) => e, + Err(_) => return None, + }; + + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let lower = name.to_ascii_lowercase(); + if !SYSTEM_USERS.contains(&lower.as_str()) && entry.path().is_dir() { + candidates.push(name); + } + } + + if candidates.len() == 1 { + return Some(candidates.into_iter().next().unwrap()); + } + + // If multiple candidates, prefer "steamuser" for Proton + if candidates.iter().any(|c| c.eq_ignore_ascii_case("steamuser")) { + return Some("steamuser".to_string()); + } + + // Return first candidate if any (caller should handle ambiguity) + candidates.into_iter().next() +} + +/// Scan dosdevices directory for drive letter symlinks. +fn scan_dosdevices(dosdevices_dir: &str) -> HashMap { + let mut mappings = HashMap::new(); + + let path = match StrictPath::new(dosdevices_dir).interpret() { + Ok(p) => p, + Err(_) => return mappings, + }; + + let entries = match std::fs::read_dir(&path) { + Ok(e) => e, + Err(_) => return mappings, + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + // Look for patterns like "d:" or "D:" + if name.len() == 2 && name.ends_with(':') { + let letter = name.as_bytes()[0]; + if letter.is_ascii_alphabetic() { + // Check if it's a symlink + if let Ok(target) = std::fs::read_link(entry.path()) { + mappings.insert( + (letter as char).to_ascii_lowercase(), + target.to_string_lossy().to_string(), + ); + } + } + } + } + + mappings +} + +/// Choose the Wine user for restore into a validated prefix. +pub fn choose_wine_user_for_restore( + prefix: &ValidatedPrefix, + preferred_wine_user: Option<&str>, + target_path_hint: Option<&str>, + is_proton: bool, +) -> Result { + // 1. Configured preferred user + if let Some(user) = preferred_wine_user { + return Ok(user.to_string()); + } + + // 2. Target path hint + if let Some(hint) = target_path_hint { + let hint_lower = hint.to_ascii_lowercase(); + let users_dir = format!("{}/drive_c/users", prefix.path.render()); + if let Ok(entries) = std::fs::read_dir(StrictPath::new(&users_dir).interpret().unwrap_or_default()) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let lower = name.to_ascii_lowercase(); + if !SYSTEM_USERS.contains(&lower.as_str()) && entry.path().is_dir() { + let user_path = format!("{}/{}", users_dir, name).to_ascii_lowercase(); + if hint_lower.starts_with(&user_path) { + return Ok(name); + } + } + } + } + } + + // 3. Single non-system user + let users_dir = format!("{}/drive_c/users", prefix.path.render()); + let mut candidates = Vec::new(); + if let Ok(entries) = std::fs::read_dir(StrictPath::new(&users_dir).interpret().unwrap_or_default()) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let lower = name.to_ascii_lowercase(); + if !SYSTEM_USERS.contains(&lower.as_str()) && entry.path().is_dir() { + candidates.push(name); + } + } + } + + if candidates.len() == 1 { + return Ok(candidates.into_iter().next().unwrap()); + } + + // 4. Proton: prefer steamuser + if is_proton && candidates.iter().any(|c| c.eq_ignore_ascii_case("steamuser")) { + return Ok("steamuser".to_string()); + } + + // 5. Ambiguity + Err(WineUserAmbiguity { candidates }) +} + +/// Error returned when multiple Wine users are found and none is preferred. +#[derive(Clone, Debug)] +pub struct WineUserAmbiguity { + pub candidates: Vec, +} + +impl std::fmt::Display for WineUserAmbiguity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Multiple Wine users found: [{}]. Please specify a preferred user.", + self.candidates.join(", ") + ) + } +} + +impl std::error::Error for WineUserAmbiguity {} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_test_prefix(base: &str) -> String { + let prefix = format!("{}/test_prefix", base); + let _ = fs::create_dir_all(&prefix); + let _ = fs::create_dir_all(format!("{}/drive_c/users/steamuser", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Public", prefix)); + let _ = fs::create_dir_all(format!("{}/dosdevices", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + prefix + } + + #[test] + fn valid_prefix_with_system_reg() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = create_test_prefix(tmp.path().to_str().unwrap()); + let result = validate_prefix(&StrictPath::new(&prefix)); + assert!(result.is_some()); + let vp = result.unwrap(); + assert!(vp.has_drive_c); + assert_eq!(vp.wine_user, "steamuser"); + } + + #[test] + fn fails_without_drive_c() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/no_drive_c", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(&prefix); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + assert!(validate_prefix(&StrictPath::new(&prefix)).is_none()); + } + + #[test] + fn fails_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/no_markers", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c/users/testuser", prefix)); + assert!(validate_prefix(&StrictPath::new(&prefix)).is_none()); + } + + #[test] + fn fails_without_users_dir() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/no_users", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + assert!(validate_prefix(&StrictPath::new(&prefix)).is_none()); + } + + #[test] + fn detects_single_wine_user() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/single_user", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c/users/myuser", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Public", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Default", prefix)); + let _ = fs::create_dir_all(format!("{}/dosdevices", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + + let result = validate_prefix(&StrictPath::new(&prefix)).unwrap(); + assert_eq!(result.wine_user, "myuser"); + } + + #[test] + fn prefers_steamuser_among_multiple() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/multi_user", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c/users/steamuser", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/deck", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Public", prefix)); + let _ = fs::create_dir_all(format!("{}/dosdevices", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + + let result = validate_prefix(&StrictPath::new(&prefix)).unwrap(); + assert_eq!(result.wine_user, "steamuser"); + } + + #[test] + fn dosdevices_mappings() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = create_test_prefix(tmp.path().to_str().unwrap()); + // Create a symlink d: -> /mnt/data (skip if not supported) + let dosdevices = format!("{}/dosdevices", prefix); + #[cfg(unix)] + { + let _ = std::os::unix::fs::symlink("/mnt/data", format!("{}/d:", dosdevices)); + } + + let result = validate_prefix(&StrictPath::new(&prefix)).unwrap(); + #[cfg(unix)] + { + assert_eq!(result.drive_mappings.get(&'d'), Some(&"/mnt/data".to_string())); + } + } + + #[test] + fn choose_user_prefers_configured() { + let tmp = tempfile::tempdir().unwrap(); + let prefix_str = create_test_prefix(tmp.path().to_str().unwrap()); + let vp = validate_prefix(&StrictPath::new(&prefix_str)).unwrap(); + + let result = choose_wine_user_for_restore(&vp, Some("custom_user"), None, false).unwrap(); + assert_eq!(result, "custom_user"); + } + + #[test] + fn choose_user_single_user() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/single", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c/users/onlyuser", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Public", prefix)); + let _ = fs::create_dir_all(format!("{}/dosdevices", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + + let vp = validate_prefix(&StrictPath::new(&prefix)).unwrap(); + let result = choose_wine_user_for_restore(&vp, None, None, false).unwrap(); + assert_eq!(result, "onlyuser"); + } + + #[test] + fn choose_user_proton_prefers_steamuser() { + let tmp = tempfile::tempdir().unwrap(); + let prefix_str = create_test_prefix(tmp.path().to_str().unwrap()); + let vp = validate_prefix(&StrictPath::new(&prefix_str)).unwrap(); + + let result = choose_wine_user_for_restore(&vp, None, None, true).unwrap(); + assert_eq!(result, "steamuser"); + } + + #[test] + fn choose_user_multi_user_no_config_errors() { + let tmp = tempfile::tempdir().unwrap(); + let prefix = format!("{}/multi", tmp.path().to_str().unwrap()); + let _ = fs::create_dir_all(format!("{}/drive_c/users/alice", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/bob", prefix)); + let _ = fs::create_dir_all(format!("{}/drive_c/users/Public", prefix)); + let _ = fs::create_dir_all(format!("{}/dosdevices", prefix)); + let _ = fs::File::create(format!("{}/system.reg", prefix)); + + let vp = validate_prefix(&StrictPath::new(&prefix)).unwrap(); + let result = choose_wine_user_for_restore(&vp, None, None, false); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.candidates.contains(&"alice".to_string())); + assert!(err.candidates.contains(&"bob".to_string())); + } +} diff --git a/src/semantic/preview.rs b/src/semantic/preview.rs new file mode 100644 index 00000000..9cac9ef3 --- /dev/null +++ b/src/semantic/preview.rs @@ -0,0 +1,321 @@ +use std::collections::BTreeSet; + +use crate::path::StrictPath; +use crate::resource::config::Config; +use crate::scan::ScanInfo; +use crate::scan::layout::{BackupLayout, PathFormat}; + +/// Analysis result for semantic backup preview/dry-run. +#[derive(Clone, Debug, Default, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SemanticPreviewAnalysis { + /// Legacy keys that would become semantic keys. + pub migrations: Vec, + /// Games that would start a new full backup chain. + pub new_full_chains: Vec, + /// Configured prefixes that failed validation. + pub invalid_prefixes: Vec, + /// Semantic key conflicts. + pub conflicts: Vec, +} + +/// A pending migration from a legacy physical key to a semantic key. +#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SemanticMigration { + /// Game whose backup key would change. + pub game_name: String, + /// Current physical key shown by the preview. + pub legacy_key: String, + /// Portable key that would be used by the new full chain. + pub semantic_key: String, +} + +/// A configured prefix that failed validation. +#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InvalidPrefix { + /// Game that configured the invalid prefix. + pub game_name: String, + /// Configured prefix path. + pub path: String, + /// Why the prefix cannot be used. + pub reason: String, +} + +/// A duplicate semantic key produced by multiple physical files. +#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PreviewConflict { + /// Game with duplicate portable keys. + pub game_name: String, + /// Portable key shared by multiple files. + pub semantic_key: String, + /// Physical files that produced the same portable key. + pub physical_paths: Vec, +} + +impl SemanticPreviewAnalysis { + /// Whether this preview has no portable-backup warnings or notices. + pub fn is_empty(&self) -> bool { + self.migrations.is_empty() + && self.new_full_chains.is_empty() + && self.invalid_prefixes.is_empty() + && self.conflicts.is_empty() + } + + /// Analyze a backup preview for portable backup changes and conflicts. + pub fn from_backup_preview(config: &Config, layout: &BackupLayout, scans: &[(&str, &ScanInfo)]) -> Self { + let mut analysis = Self { + migrations: vec![], + new_full_chains: vec![], + invalid_prefixes: vec![], + conflicts: vec![], + }; + + for (display_name, scan) in scans { + let starts_new_full_chain = will_start_new_semantic_full_backup(layout, scan); + if starts_new_full_chain { + analysis.new_full_chains.push((*display_name).to_string()); + for (scan_key, file) in &scan.found_files { + if let Some(semantic_key) = &file.semantic_key { + analysis.migrations.push(SemanticMigration { + game_name: (*display_name).to_string(), + legacy_key: file.effective(scan_key).render(), + semantic_key: semantic_key.serialize(), + }); + } + } + } + + analysis + .invalid_prefixes + .extend(invalid_configured_prefixes_for_scan(config, display_name, scan)); + + analysis + .conflicts + .extend(scan.semantic_conflicts().iter().map(|conflict| PreviewConflict { + game_name: (*display_name).to_string(), + semantic_key: conflict.semantic_key.serialize(), + physical_paths: conflict.physical_paths.iter().map(|path| path.render()).collect(), + })); + } + + analysis + } +} + +/// Validate configured Wine prefixes for the scanned game and its preferred display alias. +pub fn invalid_configured_prefixes_for_scan( + config: &Config, + display_name: &str, + scan: &ScanInfo, +) -> Vec { + let game_name = scan.game_name.as_str(); + let alias_name = config.display_name(game_name); + let mut seen: BTreeSet = BTreeSet::new(); + let mut prefixes = Vec::new(); + + for game in &config.custom_games { + if game.name == game_name || game.name == alias_name { + for prefix in &game.wine_prefix { + push_unique_prefix(&mut seen, &mut prefixes, StrictPath::new(prefix)); + } + } + } + if let Some(preference) = config + .restore + .preferred_wine_prefixes + .get(game_name) + .or_else(|| config.restore.preferred_wine_prefixes.get(alias_name)) + { + push_unique_prefix(&mut seen, &mut prefixes, preference.path.clone()); + } + + validate_configured_prefixes(display_name, &prefixes) +} + +fn push_unique_prefix(seen: &mut BTreeSet, prefixes: &mut Vec, path: StrictPath) { + if seen.insert(path.render()) { + prefixes.push(path); + } +} + +/// Check whether this preview will start a new portable full backup chain. +pub fn will_start_new_semantic_full_backup(layout: &BackupLayout, scan: &ScanInfo) -> bool { + scan.has_semantic_keys() + && scan.found_anything_processable() + && layout + .try_game_layout(&scan.game_name) + .and_then(|game| game.latest_full_path_format()) + .is_some_and(|path_format| path_format == PathFormat::Legacy) +} + +/// Validate configured prefixes and return those that fail. +pub fn validate_configured_prefixes(game_name: &str, prefixes: &[StrictPath]) -> Vec { + let mut invalid = Vec::new(); + + for prefix_path in prefixes { + let rendered = prefix_path.render(); + if crate::semantic::prefix::validate_prefix(prefix_path).is_none() { + invalid.push(InvalidPrefix { + game_name: game_name.to_string(), + path: rendered, + reason: "Not a valid Wine prefix (missing drive_c, system.reg, or dosdevices)".to_string(), + }); + } + } + + invalid +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantic::{SemanticBase, SemanticPath}; + use crate::{ + resource::config::Config, + scan::{ + ScanChange, ScannedFile, + layout::{BackupLayout, FullBackup, GameLayout, IndividualMapping, IndividualMappingFile, PathFormat}, + }, + testing::s, + }; + use std::collections::{BTreeMap, VecDeque}; + + #[test] + fn empty_analysis_is_empty() { + let analysis = SemanticPreviewAnalysis { + migrations: vec![], + new_full_chains: vec![], + invalid_prefixes: vec![], + conflicts: vec![], + }; + assert!(analysis.is_empty()); + } + + #[test] + fn analysis_with_content_is_not_empty() { + let analysis = SemanticPreviewAnalysis { + migrations: vec![SemanticMigration { + game_name: "Test".to_string(), + legacy_key: "key".to_string(), + semantic_key: SemanticPath { + base: SemanticBase::WinDocuments, + tail: "file.dat".to_string(), + } + .serialize(), + }], + new_full_chains: vec![], + invalid_prefixes: vec![], + conflicts: vec![], + }; + assert!(!analysis.is_empty()); + } + + #[test] + fn backup_preview_analysis_reports_migrations_and_full_chain_switches() { + let temp = tempfile::tempdir().unwrap(); + let backup_root = StrictPath::new(temp.path().join("backup").to_string_lossy().to_string()); + let game_path = backup_root.joined("Game"); + GameLayout::new( + game_path.clone(), + IndividualMapping { + name: "Game".to_string(), + backups: VecDeque::from([FullBackup { + path_format: PathFormat::Legacy, + files: BTreeMap::from([( + "/legacy/save.dat".to_string(), + IndividualMappingFile { + hash: "old".to_string(), + size: 3, + }, + )]), + ..Default::default() + }]), + ..Default::default() + }, + ) + .save(); + let layout = BackupLayout::new(backup_root); + let scan = ScanInfo { + game_name: s("Game"), + has_backups: true, + found_files: [( + StrictPath::new("/prefix/drive_c/users/steamuser/Documents/Game/save.dat"), + ScannedFile { + size: 3, + hash: "new".to_string(), + change: ScanChange::Different, + semantic_key: Some(SemanticPath::parse("/Game/save.dat").unwrap()), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + let analysis = SemanticPreviewAnalysis::from_backup_preview(&Config::default(), &layout, &[("Game", &scan)]); + + assert_eq!(1, analysis.migrations.len()); + assert_eq!( + "/prefix/drive_c/users/steamuser/Documents/Game/save.dat", + analysis.migrations[0].legacy_key + ); + assert_eq!("/Game/save.dat", analysis.migrations[0].semantic_key); + assert_eq!(vec!["Game".to_string()], analysis.new_full_chains); + } + + #[test] + fn backup_preview_analysis_reports_invalid_alias_prefix() { + let scan = ScanInfo { + game_name: s("Game"), + ..Default::default() + }; + let mut config = Config::default(); + config.custom_games.push(crate::resource::config::CustomGame { + name: s("Display Game"), + alias: Some(s("Game")), + prefer_alias: true, + wine_prefix: vec![s("/not/a/prefix")], + ..Default::default() + }); + + let analysis = SemanticPreviewAnalysis::from_backup_preview( + &config, + &BackupLayout::new(StrictPath::new("/tmp/backup")), + &[("Display Game", &scan)], + ); + + assert_eq!(1, analysis.invalid_prefixes.len()); + assert_eq!("Display Game", analysis.invalid_prefixes[0].game_name); + assert_eq!("/not/a/prefix", analysis.invalid_prefixes[0].path); + } + + #[test] + fn backup_preview_analysis_reports_invalid_preferred_prefix() { + let scan = ScanInfo { + game_name: s("Game"), + ..Default::default() + }; + let mut config = Config::default(); + config.restore.preferred_wine_prefixes.insert( + s("Game"), + crate::resource::config::GameWinePrefixPreference { + path: StrictPath::new("/not/a/preferred-prefix"), + ..Default::default() + }, + ); + + let analysis = SemanticPreviewAnalysis::from_backup_preview( + &config, + &BackupLayout::new(StrictPath::new("/tmp/backup")), + &[("Game", &scan)], + ); + + assert_eq!(1, analysis.invalid_prefixes.len()); + assert_eq!("Game", analysis.invalid_prefixes[0].game_name); + assert_eq!("/not/a/preferred-prefix", analysis.invalid_prefixes[0].path); + } +} diff --git a/src/semantic/signals.rs b/src/semantic/signals.rs new file mode 100644 index 00000000..ce60634f --- /dev/null +++ b/src/semantic/signals.rs @@ -0,0 +1,154 @@ +use crate::semantic::SemanticPath; + +/// Signal types for comparing current scan to existing backup. +#[derive(Clone, Debug)] +pub enum SemanticSignal { + /// Current scan and existing backup describe the same save location. + SameSemanticKey { semantic_key: SemanticPath }, + /// Existing backup has entries in a namespace the current platform understands, + /// but current scan has no match. + SameNamespaceMissing { semantic_key: SemanticPath }, + /// Existing backup has entries in a namespace the current platform cannot materialize. + ForeignNamespace { semantic_key: SemanticPath }, + /// Key is understood but multiple physical targets exist. + AmbiguousMaterialization { + semantic_key: SemanticPath, + candidates: Vec, + }, +} + +impl SemanticSignal { + pub fn semantic_key(&self) -> &SemanticPath { + match self { + Self::SameSemanticKey { semantic_key } => semantic_key, + Self::SameNamespaceMissing { semantic_key } => semantic_key, + Self::ForeignNamespace { semantic_key } => semantic_key, + Self::AmbiguousMaterialization { semantic_key, .. } => semantic_key, + } + } + + pub fn is_same_key(&self) -> bool { + matches!(self, Self::SameSemanticKey { .. }) + } + + pub fn is_foreign(&self) -> bool { + matches!(self, Self::ForeignNamespace { .. }) + } + + pub fn is_ambiguous(&self) -> bool { + matches!(self, Self::AmbiguousMaterialization { .. }) + } +} + +/// Compare current scan semantic keys against existing backup semantic keys +/// and produce signals. +pub fn compare_semantic_keys( + current: &[SemanticPath], + backup: &[SemanticPath], + current_can_materialize: impl Fn(&SemanticPath) -> bool, +) -> Vec { + let mut signals = Vec::new(); + + // Use semantic equality for case-insensitive comparison + let _current_set: std::collections::HashSet = current.iter().map(|s| s.serialize()).collect(); + let _backup_set: std::collections::HashSet = backup.iter().map(|s| s.serialize()).collect(); + + // Find keys in backup that the current platform understands + for bk in backup { + // Check if any current key matches semantically (case-insensitive for Windows/Wine) + let current_match = current.iter().find(|c| c.eq_semantic(bk)); + if current_match.is_some() { + signals.push(SemanticSignal::SameSemanticKey { + semantic_key: bk.clone(), + }); + } else if current_can_materialize(bk) { + // Current platform can materialize this key but no current scan match + signals.push(SemanticSignal::SameNamespaceMissing { + semantic_key: bk.clone(), + }); + } else { + // Current platform cannot materialize this key + signals.push(SemanticSignal::ForeignNamespace { + semantic_key: bk.clone(), + }); + } + } + + signals +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantic::SemanticBase; + + fn win_docs(tail: &str) -> SemanticPath { + SemanticPath { + base: SemanticBase::WinDocuments, + tail: tail.to_string(), + } + } + + fn win_drive_d(tail: &str) -> SemanticPath { + SemanticPath { + base: SemanticBase::WinDrive('d'), + tail: tail.to_string(), + } + } + + #[test] + fn same_semantic_key() { + let current = vec![win_docs("Game/save.dat")]; + let backup = vec![win_docs("Game/save.dat")]; + let signals = compare_semantic_keys(¤t, &backup, |_| true); + assert_eq!(signals.len(), 1); + assert!(signals[0].is_same_key()); + } + + #[test] + fn same_namespace_missing() { + let current = vec![]; + let backup = vec![win_docs("Game/save.dat")]; + let signals = compare_semantic_keys(¤t, &backup, |_| true); + assert_eq!(signals.len(), 1); + assert!(matches!(signals[0], SemanticSignal::SameNamespaceMissing { .. })); + } + + #[test] + fn foreign_namespace() { + let current = vec![]; + let backup = vec![win_docs("Game/save.dat")]; + let signals = compare_semantic_keys(¤t, &backup, |_| false); + assert_eq!(signals.len(), 1); + assert!(signals[0].is_foreign()); + } + + #[test] + fn mixed_signals() { + let current = vec![win_docs("Game/save.dat")]; + let backup = vec![ + win_docs("Game/save.dat"), + win_docs("Other/game.dat"), + win_drive_d("Games/save.dat"), + ]; + let signals = compare_semantic_keys(¤t, &backup, |sk| sk.base != SemanticBase::WinDrive('d')); + assert_eq!(signals.len(), 3); + + let same_count = signals.iter().filter(|s| s.is_same_key()).count(); + let missing_count = signals + .iter() + .filter(|s| matches!(s, SemanticSignal::SameNamespaceMissing { .. })) + .count(); + let foreign_count = signals.iter().filter(|s| s.is_foreign()).count(); + + assert_eq!(same_count, 1); + assert_eq!(missing_count, 1); + assert_eq!(foreign_count, 1); + } + + #[test] + fn empty_inputs() { + let signals = compare_semantic_keys(&[], &[], |_| true); + assert!(signals.is_empty()); + } +} From 861f6bd9a5431cae62fb9c88edb992783b23d75f Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Thu, 28 May 2026 22:41:15 -0700 Subject: [PATCH 2/4] test(semantic): add property-based tests and scan benchmarks - proptest round-trips: parse/serialize, storage-path invariants, and materialize->re-derive stability; username and Wine-prefix changes do not change the semantic key - criterion benchmark for physical<->semantic conversion across many paths - test fixture Wine prefix marker file Adds proptest, criterion, and tempfile as dev-dependencies. --- Cargo.lock | 364 +++++++++++++++++-- Cargo.toml | 7 + benches/semantic_scan.rs | 165 +++++++++ tests/semantic_properties.rs | 143 ++++++++ tests/wine-prefix/drive_c/windows/system.reg | 1 + 5 files changed, 643 insertions(+), 37 deletions(-) create mode 100644 benches/semantic_scan.rs create mode 100644 tests/semantic_properties.rs create mode 100644 tests/wine-prefix/drive_c/windows/system.reg diff --git a/Cargo.lock b/Cargo.lock index 2cbf4700..2af0003a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", ] @@ -62,7 +62,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", "zerocopy 0.7.35", @@ -128,6 +128,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.15" @@ -547,7 +553,7 @@ dependencies = [ "bitflags 2.10.0", "log", "polling", - "rustix", + "rustix 0.38.37", "slab", "thiserror 1.0.64", ] @@ -559,11 +565,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ "calloop", - "rustix", + "rustix 0.38.37", "wayland-backend", "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.49" @@ -618,6 +630,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -918,6 +957,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.5" @@ -1168,12 +1243,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1584,6 +1659,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gif" version = "0.13.3" @@ -1864,6 +1951,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hexf-parse" version = "0.2.1" @@ -2237,7 +2330,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2247,7 +2340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", - "quick-error", + "quick-error 2.0.1", ] [[package]] @@ -2350,12 +2443,32 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2550,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -2619,6 +2732,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litrs" version = "1.0.0" @@ -2665,6 +2784,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "criterion", "dialoguer", "dirs", "embed-resource", @@ -2683,6 +2803,7 @@ dependencies = [ "log", "opener", "pretty_assertions", + "proptest", "rayon", "regashii", "regex", @@ -2700,6 +2821,7 @@ dependencies = [ "steamlocate", "strsim", "sysinfo", + "tempfile", "tokio", "typed-path", "unic-langid", @@ -3235,6 +3357,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opener" version = "0.7.2" @@ -3346,7 +3474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -3463,6 +3591,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.14" @@ -3486,7 +3642,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", - "rustix", + "rustix 0.38.37", "tracing", "windows-sys 0.59.0", ] @@ -3610,6 +3766,25 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -3639,6 +3814,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -3679,7 +3860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", - "rand", + "rand 0.8.5", "ring", "rustc-hash 2.0.0", "rustls", @@ -3711,6 +3892,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "radium" version = "0.7.0" @@ -3724,8 +3911,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3735,7 +3932,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3744,7 +3951,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -3785,8 +4010,8 @@ dependencies = [ "once_cell", "paste", "profiling", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "simd_helpers", "system-deps", "thiserror 1.0.64", @@ -3803,7 +4028,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rgb", ] @@ -3869,7 +4094,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox 0.1.3", "thiserror 1.0.64", ] @@ -4051,7 +4276,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", @@ -4117,7 +4342,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.5", "rkyv", "serde", "serde_json", @@ -4159,10 +4384,23 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.23.14" @@ -4209,6 +4447,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rustybuzz" version = "0.20.1" @@ -4554,7 +4804,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix", + "rustix 0.38.37", "thiserror 1.0.64", "wayland-backend", "wayland-client", @@ -4616,7 +4866,7 @@ dependencies = [ "objc2-quartz-core", "raw-window-handle", "redox_syscall 0.5.7", - "rustix", + "rustix 0.38.37", "tiny-xlib", "wasm-bindgen", "wayland-backend", @@ -4803,14 +5053,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.13.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.59.0", ] @@ -4829,7 +5079,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" dependencies = [ - "rustix", + "rustix 0.38.37", "windows-sys 0.59.0", ] @@ -4961,6 +5211,16 @@ dependencies = [ "displaydoc", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -5179,6 +5439,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unic-langid" version = "0.9.5" @@ -5416,6 +5682,15 @@ dependencies = [ "libc", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5441,6 +5716,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -5526,7 +5810,7 @@ checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 0.38.37", "scoped-tls", "smallvec", "wayland-sys", @@ -5539,7 +5823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3f45d1222915ef1fd2057220c1d9d9624b7654443ea35c3877f7a52bd0a5a2d" dependencies = [ "bitflags 2.10.0", - "rustix", + "rustix 0.38.37", "wayland-backend", "wayland-scanner", ] @@ -5561,7 +5845,7 @@ version = "0.31.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a94697e66e76c85923b0d28a0c251e8f0666f58fc47d316c0f4da6da75d37cb" dependencies = [ - "rustix", + "rustix 0.38.37", "wayland-client", "xcursor", ] @@ -5821,7 +6105,7 @@ checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ "either", "home", - "rustix", + "rustix 0.38.37", "winsafe", ] @@ -6318,7 +6602,7 @@ dependencies = [ "pin-project", "raw-window-handle", "redox_syscall 0.4.1", - "rustix", + "rustix 0.38.37", "sctk-adwaita", "smithay-client-toolkit", "smol_str", @@ -6379,6 +6663,12 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wyz" version = "0.5.1" @@ -6410,7 +6700,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix", + "rustix 0.38.37", "x11rb-protocol", ] diff --git a/Cargo.toml b/Cargo.toml index 90b879d3..f53bde55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,9 +82,16 @@ windows = { version = "0.60.0", features = ["Win32_System_Console", "Win32_Syste embed-resource = "3.0.6" [dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } pretty_assertions = "1.4.1" +proptest = "1.6.0" +tempfile = "3.14.0" velcro = "0.5.4" +[[bench]] +name = "semantic_scan" +harness = false + [profile.dev] opt-level = 1 diff --git a/benches/semantic_scan.rs b/benches/semantic_scan.rs new file mode 100644 index 00000000..4194f279 --- /dev/null +++ b/benches/semantic_scan.rs @@ -0,0 +1,165 @@ +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; + +use ludusavi::path::StrictPath; +use ludusavi::resource::manifest::Store; +use ludusavi::scan::saves::ScanOrigin; +use ludusavi::semantic::SemanticPath; +use ludusavi::semantic::convert::{ + KnownFolders, derive_from_manifest_origin, windows_physical_to_semantic, wine_physical_to_semantic, +}; + +fn make_known_folders() -> KnownFolders { + KnownFolders { + saved_games: Some("C:/Users/Alice/Saved Games".to_string()), + documents: Some("C:/Users/Alice/Documents".to_string()), + local_app_data: Some("C:/Users/Alice/AppData/Local".to_string()), + app_data: Some("C:/Users/Alice/AppData/Roaming".to_string()), + public: Some("C:/Users/Public".to_string()), + program_data: Some("C:/ProgramData".to_string()), + windows: Some("C:/Windows".to_string()), + user_profile: Some("C:/Users/Alice".to_string()), + } +} + +fn bench_parse(c: &mut Criterion) { + c.bench_function("semantic_parse", |b| { + b.iter(|| { + SemanticPath::parse("/Game/save.dat").unwrap(); + }) + }); +} + +fn bench_serialize(c: &mut Criterion) { + let sp = SemanticPath::parse("/Game/save.dat").unwrap(); + c.bench_function("semantic_serialize", |b| { + b.iter(|| { + sp.serialize(); + }) + }); +} + +fn bench_storage_path(c: &mut Criterion) { + let sp = SemanticPath::parse("/Game/save.dat").unwrap(); + c.bench_function("semantic_storage_path", |b| { + b.iter(|| { + sp.storage_path(); + }) + }); +} + +fn bench_windows_to_semantic(c: &mut Criterion) { + let kf = make_known_folders(); + let paths = [ + StrictPath::new("C:/Users/Alice/Documents/Game/save.dat"), + StrictPath::new("C:/Users/Alice/AppData/Roaming/Game/config.ini"), + StrictPath::new("C:/Users/Alice/AppData/Local/Game/cache.dat"), + StrictPath::new("C:/ProgramData/Game/telemetry.dat"), + StrictPath::new("D:/Games/save.dat"), + ]; + + let mut group = c.benchmark_group("windows_physical_to_semantic"); + for (i, path) in paths.iter().enumerate() { + group.bench_with_input(BenchmarkId::new("path", i), path, |b, p| { + b.iter(|| { + windows_physical_to_semantic(p, &kf); + }) + }); + } + group.finish(); +} + +fn bench_wine_to_semantic(c: &mut Criterion) { + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + let paths = [ + StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat"), + StrictPath::new("/home/deck/Prefixes/Game/drive_c/users/steamuser/AppData/Roaming/Game/config.ini"), + StrictPath::new("/home/deck/Prefixes/Game/drive_c/ProgramData/Game/telemetry.dat"), + StrictPath::new("/home/deck/Prefixes/Game/drive_d/Games/save.dat"), + ]; + + let mut group = c.benchmark_group("wine_physical_to_semantic"); + for (i, path) in paths.iter().enumerate() { + group.bench_with_input(BenchmarkId::new("path", i), path, |b, p| { + b.iter(|| { + wine_physical_to_semantic(p, &prefix, "steamuser"); + }) + }); + } + group.finish(); +} + +fn bench_manifest_derive(c: &mut Criterion) { + let origins = [ + ScanOrigin { + manifest_path: "/Remedy/Alan Wake".to_string(), + store: Store::Other, + expanded_prefix: "C:/Users/Alice/Documents".to_string(), + matched_prefix_len: 25, + tail: "Remedy/Alan Wake/save.dat".to_string(), + }, + ScanOrigin { + manifest_path: "/userdata///remote".to_string(), + store: Store::Steam, + expanded_prefix: "C:/Program Files (x86)/Steam".to_string(), + matched_prefix_len: 34, + tail: "userdata/12345/67890/remote/save.dat".to_string(), + }, + ]; + + let mut group = c.benchmark_group("manifest_derive"); + for (i, origin) in origins.iter().enumerate() { + group.bench_with_input(BenchmarkId::new("origin", i), origin, |b, o| { + b.iter(|| { + derive_from_manifest_origin(o); + }) + }); + } + group.finish(); +} + +fn bench_batch_scan_simulation(c: &mut Criterion) { + let kf = make_known_folders(); + let prefix = StrictPath::new("/home/deck/Prefixes/Game"); + + // Simulate scanning 500 games worth of paths + let windows_paths: Vec = (0..500) + .map(|i| StrictPath::new(format!("C:/Users/Alice/Documents/Game{}/save.dat", i))) + .collect(); + + let wine_paths: Vec = (0..500) + .map(|i| { + StrictPath::new(format!( + "/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game{}/save.dat", + i + )) + }) + .collect(); + + c.bench_function("batch_500_windows", |b| { + b.iter(|| { + for path in &windows_paths { + windows_physical_to_semantic(path, &kf); + } + }) + }); + + c.bench_function("batch_500_wine", |b| { + b.iter(|| { + for path in &wine_paths { + wine_physical_to_semantic(path, &prefix, "steamuser"); + } + }) + }); +} + +criterion_group!( + benches, + bench_parse, + bench_serialize, + bench_storage_path, + bench_windows_to_semantic, + bench_wine_to_semantic, + bench_manifest_derive, + bench_batch_scan_simulation, +); +criterion_main!(benches); diff --git a/tests/semantic_properties.rs b/tests/semantic_properties.rs new file mode 100644 index 00000000..6a14cee7 --- /dev/null +++ b/tests/semantic_properties.rs @@ -0,0 +1,143 @@ +//! Property-based and round-trip tests for semantic paths. + +use proptest::prelude::*; + +use ludusavi::path::StrictPath; +use ludusavi::semantic::convert::{KnownFolders, windows_physical_to_semantic, wine_physical_to_semantic}; +use ludusavi::semantic::materialize::{MaterializeTarget, materialize_semantic}; +use ludusavi::semantic::{SemanticBase, SemanticPath}; + +fn arb_tail() -> impl Strategy { + // Generate valid tail paths: non-empty, no dots, forward-slash separated + // Use alphanumeric characters only to avoid edge cases with spaces + prop::collection::vec("[a-zA-Z0-9_]{1,20}", 1..5).prop_map(|parts| parts.join("/")) +} + +fn arb_semantic_base() -> impl Strategy { + prop_oneof![ + Just(SemanticBase::WinHome), + Just(SemanticBase::WinDocuments), + Just(SemanticBase::WinAppData), + Just(SemanticBase::WinLocalAppData), + Just(SemanticBase::WinLocalAppDataLow), + Just(SemanticBase::WinSavedGames), + Just(SemanticBase::WinPublic), + Just(SemanticBase::WinProgramData), + Just(SemanticBase::WinDir), + prop::char::range('a', 'z').prop_map(SemanticBase::WinDrive), + ] +} + +fn arb_semantic_path() -> impl Strategy { + (arb_semantic_base(), arb_tail()).prop_map(|(base, tail)| SemanticPath { base, tail }) +} + +proptest! { + #[test] + fn parse_serialize_round_trip(sp in arb_semantic_path()) { + let serialized = sp.serialize(); + let parsed = SemanticPath::parse(&serialized).unwrap(); + prop_assert_eq!(sp, parsed); + } + + #[test] + fn storage_path_never_has_backslash(sp in arb_semantic_path()) { + let storage = sp.storage_path(); + prop_assert!(!storage.contains('\\'), "storage path contains backslash: {}", storage); + } + + #[test] + fn storage_path_starts_with_prefix(sp in arb_semantic_path()) { + let storage = sp.storage_path(); + prop_assert!(storage.starts_with("__ludusavi_semantic__/"), "storage path: {}", storage); + } + + #[test] + fn serde_json_round_trip(sp in arb_semantic_path()) { + let json = serde_json::to_string(&sp).unwrap(); + let deserialized: SemanticPath = serde_json::from_str(&json).unwrap(); + prop_assert_eq!(sp, deserialized); + } + + #[test] + fn changing_username_does_not_change_semantic_key( + tail in "[a-zA-Z0-9_]{1,10}", + user1 in "[a-z]{3,10}", + user2 in "[a-z]{3,10}", + ) { + prop_assume!(user1 != user2); + let kf1 = KnownFolders { + documents: Some(format!("C:/Users/{}/Documents", user1)), + ..Default::default() + }; + let kf2 = KnownFolders { + documents: Some(format!("C:/Users/{}/Documents", user2)), + ..Default::default() + }; + + let path1 = StrictPath::new(format!("C:/Users/{}/Documents/Game/{}", user1, tail)); + let path2 = StrictPath::new(format!("C:/Users/{}/Documents/Game/{}", user2, tail)); + + let sk1 = windows_physical_to_semantic(&path1, &kf1); + let sk2 = windows_physical_to_semantic(&path2, &kf2); + + prop_assert!(sk1.is_some()); + prop_assert!(sk2.is_some()); + prop_assert_eq!(sk1.unwrap(), sk2.unwrap()); + } + + #[test] + fn changing_wine_prefix_does_not_change_semantic_key( + tail in "[a-zA-Z0-9_]{1,10}", + prefix1 in "[a-z]{3,10}", + prefix2 in "[a-z]{3,10}", + ) { + prop_assume!(prefix1 != prefix2); + let p1 = StrictPath::new(format!("/home/{}/Prefixes/Game", prefix1)); + let p2 = StrictPath::new(format!("/home/{}/Prefixes/Game", prefix2)); + let f1 = StrictPath::new(format!("/home/{}/Prefixes/Game/drive_c/users/steamuser/Documents/Game/{}", prefix1, tail)); + let f2 = StrictPath::new(format!("/home/{}/Prefixes/Game/drive_c/users/steamuser/Documents/Game/{}", prefix2, tail)); + + let sk1 = wine_physical_to_semantic(&f1, &p1, "steamuser"); + let sk2 = wine_physical_to_semantic(&f2, &p2, "steamuser"); + + prop_assert!(sk1.is_some()); + prop_assert!(sk2.is_some()); + prop_assert_eq!(sk1.unwrap(), sk2.unwrap()); + } + + #[test] + fn materialize_then_rederive_windows(sp in arb_semantic_path()) { + // All semantic bases are Win* bases that materialize to Windows known folders. + prop_assume!(matches!(sp.base, + SemanticBase::WinHome | + SemanticBase::WinDocuments | + SemanticBase::WinAppData | + SemanticBase::WinLocalAppData | + SemanticBase::WinLocalAppDataLow | + SemanticBase::WinSavedGames | + SemanticBase::WinPublic | + SemanticBase::WinProgramData | + SemanticBase::WinDir | + SemanticBase::WinDrive(_) + )); + + let kf = KnownFolders { + saved_games: Some("C:/Users/Test/Saved Games".to_string()), + documents: Some("C:/Users/Test/Documents".to_string()), + local_app_data: Some("C:/Users/Test/AppData/Local".to_string()), + app_data: Some("C:/Users/Test/AppData/Roaming".to_string()), + public: Some("C:/Users/Public".to_string()), + program_data: Some("C:/ProgramData".to_string()), + windows: Some("C:/Windows".to_string()), + user_profile: Some("C:/Users/Test".to_string()), + }; + + let target = MaterializeTarget::CurrentWindows { known_folders: &kf }; + let physical = materialize_semantic(&sp, &target).unwrap(); + let rederived = windows_physical_to_semantic(&physical, &kf); + + prop_assert!(rederived.is_some(), "re-derivation failed for: {:?} -> {:?}", sp, physical); + prop_assert_eq!(sp, rederived.unwrap()); + } +} diff --git a/tests/wine-prefix/drive_c/windows/system.reg b/tests/wine-prefix/drive_c/windows/system.reg new file mode 100644 index 00000000..512d2aa1 --- /dev/null +++ b/tests/wine-prefix/drive_c/windows/system.reg @@ -0,0 +1 @@ +; Wine system registry From e14a6f1845e9119c64422b03686463e6f4abb168 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Thu, 28 May 2026 22:41:35 -0700 Subject: [PATCH 3/4] i18n(semantic): add translation keys for portable backup UI and errors Add Fluent keys (English source plus fallbacks) for the portable / new-full-backup / conflict / invalid-prefix badges, the Wine prefix conflict and missing-drive errors, and the semantic preview notice. --- lang/ar-SA.ftl | 8 ++++++++ lang/cs-CZ.ftl | 8 ++++++++ lang/de-DE.ftl | 8 ++++++++ lang/en-US.ftl | 18 ++++++++++++++++++ lang/eo-UY.ftl | 8 ++++++++ lang/es-ES.ftl | 8 ++++++++ lang/fi-FI.ftl | 8 ++++++++ lang/fil-PH.ftl | 8 ++++++++ lang/fr-FR.ftl | 8 ++++++++ lang/it-IT.ftl | 8 ++++++++ lang/ja-JP.ftl | 8 ++++++++ lang/ko-KR.ftl | 8 ++++++++ lang/nl-NL.ftl | 8 ++++++++ lang/no-NO.ftl | 8 ++++++++ lang/pl-PL.ftl | 8 ++++++++ lang/pt-BR.ftl | 8 ++++++++ lang/ru-RU.ftl | 8 ++++++++ lang/sv-SE.ftl | 8 ++++++++ lang/th-TH.ftl | 8 ++++++++ lang/tr-TR.ftl | 8 ++++++++ lang/uk-UA.ftl | 8 ++++++++ lang/vi-VN.ftl | 8 ++++++++ lang/zh-CN.ftl | 8 ++++++++ lang/zh-TW.ftl | 8 ++++++++ 24 files changed, 202 insertions(+) diff --git a/lang/ar-SA.ftl b/lang/ar-SA.ftl index 6a24cb30..f1c65522 100644 --- a/lang/ar-SA.ftl +++ b/lang/ar-SA.ftl @@ -259,3 +259,11 @@ new-version-available = يتوفر تحديث للتطبيق: { $version }. هل custom-game-will-override = هذه اللعبة المخصصة تتجاوز عنصر في اللائحة custom-game-will-extend = هذه اللعبة المخصصة توسع عنصر في اللائحة operation-will-only-include-listed-games = سيؤدي هذا فقط إلى معالجة الألعاب المدرجة حاليا + +semantic-prefix-ambiguous = تم العثور على بادئات Wine متعددة لهذه اللعبة. يرجى اختيار واحدة. +semantic-prefix-invalid = بادئة Wine المكونة غير صالحة: { $path } +semantic-drive-missing = القرص { $drive } غير متاح على النظام المستهدف. +semantic-key-conflict = تعيين ملفات متعددة إلى نفس موقع الحفظ المحمول: { $key } +semantic-foreign-namespace = يحتوي هذا النسخ الاحتياطي على حفظات من منصة أخرى لا يمكن استعادتها هنا. +semantic-format-switch-notice = ستنتقل هذه اللعبة إلى تنسيق نسخ احتياطي محمول. سيتم إنشاء نسخ احتياطي كامل جديد. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/cs-CZ.ftl b/lang/cs-CZ.ftl index 78d55023..a33e77b2 100644 --- a/lang/cs-CZ.ftl +++ b/lang/cs-CZ.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Pro tuto hru bylo nalezeno více prefixů Wine. Vyberte jeden. +semantic-prefix-invalid = Nakonfigurovaný prefix Wine není platný: { $path } +semantic-drive-missing = Jednotka { $drive } není dostupná v cílovém systému. +semantic-key-conflict = Více souborů mapuje na stejné přenosné umístění uložení: { $key } +semantic-foreign-namespace = Tato záloha obsahuje uložení z jiné platformy, která zde nelze obnovit. +semantic-format-switch-notice = Tato hra přejde na přenosný formát zálohy. Bude vytvořena nová úplná záloha. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/de-DE.ftl b/lang/de-DE.ftl index 9a0a619e..691e0585 100644 --- a/lang/de-DE.ftl +++ b/lang/de-DE.ftl @@ -259,3 +259,11 @@ new-version-available = Eine Anwendungsaktualisierung ist verfügbar: { $version custom-game-will-override = Dieses benutzerdefinierte Spiel überschreibt einen Manifest-Eintrag custom-game-will-extend = Dieses benutzerdefinierte Spiel erweitert einen Manifest-Eintrag operation-will-only-include-listed-games = Hiermit werden nur die derzeit aufgelisteten Spiele verarbeitet + +semantic-prefix-ambiguous = Für dieses Spiel wurden mehrere Wine-Präfixe gefunden. Bitte wählen Sie eines aus. +semantic-prefix-invalid = Das konfigurierte Wine-Präfix ist ungültig: { $path } +semantic-drive-missing = Laufwerk { $drive } ist auf dem Zielsystem nicht verfügbar. +semantic-key-conflict = Mehrere Dateien werden auf denselben portablen Speicherort abgebildet: { $key } +semantic-foreign-namespace = Diese Sicherung enthält Speicherstände von einer anderen Plattform, die hier nicht wiederhergestellt werden können. +semantic-format-switch-notice = Dieses Spiel wechselt zu einem portablen Sicherungsformat. Eine neue vollständige Sicherung wird erstellt. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/en-US.ftl b/lang/en-US.ftl index 95065360..6ab09c73 100644 --- a/lang/en-US.ftl +++ b/lang/en-US.ftl @@ -13,6 +13,9 @@ cli-unable-to-request-confirmation = Unable to request confirmation. .winpty-workaround = If you are using a Bash emulator (like Git Bash), try running winpty. cli-backup-id-with-multiple-games = Cannot specify backup ID when restoring multiple games. cli-invalid-backup-id = Invalid backup ID. +wine-prefix-conflict = Cannot use --wine-prefix for {$game} because it conflicts with the game's preferred Wine prefix. +wine-prefix-conflict-cli = Command prefix: {$path} +wine-prefix-conflict-configured = Preferred prefix: {$path} badge-failed = FAILED badge-duplicates = DUPLICATES @@ -20,11 +23,17 @@ badge-duplicated = DUPLICATED badge-ignored = IGNORED badge-redirected-from = FROM: {$path} badge-redirecting-to = TO: {$path} +badge-portable = PORTABLE: {$path} +label-portable = Portable +label-new-full-backup = New full backup +label-portable-conflict = Portable conflict +label-invalid-prefix = Invalid prefix some-entries-failed = Some entries failed to process; look for {badge-failed} in the output for details. Double check whether you can access those files or whether their paths are very long. cli-game-line-item-redirected = Redirected from: {$path} cli-game-line-item-redirecting = Redirecting to: {$path} +cli-game-line-item-portable = Stored as portable save location: {$path} button-backup = Back up button-preview = Preview @@ -296,3 +305,12 @@ custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +## Semantic path / cross-platform messages +semantic-prefix-ambiguous = Multiple Wine prefixes found for this game. Please select one. +semantic-prefix-invalid = The configured Wine prefix is not valid: { $path } +semantic-drive-missing = Drive { $drive } is not available on the target system. +semantic-key-conflict = Multiple files map to the same portable save location: { $key } +semantic-foreign-namespace = This backup contains saves from another platform that cannot be restored here. +semantic-format-switch-notice = This game will switch to a portable backup format. A new full backup will be created. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/eo-UY.ftl b/lang/eo-UY.ftl index e559724f..d8013912 100644 --- a/lang/eo-UY.ftl +++ b/lang/eo-UY.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Multaj Wine-prefiksoj trovitaj por ĉi tiu ludo. Bonvolu elekti unu. +semantic-prefix-invalid = La agordita Wine-prefikso ne validas: { $path } +semantic-drive-missing = Disko { $drive } ne disponeblas en la celsistemo. +semantic-key-conflict = Multaj dosieroj mapas al la sama portebla konservloko: { $key } +semantic-foreign-namespace = Ĉi tiu sekurkopio enhavas konservojn de alia platformo, kiuj ne povas esti restarigitaj ĉi tie. +semantic-format-switch-notice = Ĉi tiu ludo ŝanĝos al portebla sekurkopia formato. Nova plena sekurkopio kreiĝos. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/es-ES.ftl b/lang/es-ES.ftl index b4d79315..c8cbc376 100644 --- a/lang/es-ES.ftl +++ b/lang/es-ES.ftl @@ -259,3 +259,11 @@ new-version-available = Una actualización de la aplicación está disponible: { custom-game-will-override = Este juego personalizado reemplaza una entrada de manifiesto custom-game-will-extend = Este juego personalizado extiende una entrada de manifiesto operation-will-only-include-listed-games = Esto solo procesará los juegos que se encuentran actualmente listados + +semantic-prefix-ambiguous = Se encontraron múltiples prefijos de Wine para este juego. Por favor, seleccione uno. +semantic-prefix-invalid = El prefijo de Wine configurado no es válido: { $path } +semantic-drive-missing = La unidad { $drive } no está disponible en el sistema de destino. +semantic-key-conflict = Múltiples archivos se asignan a la misma ubicación de guardado portátil: { $key } +semantic-foreign-namespace = Esta copia de seguridad contiene guardados de otra plataforma que no se pueden restaurar aquí. +semantic-format-switch-notice = Este juego cambiará a un formato de copia de seguridad portátil. Se creará una nueva copia de seguridad completa. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/fi-FI.ftl b/lang/fi-FI.ftl index e321b87f..5e9f63ac 100644 --- a/lang/fi-FI.ftl +++ b/lang/fi-FI.ftl @@ -259,3 +259,11 @@ new-version-available = Sovelluspäivitys saatavilla: { $version }. Haluatko nä custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = Tämä käsittelee vain pelit, jotka on tällä hetkellä lueteltu + +semantic-prefix-ambiguous = Tälle pelille löytyi useita Wine-etuliitteitä. Valitse yksi. +semantic-prefix-invalid = Määritetty Wine-etuliite ei ole kelvollinen: { $path } +semantic-drive-missing = Asema { $drive } ei ole käytettävissä kohdejärjestelmässä. +semantic-key-conflict = Useat tiedostot osoittavat samaan siirrettävään tallennuspaikkaan: { $key } +semantic-foreign-namespace = Tämä varmuuskopio sisältää tallennuksia toiselta alustalta, joita ei voi palauttaa täällä. +semantic-format-switch-notice = Tämä peli siirtyy siirrettävään varmuuskopiointimuotoon. Uusi täysi varmuuskopio luodaan. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/fil-PH.ftl b/lang/fil-PH.ftl index 5079c207..969c5513 100644 --- a/lang/fil-PH.ftl +++ b/lang/fil-PH.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Maraming Wine prefix ang natagpuan para sa larong ito. Pumili ng isa. +semantic-prefix-invalid = Ang nakaconfig na Wine prefix ay hindi valid: { $path } +semantic-drive-missing = Ang drive { $drive } ay hindi available sa target system. +semantic-key-conflict = Maraming file ang naka-map sa parehong portable save location: { $key } +semantic-foreign-namespace = Ang backup na ito ay naglalaman ng saves mula sa ibang platform na hindi ma-restore dito. +semantic-format-switch-notice = Ang larong ito ay magiging portable backup format. Isang bagong full backup ang malilikha. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/fr-FR.ftl b/lang/fr-FR.ftl index c2f3eece..80935351 100644 --- a/lang/fr-FR.ftl +++ b/lang/fr-FR.ftl @@ -257,3 +257,11 @@ new-version-available = Une mise à jour de l'application est disponible : { $ve custom-game-will-override = Ce jeu personnalisé remplace une entrée du manifeste custom-game-will-extend = Ce jeu personnalisé étend une entrée du manifeste operation-will-only-include-listed-games = Cette opération ne traitera que les jeux qui sont actuellement répertoriés + +semantic-prefix-ambiguous = Plusieurs préfixes Wine trouvés pour ce jeu. Veuillez en sélectionner un. +semantic-prefix-invalid = Le préfixe Wine configuré n'est pas valide : { $path } +semantic-drive-missing = Le lecteur { $drive } n'est pas disponible sur le système cible. +semantic-key-conflict = Plusieurs fichiers correspondent au même emplacement de sauvegarde portable : { $key } +semantic-foreign-namespace = Cette sauvegarde contient des sauvegardes d'une autre plateforme qui ne peuvent pas être restaurées ici. +semantic-format-switch-notice = Ce jeu passera à un format de sauvegarde portable. Une nouvelle sauvegarde complète sera créée. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/it-IT.ftl b/lang/it-IT.ftl index 948dae79..3a531a6b 100644 --- a/lang/it-IT.ftl +++ b/lang/it-IT.ftl @@ -259,3 +259,11 @@ new-version-available = Un aggiornamento dell'applicazione è disponibile: { $ve custom-game-will-override = Questo gioco personalizzato sovrascrive una voce del manifesto custom-game-will-extend = Questo gioco personalizzato estende una voce del manifesto operation-will-only-include-listed-games = Questo processerà solo i giochi che sono attualmente elencati + +semantic-prefix-ambiguous = Trovati più prefissi Wine per questo gioco. Selezionarne uno. +semantic-prefix-invalid = Il prefisso Wine configurato non è valido: { $path } +semantic-drive-missing = L'unità { $drive } non è disponibile sul sistema di destinazione. +semantic-key-conflict = Più file mappano la stessa posizione di salvataggio portatile: { $key } +semantic-foreign-namespace = Questo backup contiene salvataggi da un'altra piattaforma che non possono essere ripristinati qui. +semantic-format-switch-notice = Questo gioco passerà a un formato di backup portatile. Verrà creata una nuova backup completa. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/ja-JP.ftl b/lang/ja-JP.ftl index 1327e9ff..95c48ecb 100644 --- a/lang/ja-JP.ftl +++ b/lang/ja-JP.ftl @@ -257,3 +257,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = このゲームに複数の Wine プレフィックスが見つかりました。1つを選択してください。 +semantic-prefix-invalid = 設定された Wine プレフィックスが無効です: { $path } +semantic-drive-missing = ドライブ { $drive } はターゲットシステムで利用できません。 +semantic-key-conflict = 複数のファイルが同じポータブル保存場所にマップされています: { $key } +semantic-foreign-namespace = このバックアップには、ここでは復元できない他のプラットフォームのセーブデータが含まれています。 +semantic-format-switch-notice = このゲームはポータブルバックアップ形式に切り替わります。新しいフルバックアップが作成されます。 +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/ko-KR.ftl b/lang/ko-KR.ftl index 88547bef..b302f8bb 100644 --- a/lang/ko-KR.ftl +++ b/lang/ko-KR.ftl @@ -257,3 +257,11 @@ new-version-available = 업데이트를 할 수 있습니다: { $version }. 변 custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = 이 게임에 대해 여러 Wine 접두사가 발견되었습니다. 하나를 선택하세요. +semantic-prefix-invalid = 구성된 Wine 접두사가 유효하지 않습니다: { $path } +semantic-drive-missing = 드라이브 { $drive }는 대상 시스템에서 사용할 수 없습니다. +semantic-key-conflict = 여러 파일이 동일한 휴대용 저장 위치에 매핑됩니다: { $key } +semantic-foreign-namespace = 이 백업에는 여기에서 복원할 수 없는 다른 플랫폼의 저장 데이터가 포함되어 있습니다. +semantic-format-switch-notice = 이 게임은 휴대용 백업 형식으로 전환됩니다. 새로운 전체 백업이 생성됩니다. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/nl-NL.ftl b/lang/nl-NL.ftl index 9b956a31..b5fb6942 100644 --- a/lang/nl-NL.ftl +++ b/lang/nl-NL.ftl @@ -257,3 +257,11 @@ new-version-available = Een applicatie-update is beschikbaar: { $version }. Wil custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Meerdere Wine-prefixen gevonden voor dit spel. Selecteer er een. +semantic-prefix-invalid = Het geconfigureerde Wine-prefix is niet geldig: { $path } +semantic-drive-missing = Schijf { $drive } is niet beschikbaar op het doelsysteem. +semantic-key-conflict = Meerdere bestanden worden toegewezen aan dezelfde draagbare opslaglocatie: { $key } +semantic-foreign-namespace = Deze back-up bevat saves van een ander platform die hier niet kunnen worden hersteld. +semantic-format-switch-notice = Dit spel schakelt over naar een draagbaar back-upformaat. Een nieuwe volledige back-up wordt gemaakt. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/no-NO.ftl b/lang/no-NO.ftl index 81e598e9..edee6158 100644 --- a/lang/no-NO.ftl +++ b/lang/no-NO.ftl @@ -243,3 +243,11 @@ new-version-available = En programoppdatering er tilgjenglig: { $version }. Vil custom-game-will-override = Dette tilpassede spillet overskriver en manifest oppføring custom-game-will-extend = Dette tilpassede spillet utvider en manifest oppføring operation-will-only-include-listed-games = Dette kommer bare til å prosessere spillene som er for øyeblikket oppført + +semantic-prefix-ambiguous = Flere Wine-prefikser funnet for dette spillet. Velg ett. +semantic-prefix-invalid = Den konfigurerte Wine-prefiksen er ikke gyldig: { $path } +semantic-drive-missing = Stasjon { $drive } er ikke tilgjengelig på målsystemet. +semantic-key-conflict = Flere filer mapper til samme bærbare lagringsplassering: { $key } +semantic-foreign-namespace = Denne sikkerhetskopien inneholder lagringer fra en annen plattform som ikke kan gjenopprettes her. +semantic-format-switch-notice = Dette spillet vil bytte til et bærbart sikkerhetskopiformat. En ny full sikkerhetskopi vil bli opprettet. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/pl-PL.ftl b/lang/pl-PL.ftl index 0c16d7e7..691f2c75 100644 --- a/lang/pl-PL.ftl +++ b/lang/pl-PL.ftl @@ -257,3 +257,11 @@ new-version-available = Dostępna jest aktualizacja aplikacji: { $version }. Chc custom-game-will-override = Ta niestandardowa gra zastępuje wpis manifestu custom-game-will-extend = Ta niestandardowa gra rozszerza wpis manifestu operation-will-only-include-listed-games = Spowoduje to przetworzenie tylko tych gier, które aktualnie znajdują się na liście + +semantic-prefix-ambiguous = Znaleziono wiele prefiksów Wine dla tej gry. Wybierz jeden. +semantic-prefix-invalid = Skonfigurowany prefiks Wine jest nieprawidłowy: { $path } +semantic-drive-missing = Dysk { $drive } nie jest dostępny w systemie docelowym. +semantic-key-conflict = Wiele plików mapuje to samo przenośne miejsce zapisu: { $key } +semantic-foreign-namespace = Ta kopia zapasowa zawiera zapisy z innej platformy, których nie można tu przywrócić. +semantic-format-switch-notice = Ta gra przełączy się na przenośny format kopii zapasowej. Zostanie utworzona nowa pełna kopia zapasowa. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/pt-BR.ftl b/lang/pt-BR.ftl index 389645d9..722b9751 100644 --- a/lang/pt-BR.ftl +++ b/lang/pt-BR.ftl @@ -259,3 +259,11 @@ new-version-available = Uma atualização do aplicativo está disponível: { $ve custom-game-will-override = Esse jogo personalizado substitui uma entrada de manifesto custom-game-will-extend = Este jogo personalizado estende uma entrada de manifesto operation-will-only-include-listed-games = Isso processará apenas os jogos que estão listados no momento + +semantic-prefix-ambiguous = Vários prefixos do Wine encontrados para este jogo. Selecione um. +semantic-prefix-invalid = O prefixo do Wine configurado não é válido: { $path } +semantic-drive-missing = A unidade { $drive } não está disponível no sistema de destino. +semantic-key-conflict = Vários arquivos mapeiam para o mesmo local de salvamento portátil: { $key } +semantic-foreign-namespace = Este backup contém saves de outra plataforma que não podem ser restaurados aqui. +semantic-format-switch-notice = Este jogo mudará para um formato de backup portátil. Um novo backup completo será criado. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/ru-RU.ftl b/lang/ru-RU.ftl index 0704c1a7..780093f8 100644 --- a/lang/ru-RU.ftl +++ b/lang/ru-RU.ftl @@ -259,3 +259,11 @@ new-version-available = Доступно обновление приложени custom-game-will-override = Пользовательская игра переопределяет элемент манифеста custom-game-will-extend = Пользовательская игра расширяет манифест operation-will-only-include-listed-games = Обработаются только перечисленные игры + +semantic-prefix-ambiguous = Для этой игры найдено несколько префиксов Wine. Выберите один. +semantic-prefix-invalid = Настроенный префикс Wine недействителен: { $path } +semantic-drive-missing = Диск { $drive } недоступен в целевой системе. +semantic-key-conflict = Несколько файлов сопоставляются с одним и тем же портативным местом сохранения: { $key } +semantic-foreign-namespace = Эта резервная копия содержит сохранения с другой платформы, которые нельзя восстановить здесь. +semantic-format-switch-notice = Эта игра перейдет на портативный формат резервного копирования. Будет создана новая полная резервная копия. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/sv-SE.ftl b/lang/sv-SE.ftl index 59c9de1e..a48688fe 100644 --- a/lang/sv-SE.ftl +++ b/lang/sv-SE.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Flera Wine-prefix hittades för detta spel. Välj ett. +semantic-prefix-invalid = Det konfigurerade Wine-prefixet är inte giltigt: { $path } +semantic-drive-missing = Enheten { $drive } är inte tillgänglig på målsystemet. +semantic-key-conflict = Flera filer mappar till samma bärbara sparplats: { $key } +semantic-foreign-namespace = Denna säkerhetskopia innehåller sparningar från en annan plattform som inte kan återställas här. +semantic-format-switch-notice = Detta spel kommer att växla till ett bärbart säkerhetskopieringsformat. En ny fullständig säkerhetskopia skapas. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/th-TH.ftl b/lang/th-TH.ftl index 4a943a49..8ae8405f 100644 --- a/lang/th-TH.ftl +++ b/lang/th-TH.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = พบหลาย Wine prefix สำหรับเกมนี้ กรุณาเลือกหนึ่งรายการ +semantic-prefix-invalid = Wine prefix ที่กำหนดค่าไม่ถูกต้อง: { $path } +semantic-drive-missing = ไดรฟ์ { $drive } ไม่พร้อมใช้งานบนระบบปลายทาง +semantic-key-conflict = มีไฟล์หลายไฟล์ที่แมปไปยังตำแหน่งบันทึกแบบพกพาเดียวกัน: { $key } +semantic-foreign-namespace = การสำรองข้อมูลนี้มีข้อมูลบันทึกจากแพลตฟอร์มอื่นที่ไม่สามารถกู้คืนได้ที่นี่ +semantic-format-switch-notice = เกมนี้จะเปลี่ยนเป็นรูปแบบการสำรองข้อมูลแบบพกพา จะสร้างการสำรองข้อมูลแบบเต็มใหม่ +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/tr-TR.ftl b/lang/tr-TR.ftl index a88def06..d707f1bf 100644 --- a/lang/tr-TR.ftl +++ b/lang/tr-TR.ftl @@ -259,3 +259,11 @@ new-version-available = Güncelleme mevcut: { $version }. Sürüm notlarını g custom-game-will-override = Bu özel oyun, bildirim girişini geçersiz kılıyor custom-game-will-extend = Bu özel oyun, manifest girişini genişletiyor operation-will-only-include-listed-games = Bu yalnızca şu anda listelenen oyunları işleyecektir + +semantic-prefix-ambiguous = Bu oyun için birden fazla Wine öneki bulundu. Lütfen birini seçin. +semantic-prefix-invalid = Yapılandırılan Wine öneki geçersiz: { $path } +semantic-drive-missing = { $drive } sürücüsü hedef sistemde kullanılamıyor. +semantic-key-conflict = Birden fazla dosya aynı taşınabilir kaydetme konumuna eşleniyor: { $key } +semantic-foreign-namespace = Bu yedek, burada geri yüklenemeyecek başka bir platformdan kayıtlar içeriyor. +semantic-format-switch-notice = Bu oyun taşınabilir bir yedekleme formatına geçecek. Yeni bir tam yedekleme oluşturulacak. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/uk-UA.ftl b/lang/uk-UA.ftl index f2ae56e5..925cbec2 100644 --- a/lang/uk-UA.ftl +++ b/lang/uk-UA.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Для цієї гри знайдено кілька префіксів Wine. Виберіть один. +semantic-prefix-invalid = Налаштований префікс Wine недійсний: { $path } +semantic-drive-missing = Диск { $drive } недоступний у цільовій системі. +semantic-key-conflict = Кілька файлів відображаються на одне й те саме портативне місце збереження: { $key } +semantic-foreign-namespace = Ця резервна копія містить збереження з іншої платформи, які не можна відновити тут. +semantic-format-switch-notice = Ця гра перейде на портативний формат резервного копіювання. Буде створено нову повну резервну копію. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/vi-VN.ftl b/lang/vi-VN.ftl index dc44eb8f..f3b17f92 100644 --- a/lang/vi-VN.ftl +++ b/lang/vi-VN.ftl @@ -259,3 +259,11 @@ new-version-available = An application update is available: { $version }. Would custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = Đã tìm thấy nhiều tiền tố Wine cho trò chơi này. Vui lòng chọn một. +semantic-prefix-invalid = Tiền tố Wine được cấu hình không hợp lệ: { $path } +semantic-drive-missing = Ổ đĩa { $drive } không khả dụng trên hệ thống đích. +semantic-key-conflict = Nhiều tệp ánh xạ đến cùng một vị trí lưu di động: { $key } +semantic-foreign-namespace = Bản sao lưu này chứa dữ liệu lưu từ nền tảng khác không thể khôi phục tại đây. +semantic-format-switch-notice = Trò chơi này sẽ chuyển sang định dạng sao lưu di động. Một bản sao lưu đầy đủ mới sẽ được tạo. +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/zh-CN.ftl b/lang/zh-CN.ftl index dab225f6..66da4ade 100644 --- a/lang/zh-CN.ftl +++ b/lang/zh-CN.ftl @@ -249,3 +249,11 @@ new-version-available = 应用程序更新可用:{ $version }. 是否要查看 custom-game-will-override = 这个自定义游戏会覆盖一个清单项 custom-game-will-extend = 这个自定义游戏会扩展一个清单项 operation-will-only-include-listed-games = 这将仅处理当前列出的游戏 + +semantic-prefix-ambiguous = 检测到多个 Wine 前缀。请选择一个。 +semantic-prefix-invalid = 配置的 Wine 前缀无效:{ $path } +semantic-drive-missing = 目标系统中不存在 { $drive } 盘符。 +semantic-key-conflict = 多个文件映射到同一可移植保存位置:{ $key } +semantic-foreign-namespace = 此备份包含来自其他平台的存档,无法在此恢复。 +semantic-format-switch-notice = 此游戏将切换为可移植备份格式。将创建新的完整备份。 +semantic-preview-would-become = { $legacy } → { $semantic } diff --git a/lang/zh-TW.ftl b/lang/zh-TW.ftl index 5ce670f6..752b82ff 100644 --- a/lang/zh-TW.ftl +++ b/lang/zh-TW.ftl @@ -257,3 +257,11 @@ new-version-available = 可用的更新:{ $version }。您要查看更新說 custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry operation-will-only-include-listed-games = This will only process the games that are currently listed + +semantic-prefix-ambiguous = 偵測到多個 Wine 前綴。請選擇一個。 +semantic-prefix-invalid = 設定的 Wine 前綴無效:{ $path } +semantic-drive-missing = 目標系統中不存在 { $drive } 磁碟機。 +semantic-key-conflict = 多個檔案對應到同一個可攜式儲存位置:{ $key } +semantic-foreign-namespace = 此備份包含其他平台的存檔,無法在此還原。 +semantic-format-switch-notice = 此遊戲將切換為可攜式備份格式。將建立新的完整備份。 +semantic-preview-would-become = { $legacy } → { $semantic } From 06bb7d7cb0ed5846b4b5827b9335af4b27ff0304 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Thu, 28 May 2026 22:41:53 -0700 Subject: [PATCH 4/4] docs(semantic): document portable Windows/Wine backups - cross-platform-sync-plan.md: design and implementation plan, scoped to Windows<->Wine/Proton; XDG bases, Steam userdata identity, and cross-platform registry translation are listed as explicitly out of scope - help: explain semantic vs physical paths, the new config fields, and the Windows/Wine transfer support level - schema: document `backup.semanticPaths`, restore `winePrefix`/`driveMappings`, and per-game preferred prefixes --- docs/cross-platform-sync-plan.md | 1828 +++++++++++++++++ docs/help/backup-structure.md | 23 + docs/help/backup-validation.md | 7 + docs/help/configuration-file.md | 9 + docs/help/redirects.md | 6 + docs/help/roots.md | 8 + .../transfer-between-operating-systems.md | 52 +- docs/schema/config.yaml | 38 + docs/schema/general-output.yaml | 83 + 9 files changed, 2049 insertions(+), 5 deletions(-) create mode 100644 docs/cross-platform-sync-plan.md diff --git a/docs/cross-platform-sync-plan.md b/docs/cross-platform-sync-plan.md new file mode 100644 index 00000000..e317d9ed --- /dev/null +++ b/docs/cross-platform-sync-plan.md @@ -0,0 +1,1828 @@ +# Cross-platform save synchronization plan + +This document summarizes the context and proposed implementation plan for +making Ludusavi backups portable across operating systems, with a first focus +on Windows paths and Wine/Proton prefixes. + +Related issues: + +- https://github.com/mtkennerly/ludusavi/issues/156 +- https://github.com/mtkennerly/ludusavi/issues/194 +- https://github.com/mtkennerly/ludusavi/issues/310 +- https://github.com/mtkennerly/ludusavi/issues/490 + +Related documentation: + +- `docs/help/backup-structure.md` +- `docs/help/backup-validation.md` +- `docs/help/redirects.md` +- `docs/help/roots.md` +- `docs/help/transfer-between-operating-systems.md` + +## Goal + +Ludusavi should be able to back up on any supported platform and restore on any +supported platform whenever the save locations are semantically equivalent. +Machine-specific details such as the source operating system username, SteamOS' +`deck` account, a Wine username such as `steamuser`, or the absolute location of +a Wine prefix should not define the identity of the save data. + +The core principle is: + +> A backup should identify the semantic save location, not the source machine's +> physical path. + +For example, these paths should be treated as the same logical save location: + +```text +C:/Users/Alice/Documents/Remedy/Alan Wake/save.dat +/home/deck/Games/Heroic/Prefixes/Alan Wake/drive_c/users/steamuser/Documents/Remedy/Alan Wake/save.dat +``` + +Both represent the Windows "Documents" save location for the game. The username +and Wine prefix only describe where that semantic location happens to live on a +specific machine. + +## Current behavior + +Today, backups are keyed by rendered paths after redirects are applied. +`ScannedFile::mapping_key` stores `effective(scan_key).render()`, and backup +planning writes that key directly into `mapping.yaml`. The simple backup format +then converts the rendered path into `drive-*` folders for on-disk storage. + +This is safe for same-machine restore, but it couples the backup identity to +the machine that created it. A Wine save discovered on Linux may be stored under +a path like: + +```text +/home/deck/Games/Heroic/Prefixes/Alan Wake/drive_c/users/steamuser/Documents/... +``` + +That path is not useful as the canonical identity of the save. It includes: + +- the Linux user's home directory +- the launcher's prefix layout +- the game-specific prefix folder +- the Wine user name + +The prefix is useful while scanning and restoring on Linux, but it should not be +part of the portable backup key. + +## Issue context + +### #156: Relative paths instead of absolute paths + +Issue #156 proposed storing paths relative to save roots or manifest entries. +The discussion identified several hard cases: + +- one backup may include files from multiple system users; +- special folders such as Documents or XDG directories can be relocated; +- the same game can be installed through multiple stores; +- native Windows and native Linux paths are not always equivalent; +- the manifest usually lists folders, not file-level cross-OS relationships. + +The important conclusion is that fully relative, cross-OS restoration is not +reliable without more semantic information. However, the later discussion +introduced a narrower and more reliable idea: a Windows game running through +Wine should be backed up as a Windows save, not as a Linux save whose path +happens to contain `drive_c`. + +### #194: Translate Wine prefixes across OSes + +Issue #194 focuses on Windows/Wine portability. The original proposal suggested +adding backup metadata such as `os` and `wine_prefixes`, then translating paths +that live under known Wine prefixes. + +The discussion later clarified several user pain points: + +- backing up in Wine on Linux and restoring on Windows should not require a + per-game redirect; +- backing up on Windows and restoring into Wine on Linux needs a way to choose + the target prefix; +- current game-specific Wine prefix roots help scanning, but they do not solve + cross-platform path identity; +- redirects from a whole prefix root to `C:/Users/` do not work when the + prefix path itself is still retained in the backup key; +- users currently need fully game-specific redirects down to + `.../drive_c/users/` to get a clean Windows-style result. + +This plan treats #194 as the primary implementation target. + +### #310: Complex Wine prefixes across installs + +Issue #310 describes a different but related problem: redirects only replace +path prefixes. They do not handle variable path segments after the game +directory, such as: + +```text +/home/dane/game/drive_c/users/dane/saves +/home/deck/game/drive_c/users/deck/saves +``` + +Regex redirects with capture groups would help advanced users normalize these +paths. That is useful, but it is not the best first fix for cross-platform +restore. Regex redirects still require users to encode machine-specific paths. +The more direct solution is to remove usernames and Wine prefix locations from +the backup identity in the first place. + +Regex redirects should remain a follow-up enhancement, not the foundation of +the cross-platform model. + +### #490: Foreign-platform saves + +Issue #490 asks for automatically de-selecting saves from another platform. +That request is a symptom of the same underlying issue: Ludusavi can currently +see saves from another OS in the same backup set, but it does not know whether +they are portable, foreign, redundant, or dangerous to remove. + +Semantic path identity gives Ludusavi a better basis for warnings. If two +physical paths map to the same semantic location, they are cross-platform +counterparts. If they do not, Ludusavi can keep warning or require user action. + +## Pain point coverage + +| Source | Pain point | Coverage | Notes | +| --- | --- | --- | --- | +| #194 | Wine backup to Windows restore without per-game redirects | Phase 1 and Phase 3 | Directly addressed by storing Windows/Wine saves under the same semantic key. | +| #194 | Windows backup to Wine restore needs a target prefix | Phase 3 | Requires deterministic prefix selection or an explicit per-game choice. | +| #194 | SteamOS `deck`, Wine `steamuser`, and Windows usernames should not affect backup keys | User identity model | Covered for current-user saves; multi-user scans must stay explicit. | +| #194 | Registry transfer between Windows and Wine | Registry model | Explicitly deferred, but the format should reserve registry metadata now. | +| #310 | Variable path segments such as `drive_c/users/` | Semantic paths | Solved by removing the user segment from the backup identity. | +| #310 | Regex redirects | Phase 5 | Kept as an advanced feature after semantic paths exist. | +| transfer docs | Native Windows and native Linux paths may not be equivalent | Phase 6 | Not solved by Phase 1 unless manifest metadata proves equivalence. | +| transfer docs | Relocated Windows KnownFolders and changed XDG paths | Phase 3 | Restore must materialize semantic paths through current-platform location APIs where available. | +| #490 | Foreign-platform backup entries | Phase 4 | Semantic comparison should expose explicit match and mismatch signals. | + +## Design principle + +Separate the three meanings that are currently collapsed into one path string: + +1. Physical path + The actual file path used for reading or writing on the current machine. + +2. Semantic path + The portable identity of the save location, independent of username, + Wine prefix location, or current OS. + +3. Storage path + The safe path used inside Ludusavi's backup folder or zip archive. + +For normal legacy paths, these may still line up closely. For Windows/Wine +paths, they must be distinct. + +Example: + +```text +Physical path: + /home/deck/Games/Heroic/Prefixes/Alan Wake/drive_c/users/steamuser/Documents/Remedy/Alan Wake/save.dat + +Semantic path: + /Remedy/Alan Wake/save.dat + +Storage path: + __ludusavi_semantic__/winDocuments/Remedy/Alan Wake/save.dat +``` + +On Windows, the same semantic path would materialize to the current user's +Documents folder. In Wine, it would materialize to the chosen prefix's +`drive_c/users//Documents` folder. + +## Proposed semantic paths + +Use existing manifest-style placeholders where possible because several of them +already represent semantic locations. Add new tokens only where the current +placeholder set cannot describe a common Windows location. + +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` +- `` + +Proposed new semantic tokens: + +- `` +- `` + +For the first implementation phase, only Windows semantic locations should be +used for Windows and Wine equivalence. Native Linux/macOS equivalence can be +added later when Ludusavi has enough information to prove that two manifest +locations are counterparts. + +Examples: + +```text +Windows physical: + C:/Users/Alice/AppData/Roaming/Game/save.dat +Semantic: + /Game/save.dat + +Wine physical: + /home/deck/Prefixes/Game/drive_c/users/steamuser/AppData/Roaming/Game/save.dat +Semantic: + /Game/save.dat + +Windows physical: + C:/ProgramData/Game/save.dat +Semantic: + /Game/save.dat +``` + +Usernames are intentionally absent from these semantic paths for normal +current-user saves. + +## Semantic key syntax + +Semantic keys are serialized strings in `mapping.yaml`, but they are not OS +paths. They must be parsed as semantic keys before use. + +Canonical rules: + +- use `/` as the only separator; +- begin with a recognized semantic base token, such as `` or + ``; +- reject `.` and `..` path components after parsing; +- preserve the display casing of the discovered tail; +- compare keys according to the semantic base's case policy; +- never pass a semantic key directly to `StrictPath` or filesystem APIs. + +Initial case policy: + +- Windows/Wine semantic bases are case-insensitive for equality and conflict + detection, but preserve casing for display and storage; +- Steam userdata should use the manifest/store policy for the matched entry; +- future native Linux/macOS bases should keep their platform case policy. + +If two distinct physical files produce semantic keys that differ only by case in +a case-insensitive namespace, treat that as a semantic-key conflict. Do not +choose one file silently. This covers cases where a native Linux game has +case-distinct files that would collide on Windows. + +Storage-path generation should encode semantic keys through a single structured +function rather than by string concatenation. That function owns escaping, +reserved component handling, and case-collision checks. + +## Semantic key sources + +Semantic keys should come from explicit source information, not from filename +similarity. + +There are two primary sources. + +### Source precedence + +Semantic key derivation must be deterministic: + +1. manifest-derived keys take precedence whenever a scan candidate has manifest + origin metadata; +2. platform-location reverse mapping is used only when a path has no usable + manifest origin; +3. if neither source can produce a semantic key, the file stays under legacy + physical-path behavior. + +This avoids different code paths producing different keys for the same file. +For example, a Steam userdata path should use a manifest/store-derived +`` key instead of being reverse-mapped as a generic path under a +Windows user profile. + +### Manifest-derived keys + +When a scan path comes from a manifest entry, Ludusavi should keep enough origin +metadata to reconstruct the matched placeholder path and the tail below it. The +manifest entry is often already the best semantic description of the save. +However, placeholders are not all equally portable. A semantic key derived from +`` or `` must include the store/root identity when that identity is +needed to distinguish two installs of the same game. Steam and GOG install +folder saves should not collapse into one key merely because both came from a +manifest entry ending in the same file name. + +Stable root identity should come from durable metadata, not from the host path. +Examples: + +- Steam: store kind plus Steam app ID or shortcut identity, with Steam root + omitted from the semantic key; +- GOG/Epic/Heroic/Lutris: store kind plus store game ID when available; +- generic `Other` roots: only use a manifest-derived portable key when the + configured root has a stable user-visible identity; otherwise keep legacy + physical-path behavior for that entry. + +When no stable root identity exists, do not invent one from the absolute root +path. That would reintroduce the machine-specific coupling this design removes. + +When multiple manifest entries match the same physical file, choose the most +specific entry, defined as the longest matched physical prefix after placeholder +expansion. If two entries have the same matched length, break ties by manifest +declaration order. The same rule must be used in backup preview, backup, and +restore planning. + +For example: + +```yaml +files: + "/Remedy/Alan Wake": {} +``` + +If the physical file is: + +```text +/home/deck/Prefixes/Alan Wake/drive_c/users/steamuser/Documents/Remedy/Alan Wake/save.dat +``` + +then the semantic key should be: + +```text +/Remedy/Alan Wake/save.dat +``` + +This also matters for store-specific paths that are not Windows KnownFolders. +Steam `userdata` is the most important early case. Windows and Linux Steam paths +often differ only by the Steam root: + +```text +C:/Program Files (x86)/Steam/userdata///remote/save.dat +/home/deck/.local/share/Steam/userdata///remote/save.dat +``` + +These should be modeled with a store-specific semantic base, for example: + +```text +///remote/save.dat +``` + +`` is intentionally part of the semantic key. Steam account A's +save and Steam account B's save are different semantic locations even when they +belong to the same game on the same machine. This is different from OS usernames +and Wine usernames, which are host materialization details for current-user +profile saves. + +This is not the same as native Windows/Linux path guessing. It is valid because +the Steam userdata structure and manifest placeholders prove that the paths are +the same store-owned save namespace. + +Manifest changes only affect future scans. Existing backup keys are recorded as +strings in `mapping.yaml` and must not be retroactively rewritten by re-parsing +old backups against a newer manifest. Differential comparison should compare the +stored old semantic key to the newly scanned semantic key. + +Implementation implication: scan candidates should carry an origin record such +as the manifest path, root/store kind, expanded placeholders, matched prefix, +and matched tail. A plain `HashSet` is not enough once semantic keys +are introduced. + +### Platform-location reverse mapping + +When manifest origin metadata is not enough, Ludusavi can reverse-map physical +Windows or Wine paths into known semantic bases: + +- current Windows KnownFolders to ``, ``, + ``, ``, ``, and related + bases; +- Wine prefix paths under `drive_c/users//...` to the corresponding + Windows semantic bases; +- Wine `ProgramData`, `Windows`, and drive roots to ``, + ``, or `WinDrive(char)`. + +Reverse mapping must be explicit and ordered. Prefer more specific bases before +broader ones: + +1. `Saved Games` +2. `Documents` and `My Documents` +3. `AppData/LocalLow` +4. `AppData/Local` and `Local Settings/Application Data` +5. `AppData/Roaming` and `Application Data` +6. `Public` +7. `ProgramData` +8. `Windows` +9. current-user home +10. drive root + +`` means the current Windows user profile root after all more specific +KnownFolder bases have failed to match. For example, +`C:/Users/Alice/MyGames/save.dat` may become `/MyGames/save.dat`, but +`C:/Users/Alice/Documents/Game/save.dat` must become `/Game/save.dat` +when Documents resolves to that location. + +When reverse-mapping to ``, the first tail component should not be a +known folder alias such as `Documents`, `My Documents`, `AppData`, `Application +Data`, `Local Settings`, `Saved Games`, or `Desktop` unless the relevant +KnownFolder check already proved that the directory is not that semantic +location on the current machine. This keeps the broad user-profile base from +swallowing paths that should have been handled by a more specific base. + +Windows and Wine matching should be case-insensitive for these base segments. +This covers common Wine and legacy aliases such as `Application Data`, +`Local Settings/Application Data`, `My Documents`, and case variations like +`Appdata`. + +Native Windows-to-native Linux equivalence must not be inferred from filename +or suffix similarity. It should require a manifest relationship or explicit +user configuration. + +## User identity model + +Most save data belongs to the current user profile. In that common case, +the original username is irrelevant: + +- Windows backup from `C:/Users/Alice/...` should restore for user `Bob` on a + different Windows machine. +- Windows backup from `C:/Users/Alice/...` should restore into + `drive_c/users/steamuser/...` in a SteamOS/Wine prefix. +- SteamOS/Wine backup from `drive_c/users/steamuser/...` should restore into + the current Windows user's profile on Windows. + +Multi-user backups are a separate case and must be explicit. Ludusavi should +not silently encode multiple users through absolute paths. If a scan includes +save locations for multiple Windows users, the resulting semantic identity +should include an explicit profile identifier or require user selection. + +The first implementation should support only the current-user profile for +semantic Windows/Wine keys. If multiple user profiles are found for one game in +one scan, Ludusavi should either: + +- classify only the active/current profile semantically and keep other profiles + under existing absolute-path behavior; or +- stop and ask the user to assign explicit profile identities. + +It should not silently merge two user profiles into the same semantic key. + +## Wine prefix model + +Wine prefixes are restore targets, not backup identities. + +When scanning a Wine prefix, Ludusavi needs the prefix path to find files. When +restoring into Wine, Ludusavi needs the prefix path to choose where files go. +However, the prefix path should not appear in the semantic backup key. + +Prefix discovery should use the information Ludusavi already has: + +- Steam Proton compatdata prefixes; +- Heroic game prefixes; +- Lutris prefixes; +- custom game `winePrefix` entries; +- the CLI `--wine-prefix` option; +- configured Wine prefix roots, including roots that use ``. + +macOS bottles from tools such as CrossOver or Whisky should use the same model +once their bottle path is normalized to a Wine-prefix-like directory. A bottle +is a prefix provider; it is not a separate backup identity. + +First-version macOS bottle support may be limited to explicit bottle/prefix +paths supplied by the user. Automatic discovery for CrossOver +(`~/Library/Application Support/CrossOver/Bottles/`) and Whisky +(`~/Library/Containers/com.isaacmarovitz.Whisky/Bottles/` with metadata) +can be a follow-up task, but any explicit bottle path should still pass the same +prefix validation rules below. + +### Prefix validation + +Every discovered prefix should pass the same validation before it can +participate in semantic mapping. A valid prefix should have: + +- a `drive_c` directory; +- at least one Wine state marker such as `system.reg`, `user.reg`, or + `dosdevices`; +- a usable `drive_c/users` directory for current-user semantic paths, unless + the path being handled is not user-profile based. + +This prevents an ordinary game directory named `drive_c` from being treated as +a Wine prefix. Provider-specific discovery can be more permissive while looking +for candidates, but the semantic layer should only receive validated prefixes. + +If a configured `custom_games[].winePrefix` entry fails validation, Ludusavi +should skip that source, continue with other valid sources, and report the +invalid prefix in logs and scan/preview output. A single bad custom prefix should +not block the whole game's backup unless it was the only source needed to +materialize a selected restore target. + +### Wine user detection + +The Wine user name is a materialization detail. It must not appear in the +semantic key for current-user saves. + +When scanning, the user can be inferred from the matched path segment under +`drive_c/users/`. When restoring, Ludusavi should pick the Wine user +for the selected prefix with these rules: + +1. use a per-game preferred Wine user if one is configured; +2. use the user directory that already contains the target save path; +3. use the only non-system user under `drive_c/users`; +4. for Steam Proton, prefer `steamuser` when present; +5. if multiple non-system users remain, require explicit user selection. + +System user directories include `Public`, `Default`, `Default User`, +`All Users`, and other non-profile entries. Matching should be +case-insensitive. + +### Symlinks and lexical paths + +Semantic detection should be based on the Windows-view path through the prefix, +not on the final `realpath`. + +Wine often represents folders such as Documents with symlinks. If Ludusavi +resolves the symlink first, a file reached through: + +```text +/drive_c/users/steamuser/Documents/Game/save.dat +``` + +may appear to live under the Linux home directory instead, which loses the Wine +context. The scanner should preserve the lexical path by which the file was +matched. Content operations may use an interpreted or canonical path, but +semantic derivation should use the prefix-relative alias path. + +If a file is discovered through a symlink target outside the prefix, Ludusavi may +still map it semantically only when the scan candidate originated from a +validated prefix or a validated `dosdevices` mapping. It should not infer Wine +semantics from an arbitrary external path. + +### DOS devices and non-C drives + +Non-C drives are common in real Wine setups. A prefix may expose `D:` through +`dosdevices/d:` as a symlink to a path such as `/run/media/deck/HDD`. + +`WinDrive(char)` should represent these paths without pretending they are under +`drive_c`. When backing up from Wine: + +- paths under `/drive_` map to `WinDrive(letter)`; +- paths under a target of `/dosdevices/:` may map to + `WinDrive(letter)` only when that dosdevice mapping belongs to the selected + prefix context; +- paths under store roots such as Steam userdata should prefer the + store-specific semantic base over a raw drive-root key. + +When restoring a `WinDrive(char)` path: + +- on Windows, write to that drive only if it exists or the user has configured a + target for that drive; +- in Wine, write through the prefix's matching `dosdevices/:` mapping or + `drive_` directory; +- if the drive is missing, fail with an actionable message asking the user to + choose a drive/root mapping. + +The implementation should not silently remap missing `D:` data to `C:`. + +### Preferred prefix + +When restoring a Windows semantic path on Linux, Ludusavi should choose a prefix +in this order: + +1. the CLI `--wine-prefix` value for the current command; +2. a per-game preferred prefix saved in Ludusavi's configuration; +3. a game-specific prefix discovered from the launcher; +4. a custom game `winePrefix`; +5. a configured game-specific Wine root; +6. an explicit user selection. + +If no single prefix can be identified, the restore should fail with a clear +ambiguity message. It should not guess. + +`--wine-prefix` is a per-invocation override. If it is used for a command that +touches multiple games, it may apply only to games whose configured preferred +prefix is absent or consistent with the CLI prefix. If a game already has a +different preferred prefix, Ludusavi should fail for that game with an +actionable conflict message instead of silently overriding the saved preference. + +The explicit selection should be stored as a per-game preferred prefix so that +the same ambiguity does not recur on every restore. GUI, CLI, and wrapper flows +should use the same stored preference. + +The configuration should store preferred prefix data separately from backup +history. At minimum, it needs: + +- game identity; +- preferred prefix path; +- optional preferred Wine user; +- optional drive mappings for non-C drives. + +The game identity should use the same stable title/alias rules as existing +custom game settings. If a game is renamed through aliases, the preference must +follow the displayed game identity consistently. + +## Registry model + +Wine registry support is related but should not block file-based portability. + +Current documentation says Wine prefix roots do not back up registry-based +saves from the prefix. Issue #194 also notes that proper registry transfer +would require parsing Wine `*.reg` files and translating them to or from the +native Windows registry. + +The first implementation should explicitly focus on file-based saves. Registry +translation can be a later phase: + +- Wine backup to Windows: parse relevant Wine `*.reg` entries and restore them + to the Windows registry. +- Windows backup to Wine: export relevant Windows registry entries and merge + them into the chosen prefix's `*.reg` files. + +Until then, registry-based cross-platform transfers should be reported as +unsupported instead of being silently approximated. + +Even before registry translation is implemented, the backup metadata should +reserve a registry format field so that registry support can be added without +redefining the file format again. For example: + +```yaml +registryFormat: unsupported +``` + +Future values could distinguish native Windows registry exports from parsed +Wine registry files. The first semantic file-path implementation can keep this +field at `unsupported` for cross-platform registry transfer. + +## Backup format + +Introduce a versioned semantic path format in `mapping.yaml`, scoped to each +full backup chain. + +Legacy backups without the new marker keep their existing behavior. New semantic +backups can store semantic file keys: + +```yaml +backups: + - name: "." + os: linux + pathFormat: semantic-v1 + registryFormat: unsupported + files: + "/Remedy/Alan Wake/save.dat": + hash: ... + size: ... +``` + +The exact YAML field can be adjusted to fit existing serialization patterns, +but the invariant should be strict: + +- semantic keys are not passed directly to `StrictPath`; +- they must be parsed as semantic paths first; +- they are materialized to physical paths only when scanning, restoring, or + showing a current-machine target. + +For the simple backup format, storage paths should be derived from semantic +paths using safe folder names. For example: + +```text +__ludusavi_semantic__/winDocuments/Remedy/Alan Wake/save.dat +__ludusavi_semantic__/winAppData/Game/save.dat +__ludusavi_semantic__/winProgramData/Game/save.dat +``` + +The top-level `__ludusavi_semantic__` component is reserved inside semantic +backups. Literal game data with that name is still stored below a semantic base, +for example `__ludusavi_semantic__/winDrive-C/__ludusavi_semantic__/file.dat`, +so it cannot collide with the format marker. Validation should reject storage +paths that escape this namespace or mix semantic storage paths with legacy +`drive-*` paths in the same backup chain. + +Storage paths must remain compatible with Windows long-path handling. The +semantic namespace adds path components, so backup and restore code should +verify the final storage/extracted path with the same long-path support used +elsewhere in Ludusavi. If a path cannot be represented safely on the current +filesystem, Ludusavi should report a clear error instead of truncating or +rewriting it. + +For zip backups, the same storage path scheme can be used inside the archive. + +### Differential chains and format switching + +Each full backup starts a chain, and all differential children in that chain +must use the same `pathFormat` and `registryFormat` as the full backup. + +When Ludusavi first switches a game from legacy absolute keys to +`semantic-v1`, it must create a new full backup. It should not create a +semantic differential child under a legacy full backup, because every key would +appear to be both removed and added. This avoids space waste and restore +ambiguity. + +Old legacy chains remain readable and restorable. New semantic chains should not +rewrite legacy history. + +The first switch to `semantic-v1` should surface a one-time notice in CLI and +GUI preview: a new full backup will be created, and the previous legacy chain +will remain restorable but frozen. This matters for retention settings because a +new full backup may change which older full chains are retained. + +## Implementation phases + +### Phase 1: Windows/Wine semantic file paths + +Add a semantic path type, for example: + +```rust +enum SavePath { + Physical(StrictPath), + Semantic(SemanticPath), +} +``` + +`SemanticPath` should represent the location category and relative tail: + +```rust +struct SemanticPath { + base: SemanticBase, + tail: String, +} +``` + +Initial `SemanticBase` variants: + +- `WinHome` +- `WinDocuments` +- `WinAppData` +- `WinLocalAppData` +- `WinLocalAppDataLow` +- `WinSavedGames` +- `WinPublic` +- `WinProgramData` +- `WinDir` +- `WinDrive(char)` for paths that are genuinely drive-rooted and not under a + known special folder +- `SteamUserdata` +- manifest-derived store/root bases that are proven equivalent by the manifest + origin metadata + +Add conversion functions: + +- physical Windows path to semantic path; +- physical Wine path under a known prefix to semantic path; +- manifest-origin scan candidate to semantic path; +- semantic path to physical Windows path for the current user; +- semantic path to physical Wine path for a selected prefix; +- semantic path to safe backup storage path. + +This should not be implemented as broad recovery logic. Each semantic base must +have a direct, explicit mapping. + +Windows materialization should use current-platform location APIs such as the +existing `CommonPath`/KnownFolder logic, not hardcoded +`C:/Users//Documents` paths. This preserves relocated Documents, +AppData, Saved Games, and similar folders. + +For native Linux/macOS semantic keys that are introduced later, materialization +should likewise use the current XDG or platform-specific directory resolution +instead of reusing the source machine's expanded value. + +Wine materialization cannot ask Windows for KnownFolders. It should write +through the selected prefix's Windows-view path, such as +`drive_c/users//Documents`. If that directory is a symlink, the +filesystem will place the file at the symlink target while Ludusavi still keeps +the semantic identity as ``. + +### Phase 2: Preserve physical and semantic paths during scanning + +Replace the current assumption that one `StrictPath` is enough to represent a +scanned file. + +For each found file, keep: + +- the physical path used to read file content; +- the semantic path used for backup identity, when one is available; +- the scan origin that explains why the semantic key is valid; +- the redirected path, if user redirects are still applied. + +Redirects need a clear rule: + +- legacy physical paths continue to use current redirect behavior; +- when a semantic key is available during backup, physical redirects do not + change that semantic key; +- backup redirects may still be shown in preview for legacy paths, but they + should not be needed to normalize usernames, Wine prefixes, or Steam roots + once a semantic key exists; +- when restoring a semantic key, materialize it to the current machine's + physical target first, then apply restore redirects to that physical target; +- restore preview should show all three values when relevant: semantic source, + materialized physical target, and redirected final target; +- advanced semantic redirects are out of scope for the first implementation and + require a separate design. + +For Wine files, the backup key should be semantic. The copy source should remain +physical. + +If the same semantic key is produced more than once during a single game scan, +handle it deterministically: + +- if the entries are the same physical file reached through multiple aliases, + collapse them into one entry; +- if the entries are distinct physical files, report a scan conflict for that + semantic key instead of choosing one silently; +- while a semantic conflict exists, do not mark the prior backup entry for that + semantic key as removed; +- if the conflict comes from overlapping roots, let the user disable one source + or configure a more specific root. + +This prevents a generic Heroic prefix root and a `` Wine root from +silently racing to define the same backup key. + +Backup preview should display both the physical source and the semantic key, for +example: + +```text +/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat + Stored as: /Game/save.dat +``` + +This makes prefix misclassification visible before the user writes a backup. + +### Selection, ignores, and toggles + +Filtering has two layers: + +- physical path ignores apply before semantic derivation, because they describe + files on the current machine; +- game file selection, toggled backup paths, and restore selection should use + the semantic key when one exists, because they describe the save identity. + +Legacy toggles keyed by physical paths remain valid for legacy backups. When a +file first appears with a semantic key, preview should show the new semantic +identity and the old physical identity so users can confirm the selection. The +initial implementation should not silently migrate user toggles without showing +the changed key. + +For restore previews, the selectable row should be keyed by the semantic source, +while the rendered target shows the materialized and redirected physical path. + +### Phase 3: Restore materialization + +When reading a semantic backup: + +- on Windows, materialize Windows semantic paths to the current Windows user; +- on Linux/macOS with a selected Wine prefix, materialize Windows semantic paths + into that prefix; +- without a selected prefix, report an actionable ambiguity; +- for legacy absolute backups, keep current restore behavior. + +The UI and CLI preview should show both: + +- the semantic source, such as `/Game/save.dat`; +- the current physical target, such as + `/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat`. + +### Phase 4: Warnings and foreign-platform handling + +Use semantic identity to expose explicit comparison signals for #490: + +- `sameSemanticKey`: the current scan and existing backup describe the same + save location, even if their physical paths differ; +- `sameNamespaceMissing`: an existing backup has entries in a semantic namespace + that the current scan can understand, but no current entry matched them; +- `foreignNamespace`: an existing backup has entries in a namespace that the + current platform cannot currently materialize; +- `ambiguousMaterialization`: Ludusavi understands the semantic key but cannot + pick a single physical restore target. + +Behavior should follow from those signals: + +- `sameSemanticKey` entries are counterparts and can be compared normally; +- `sameNamespaceMissing` entries can be candidates for #490's optional + de-selection or warning behavior; +- `foreignNamespace` entries should not be removed just because the current scan + cannot see them; +- `ambiguousMaterialization` should block restore for that key until the user + chooses a target. + +### Phase 4.5: Preview and dry-run analysis + +Before migration tooling exists, preview should expose what semantic mode would +do without writing anything: + +- which legacy physical keys would become semantic keys; +- which games would start a new full backup chain; +- which files would land in foreign or ambiguous namespaces; +- which configured prefixes failed validation; +- which duplicate semantic keys would conflict. + +This can start as CLI/GUI preview output rather than a separate migration +command. It gives users with large existing backup histories a way to understand +the change before enabling or relying on semantic backups. + +### Phase 5: Regex redirects as an advanced feature + +After semantic Windows/Wine paths are in place, add regex redirect support for +remaining advanced cases from #310. + +This should be opt-in and explicit, for example: + +```yaml +redirects: + - kind: bidirectional + mode: regex + source: '^(.*/drive_c/users/)actual-user(/.*)$' + target: '${1}standard-user${2}' +``` + +Regex redirects should not be required for normal Windows/Wine portability. + +### Phase 6: Native cross-OS relationships + +Native Windows, Linux, and macOS paths are harder than Windows/Wine paths +because semantic equivalence is not guaranteed by path shape alone. For example, +a native Windows path and a native Linux path may both contain the same file +name, but the manifest does not prove that the files are interchangeable. + +Future work can add explicit manifest relationships, such as grouped path +variants: + +```yaml +paths: + - kind: folder + variants: + windows: "/Dustforce" + linux: "/Dustforce" + mac: "/Library/Application Support/Hitbox Team/Dustforce" +``` + +Until that relationship data exists, the restore can consult the manifest at +restore time: when restoring a semantic key like `</Saved +Games/Hades/`, look up the game in the manifest, find the entry tagged with the +target OS, and materialize that path instead. This uses the manifest's existing +per-platform `when` constraints without any schema changes. + +### Phase 7: Non-C drive fallback configuration + +For `<>` semantic keys, add a `restore.driveMappings` config field: + +```yaml +restore: + driveMappings: + d: /mnt/games + e: /run/media/deck/SD +``` + +When materializing `<>/path/to/save` and no matching `dosdevices` +symlink exists, fall back to the configured mapping. If neither exists, raise +an actionable error suggesting the user configure a mapping. + +### Out of scope for this work + +The following are deliberately excluded so that this effort stays focused on the +core goal: portable backups between Windows and Wine/Proton without per-game +redirects. They can be pursued separately if there is demand: + +- **Linux-native XDG bases** (``, ``, etc.): equating a + Windows known folder with a Linux XDG directory is the same unproven + native-cross-OS equivalence that Phase 6 defers. Wine/Proton games already map + to Windows bases through the prefix, so XDG bases are not needed here. +- **Steam userdata semantic identity / account mapping**: Steam Cloud + `userdata//...` is a native Windows↔Linux concern, not a Wine/Proton one. + It keeps its existing absolute-path (legacy) behavior, which restores + correctly on the same account/machine. +- **Cross-platform registry translation**: backups continue to record registry + data in the existing native format (`registryFormat` stays at its default). + Translating between the Windows registry and Wine `*.reg` files is a separate + effort. + +## Problems solved + +This plan solves or significantly improves: + +- Windows to Wine restore without per-game redirects for user-profile saves; +- Wine to Windows restore without leaking `/home/deck/.../drive_c/...` into the + backup identity; +- SteamOS `deck` and Wine `steamuser` usernames becoming irrelevant to backup + compatibility; +- Windows usernames varying between machines; +- Heroic/Lutris/Proton prefixes living at different paths on different Linux + machines; +- the common case where a Windows game stores saves under Documents, AppData, + LocalAppData, LocalLow, Public, ProgramData, or the Windows directory; +- Wine prefixes and macOS bottles living at different host paths; +- non-C Wine drives when the matching `dosdevices` mapping exists, or via + configurable `restore.driveMappings`; +- clearer detection of same semantic saves across platforms; +- safer warnings for foreign-platform backup entries; +- better cloud-sync and deduplication behavior, because the same semantic save + can use the same backup storage path across machines instead of embedding + each machine's username or prefix path. + +## Problems not yet solved + +- native Windows path to unrelated native Linux path equivalence (requires + manifest consultation at restore time — see Phase 6); +- file-level matching by filename heuristics; +- multiple users in one backup without explicit user identity; +- multiple installed copies of the same game where the user wants distinct save + streams; +- games whose Windows and Linux saves are structurally incompatible. + +## Testing strategy + +Add unit tests for semantic path conversion: + +- semantic key parser rejects direct OS paths, `.` components, and `..` + components; +- semantic key equality follows the base case policy; +- Windows current-user Documents to ``; +- Windows AppData/Roaming to ``; +- Windows AppData/Local to ``; +- relocated Windows KnownFolders materialize through the current location API; +- Wine `drive_c/users/steamuser/Documents` to ``; +- Wine `drive_c/users//AppData/Roaming` to ``; +- Wine XP-style aliases such as `Application Data`, `Local Settings`, and + `My Documents`; +- Wine paths matched through symlinked Documents without losing prefix context; +- ProgramData and Windows directory paths; +- `dosdevices/d:` mappings to `WinDrive('d')`; +- Steam userdata paths to ``; +- paths outside known locations using explicit drive-root semantics. + +Add scan tests: + +- Wine prefix file scan stores semantic mapping key; +- physical copy source remains the Wine path; +- Windows scan and Wine scan of the same logical save produce the same mapping + key; +- Windows Steam userdata and Linux Steam userdata produce the same semantic key; +- duplicate semantic keys from the same physical file are collapsed; +- duplicate semantic keys from distinct physical files are reported as conflicts; +- conflict entries do not cause prior semantic backup entries to be removed; +- backup preview shows both physical source and semantic key; +- redirects still work for legacy physical paths. + +Add restore tests: + +- semantic Windows backup restores to the current Windows user path; +- semantic Windows backup restores into a selected Wine prefix; +- semantic Windows backup restores through a relocated Windows KnownFolder; +- semantic `WinDrive('d')` restores through a matching Wine `dosdevices/d:`; +- missing drive mappings fail with an actionable message; +- semantic restore applies restore redirects after physical materialization; +- semantic keys, not physical paths, own game file selection when available; +- restore fails clearly when multiple prefixes are possible; +- legacy absolute backup continues to restore as before. + +Add backup format tests: + +- new semantic backups write `pathFormat: semantic-v1`; +- semantic backups reserve `registryFormat`; +- old backups without `pathFormat` remain readable; +- simple and zip formats use the same semantic storage path derivation; +- first semantic backup after a legacy chain is a full backup; +- differential backups compare semantic keys rather than physical Wine paths; +- semantic and legacy storage namespaces do not mix in one backup chain. + +Add property-based or table-driven round-trip tests: + +- `semantic(materialize(semantic, target), target) == semantic` for every + supported semantic base; +- materializing a semantic key twice for the same target is stable; +- scanning the same physical path twice produces the same semantic key; +- consecutive semantic backups with unchanged files produce no new differential + changes; +- changing a Wine prefix host path does not change the semantic key. + +Add performance checks: + +- semantic origin tracking and reverse mapping should not meaningfully regress + large scans; +- establish a benchmark corpus with many games, roots, prefixes, and files; +- use that benchmark to set a concrete threshold before implementation is + merged, for example a maximum percentage increase in scan time compared with + the same corpus without semantic path derivation. + +## Migration and compatibility + +Existing backups must remain valid. Absence of the semantic path marker means +legacy absolute-path behavior. + +New semantic backups should not rewrite existing legacy backups unless the user +creates a new backup. The initial implementation does not include a write +migration tool; preview/dry-run analysis is the required migration aid. + +The first semantic backup for a game after a legacy backup must be full. Later +differential backups may be semantic only if their parent full backup is also +semantic. + +During mixed history, Ludusavi may see both legacy physical keys and new +semantic keys. It should display them distinctly and avoid treating one as a +deletion of the other unless semantic equivalence is proven. + +Manifest updates may change future semantic derivation, but they must not +rewrite existing backup keys. If a new manifest version causes a current scan to +produce a different semantic key for the same physical file, Ludusavi should +show that as a visible semantic-key change rather than mutating older mappings. + +## Documentation updates + +Update `docs/help/backup-structure.md`: + +- explain semantic paths versus physical paths; +- clarify that usernames and Wine prefix paths are not part of new portable + Windows/Wine backup identity. + +Update `docs/help/backup-validation.md`: + +- validate `pathFormat` and `registryFormat` per backup chain; +- validate semantic storage namespace structure; +- report duplicate semantic keys and mixed legacy/semantic chain data. + +Update `docs/help/transfer-between-operating-systems.md`: + +- document Windows/Wine support level; +- clarify that general native cross-OS support remains limited by manifest + relationship data; +- mention the cloud-sync benefit of semantic storage paths when the same save is + backed up from multiple machines. + +Update `docs/help/roots.md`: + +- explain that Wine prefix roots are used as scan/restore targets; +- clarify that the prefix itself is not stored as the save identity in semantic + backups; +- clarify that Steam Proton compatdata prefixes do not need to be added as + separate Wine prefix roots when a Steam root is configured; the Steam root acts + as the prefix provider. + +Update `docs/help/redirects.md`: + +- distinguish redirects from semantic portability; +- note that redirects remain useful for custom layouts and unsupported cases. + +Update schema and machine-readable output docs: + +- `docs/schema/config.yaml` should document per-game preferred prefixes, + optional Wine users, and optional drive mappings; +- `docs/schema/api-output.yaml` and `docs/schema/general-output.yaml` should + expose semantic source keys separately from physical paths where preview or + restore output reports them; +- `docs/schema/api-input.yaml` should document any new restore selection fields + that accept semantic keys. + +Update localization resources: + +- add user-facing strings in `lang/*.ftl` for ambiguous prefix selection, + invalid configured prefix, missing drive mapping, semantic-key conflict, + foreign namespace, semantic mode preview, and switching to a new semantic full + backup chain. + +## Review checklist + +Before opening a PR, verify these invariants: + +- no new semantic path is accidentally interpreted as an OS path; +- a username change does not change the backup key for current-user Windows + saves; +- a Wine prefix location change does not change the backup key; +- relocated Windows KnownFolders materialize through the current platform's + location APIs; +- manifest-derived keys take precedence over platform reverse mapping; +- semantic keys have a single parser, serializer, and storage-path encoder; +- `` remains part of Steam semantic keys; +- `` is used only after more specific KnownFolder bases fail; +- overlapping manifest entries resolve by longest matched prefix, then + declaration order; +- semantic key equality follows the semantic base case policy; +- physical redirects do not alter backup semantic keys; +- restore redirects run after semantic keys are materialized to physical paths; +- CLI `--wine-prefix` conflicts with per-game preferences fail visibly; +- Wine symlinks do not erase prefix context during semantic derivation; +- Steam userdata uses a store semantic namespace rather than a host root path; +- missing non-C drives fail visibly instead of being remapped to C; +- restoring to Wine requires one clear prefix; +- duplicate semantic keys from distinct physical files are reported as + conflicts; +- semantic conflicts do not remove the prior backup entry for that key; +- switching from legacy to semantic keys starts a new full backup chain; +- first semantic backup preview explains the new full backup chain; +- physical ignores run before semantic derivation, while selection/toggles use + semantic keys when present; +- schema docs distinguish semantic keys from physical paths in API output; +- manifest updates do not retroactively rewrite existing backup keys; +- ambiguous cases fail loudly instead of guessing; +- old backups are still readable and restorable; +- unsupported registry transfers are reported honestly. + +## Implementation guide for coding agents + +This section provides explicit, step-by-step instructions, acceptance criteria, +and progress tracking for each implementation unit. A coding agent should +complete each task in order, mark its status, and not proceed to the next task +until all acceptance criteria for the current task pass. + +### Progress status markers + +Use these markers at the start of each task heading: + +- `[ ]` — not started +- `[~]` — in progress +- `[x]` — complete, all acceptance criteria pass +- `[!]` — blocked, with explanation + +--- + +### Task 1: [x] Define `SemanticBase` enum and `SemanticPath` struct + +**File(s):** Create `src/semantic.rs` (or a module under `src/path/`). + +**What to implement:** + +```rust +/// Represents a portable semantic location category. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum SemanticBase { + WinHome, + WinDocuments, + WinAppData, + WinLocalAppData, + WinLocalAppDataLow, + WinSavedGames, + WinPublic, + WinProgramData, + WinDir, + WinDrive(char), + SteamUserdata, + // Future: XdgData, XdgConfig, Home, etc. +} + +/// A portable save-file identity. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SemanticPath { + pub base: SemanticBase, + pub tail: String, // forward-slash separated, no leading slash +} +``` + +Add: +- `SemanticPath::parse(s: &str) -> Result` — parses + `/tail/path` format. +- `SemanticPath::serialize(&self) -> String` — canonical string form. +- `SemanticPath::storage_path(&self) -> String` — returns + `__ludusavi_semantic__//tail`. +- `SemanticBase::case_sensitive(&self) -> bool` — returns `false` for all Win* + bases, `true` for future Linux bases. +- `SemanticPath::eq_semantic(&self, other: &Self) -> bool` — equality respecting + case policy. + +**Acceptance criteria:** + +1. `SemanticPath::parse("/Game/save.dat")` succeeds and + round-trips through `serialize()`. +2. `parse` rejects strings without a recognized `` prefix. +3. `parse` rejects tails containing `.` or `..` components. +4. `parse` rejects empty tails. +5. `storage_path()` never contains `\`, always uses `/`. +6. `eq_semantic` is case-insensitive for `WinDocuments` base: + `/Game/Save.dat` == `/game/save.dat`. +7. `eq_semantic` preserves case distinction for future case-sensitive bases. +8. All variants of `SemanticBase` serialize/deserialize through serde without + data loss. +9. `WinDrive('d')` serializes as `` and parses back. +10. Unit tests cover all of the above. + +--- + +### Task 2: [x] Implement physical-to-semantic conversion (Windows) + +**File(s):** `src/semantic.rs` or `src/semantic/convert.rs`. + +**What to implement:** + +```rust +/// Convert a physical Windows path to a semantic path for the current user. +/// Returns None if the path cannot be semantically classified. +pub fn windows_physical_to_semantic( + physical: &StrictPath, + known_folders: &KnownFolders, // existing CommonPath/KnownFolder data +) -> Option +``` + +**Algorithm (must follow this exact priority order):** + +1. Check `known_folders.saved_games` → `WinSavedGames` +2. Check `known_folders.documents` → `WinDocuments` +3. Check `known_folders.local_app_data` + `/Low` suffix → `WinLocalAppDataLow` +4. Check `known_folders.local_app_data` → `WinLocalAppData` +5. Check `known_folders.app_data` (Roaming) → `WinAppData` +6. Check `known_folders.public` → `WinPublic` +7. Check `known_folders.program_data` → `WinProgramData` +8. Check `known_folders.windows` → `WinDir` +9. Check `known_folders.user_profile` → `WinHome` +10. Extract drive letter, use `WinDrive(letter)` with path after `X:/` + +Each check: if `physical` starts with the folder path (case-insensitive), strip +that prefix to get the tail. + +**Acceptance criteria:** + +1. `C:/Users/Alice/Documents/Game/save.dat` → `/Game/save.dat` + when Documents = `C:/Users/Alice/Documents`. +2. `C:/Users/Alice/AppData/Local/Game/save.dat` → `/Game/save.dat`. +3. `C:/Users/Alice/AppData/Local/Low/Game/save.dat` → `/Game/save.dat` + (LocalLow checked before Local). +4. `C:/Users/Alice/Saved Games/Game/save.dat` → `/Game/save.dat`. +5. Relocated Documents (e.g., `D:/MyDocs`) still works when `known_folders` + reports the relocated path. +6. `D:/Games/save.dat` → `/Games/save.dat` when no KnownFolder + matches. +7. `C:/Users/Alice/MyGames/save.dat` → `/MyGames/save.dat` (not + ``). +8. Path comparison is case-insensitive on Windows. +9. Returns `None` for UNC paths or paths that cannot be parsed. +10. Unit tests cover all of the above, including edge cases with trailing + slashes and mixed separators. + +--- + +### Task 3: [x] Implement physical-to-semantic conversion (Wine prefix) + +**File(s):** `src/semantic.rs` or `src/semantic/convert.rs`. + +**What to implement:** + +```rust +/// Convert a physical path inside a validated Wine prefix to a semantic path. +/// `prefix_path` is the validated prefix root (parent of drive_c). +/// `wine_user` is the detected Wine username for this prefix. +pub fn wine_physical_to_semantic( + physical: &StrictPath, + prefix_path: &StrictPath, + wine_user: &str, +) -> Option +``` + +**Algorithm:** + +1. Strip `prefix_path` from `physical` to get prefix-relative path. +2. Determine if path is under `drive_c/users//` (case-insensitive). +3. If yes, extract the sub-path after the user directory and apply the same + priority order as Task 2 but using Wine directory names: + - `Saved Games` → `WinSavedGames` + - `Documents` or `My Documents` → `WinDocuments` + - `AppData/Local/Low` or `Local Settings/Application Data/Low` → `WinLocalAppDataLow` + - `AppData/Local` or `Local Settings/Application Data` → `WinLocalAppData` + - `AppData/Roaming` or `Application Data` → `WinAppData` + - After all specific folders: remainder under user dir → `WinHome` +4. If path is under `drive_c/users/Public` → `WinPublic` +5. If path is under `drive_c/ProgramData` → `WinProgramData` +6. If path is under `drive_c/windows` → `WinDir` +7. If path is under `drive_` (not `drive_c`) → `WinDrive(letter)` +8. If path is under `drive_c` but not matched above → `WinDrive('c')` with + path after `drive_c/` +9. Otherwise return `None`. + +**Critical rules:** +- Use the **lexical** prefix-relative path, NOT `realpath`. Do not resolve + symlinks before classification. +- All directory name comparisons are case-insensitive. +- The `wine_user` parameter comes from the caller (Task 5 handles detection). + +**Acceptance criteria:** + +1. `.../drive_c/users/steamuser/Documents/Game/save.dat` → + `/Game/save.dat`. +2. `.../drive_c/users/deck/AppData/Roaming/Game/save.dat` → + `/Game/save.dat`. +3. `.../drive_c/users/steamuser/Application Data/Game/save.dat` → + `/Game/save.dat` (XP alias). +4. `.../drive_c/users/steamuser/Local Settings/Application Data/Game/save.dat` + → `/Game/save.dat` (XP alias). +5. `.../drive_c/users/steamuser/My Documents/Game/save.dat` → + `/Game/save.dat` (XP alias). +6. `.../drive_c/ProgramData/Game/save.dat` → `/Game/save.dat`. +7. `.../drive_d/Games/save.dat` → `/Games/save.dat`. +8. Symlinked Documents directory does NOT cause the path to escape prefix + context (lexical path used). +9. Case variations like `appdata/roaming` still match. +10. Unit tests cover all of the above. + +--- + +### Task 4: [x] Implement prefix validation + +**File(s):** `src/semantic/prefix.rs` or similar. + +**What to implement:** + +```rust +pub struct ValidatedPrefix { + pub path: StrictPath, + pub wine_user: String, + pub has_drive_c: bool, + pub drive_mappings: HashMap, // from dosdevices +} + +/// Validate a candidate prefix path. +/// Returns None if validation fails. +pub fn validate_prefix(candidate: &StrictPath) -> Option +``` + +**Validation rules:** +1. `candidate/drive_c` must exist as a directory. +2. At least one of `candidate/system.reg`, `candidate/user.reg`, or + `candidate/dosdevices` must exist. +3. `candidate/drive_c/users` must exist as a directory. +4. Detect wine user: list entries in `drive_c/users`, exclude (case-insensitive) + `Public`, `Default`, `Default User`, `All Users`. If exactly one remains, + that is the wine user. If multiple remain, return the list for caller to + resolve (or pick `steamuser` if present for Proton). +5. Scan `dosdevices/` for symlinks named `:` to build drive mappings. + +**Acceptance criteria:** + +1. A directory with `drive_c/` + `system.reg` passes. +2. A directory with `drive_c/` + `dosdevices/` passes. +3. A directory with only `drive_c/` (no reg files, no dosdevices) fails. +4. A directory without `drive_c/` fails. +5. Wine user detection excludes `Public`, `Default`, `Default User`, `All Users` + (case-insensitive). +6. Single remaining user is returned as `wine_user`. +7. `dosdevices/d:` symlink pointing to `/mnt/data` produces + `drive_mappings['d'] = /mnt/data`. +8. Invalid prefix logs a warning and returns `None`. +9. Unit tests with temp directories cover all cases. + +--- + +### Task 5: [x] Implement Wine user detection for restore + +**File(s):** Same module as Task 4. + +**What to implement:** + +```rust +/// Choose the Wine user for restore into a validated prefix. +pub fn choose_wine_user_for_restore( + prefix: &ValidatedPrefix, + game_config: Option<&GamePrefixConfig>, // per-game preferred user + target_path_hint: Option<&str>, // existing path to check + is_proton: bool, +) -> Result +``` + +**Priority order:** +1. `game_config.preferred_wine_user` if set → return it. +2. If `target_path_hint` is provided and a user directory contains that path → + return that user. +3. If only one non-system user exists → return it. +4. If `is_proton` and `steamuser` exists → return `steamuser`. +5. Otherwise → return `Err(WineUserAmbiguity { candidates })`. + +**Acceptance criteria:** + +1. Configured preferred user is always returned first. +2. Single-user prefix always succeeds without config. +3. Proton prefix with `steamuser` + `deck` returns `steamuser`. +4. Multi-user non-Proton prefix without config returns error with candidate list. +5. Unit tests cover all priority levels. + +--- + +### Task 6: [x] Implement semantic-to-physical materialization + +**File(s):** `src/semantic/materialize.rs` or similar. + +**What to implement:** + +```rust +/// Materialize a semantic path to a physical path on the current platform. +pub fn materialize_semantic( + semantic: &SemanticPath, + target: &MaterializeTarget, +) -> Result + +pub enum MaterializeTarget { + CurrentWindows { known_folders: KnownFolders }, + WinePrefix { prefix: ValidatedPrefix, wine_user: String }, +} +``` + +**Rules:** +- `WinDocuments` + `CurrentWindows` → `known_folders.documents / tail` +- `WinDocuments` + `WinePrefix` → `prefix.path / drive_c / users / wine_user / Documents / tail` +- `WinDrive('d')` + `WinePrefix` → use `prefix.drive_mappings['d']` or + `prefix.path / drive_d / tail`; error if neither exists. +- `WinDrive('d')` + `CurrentWindows` → `D:/ tail`; error if drive doesn't exist. +- All Win* bases follow the same pattern. +- Verify final path length is within long-path limits or return error. + +**Acceptance criteria:** + +1. `/Game/save.dat` + Windows target → uses actual KnownFolder + Documents path. +2. `/Game/save.dat` + Wine target → `prefix/drive_c/users/steamuser/Documents/Game/save.dat`. +3. `/Games/save.dat` + Wine target with `dosdevices/d:` → + resolves through the mapping. +4. `/Games/save.dat` + Wine target without d: mapping → error. +5. `/Games/save.dat` + Windows target without D: drive → error. +6. Round-trip: `wine_physical_to_semantic` then `materialize_semantic` back to + Wine produces the original path (modulo case normalization). +7. Round-trip: `windows_physical_to_semantic` then `materialize_semantic` back + to Windows produces the original path. +8. Long path (>260 chars) on Windows without long-path support → error. +9. Unit tests cover all bases × both targets. + +--- + +### Task 7: [x] Implement manifest-origin tracking in scan + +**File(s):** Modify `src/scan.rs` and related scan infrastructure. + +**What to implement:** + +Add an `origin` field to scan results that records: +- which manifest entry matched; +- which placeholder was expanded; +- which root/store provided the match; +- the matched prefix length and remaining tail. + +This metadata is used by Task 8 to derive manifest-based semantic keys. + +**Acceptance criteria:** + +1. After scanning a game, each `ScannedFile` carries origin metadata when it + came from a manifest entry. +2. Origin includes the manifest placeholder string (e.g., `/Remedy/Alan Wake`). +3. Origin includes the root kind (Steam, Heroic, Lutris, Other, WinePrefix). +4. Origin includes the expanded prefix that was stripped to find the tail. +5. Files found through custom game entries or non-manifest sources have + `origin = None`. +6. Existing scan behavior and results are unchanged (no regressions in + `cargo test`). +7. Integration test: scan a game with a known manifest entry and verify origin + is populated. + +--- + +### Task 8: [x] Implement manifest-derived semantic key generation + +**File(s):** `src/semantic/derive.rs` or similar. + +**What to implement:** + +```rust +/// Derive a semantic key from manifest origin metadata. +/// Returns None if the origin does not support semantic derivation. +pub fn derive_from_manifest_origin( + origin: &ScanOrigin, + physical: &StrictPath, +) -> Option +``` + +**Rules:** +- If the manifest placeholder is a recognized semantic base (e.g., + ``, ``), use it directly with the file tail. +- If the manifest uses `/userdata//` and root is Steam, + produce `///tail`. +- If the manifest uses `` or `` with a non-portable root, return + `None` (fall through to reverse mapping). +- When multiple manifest entries match, choose longest matched prefix, then + declaration order. + +**Source precedence enforcement:** +- This function is called FIRST. Only if it returns `None` should the caller + invoke `windows_physical_to_semantic` or `wine_physical_to_semantic`. + +**Acceptance criteria:** + +1. Manifest entry `/Remedy/Alan Wake` + file tail `save.dat` → + `/Remedy/Alan Wake/save.dat`. +2. Steam userdata manifest entry → `/12345/67890/remote/save.dat`. +3. Generic `/saves` with non-Steam root → returns `None`. +4. Two overlapping entries: longer match wins. +5. Same-length entries: first in manifest wins. +6. `` is preserved in the key (different accounts = different keys). +7. Unit tests cover all cases. + +--- + +### Task 9: [x] Integrate semantic keys into backup planning + +**File(s):** Modify `src/scan.rs`, `src/backup.rs`, `src/layout.rs`. + +**What to implement:** + +- After scan, for each `ScannedFile`: + 1. Try `derive_from_manifest_origin` (Task 8). + 2. If None and file is in a validated Wine prefix, try + `wine_physical_to_semantic` (Task 3). + 3. If None and platform is Windows, try `windows_physical_to_semantic` + (Task 2). + 4. If None, keep legacy physical-path behavior. +- Store the semantic key (when available) as the `mapping_key` in backup + planning. +- Write `pathFormat: semantic-v1` in `mapping.yaml` for new backups that use + semantic keys. +- Write `registryFormat: unsupported` in `mapping.yaml`. +- Use `__ludusavi_semantic__/` storage path prefix for semantic entries. +- Detect format switch: if previous full backup is legacy and new scan produces + semantic keys, force a new full backup. + +**Acceptance criteria:** + +1. A Wine prefix scan produces semantic keys in `mapping.yaml`. +2. A Windows scan produces semantic keys in `mapping.yaml`. +3. `pathFormat: semantic-v1` appears in new backup metadata. +4. `registryFormat: unsupported` appears in new backup metadata. +5. Storage files land under `__ludusavi_semantic__/winDocuments/...` etc. +6. Legacy backups without `pathFormat` still load and restore correctly. +7. First semantic backup after legacy chain is a full backup (not differential). +8. Differential backup within a semantic chain compares semantic keys correctly. +9. No `drive-*` folders appear in a semantic backup chain. +10. `cargo test` passes with no regressions. + +--- + +### Task 10: [x] Integrate semantic keys into restore planning + +**File(s):** Modify `src/restore.rs` and related. + +**What to implement:** + +- When reading a backup with `pathFormat: semantic-v1`: + 1. Parse each file key as `SemanticPath`. + 2. Determine `MaterializeTarget` (Windows or Wine prefix). + 3. Call `materialize_semantic` to get physical restore path. + 4. Apply restore redirects to the materialized physical path. + 5. Use the final path for file writing. +- When no prefix is available for Wine semantic keys on Linux, return + actionable error. +- Preferred prefix resolution uses the priority from the plan. + +**Acceptance criteria:** + +1. Semantic backup created on Linux/Wine restores correctly on Windows. +2. Semantic backup created on Windows restores correctly into a Wine prefix. +3. Restore preview shows semantic source + physical target. +4. Missing prefix → clear error message, not silent failure. +5. Ambiguous prefix → clear error with candidate list. +6. Restore redirects apply AFTER materialization. +7. Legacy backups restore unchanged. +8. `cargo test` passes. + +--- + +### Task 11: [x] Implement duplicate semantic key conflict detection + +**File(s):** Modify scan/backup planning. + +**What to implement:** + +- During scan, if two distinct physical files produce the same semantic key + (via `eq_semantic`): + - Do NOT choose one silently. + - Mark both as conflicted. + - In preview, show the conflict with both physical sources. + - Do NOT remove the prior backup entry for that key. + - Block backup for that specific key until user resolves (disable one root, + or configure more specific root). + +**Acceptance criteria:** + +1. Two files from different roots with same semantic key → conflict reported. +2. Same file via two aliases (symlink) → collapsed, no conflict. +3. Conflict does not delete existing backup entry. +4. Preview clearly shows which files conflict and suggests resolution. +5. Non-conflicting files in the same game still back up normally. +6. Unit test with mock scan data. + +--- + +### Task 12: [x] Implement Phase 4 warning signals + +**File(s):** New module or extend backup comparison logic. + +**What to implement:** + +Four signal types when comparing current scan to existing backup: +- `sameSemanticKey` — same key exists in both. +- `sameNamespaceMissing` — backup has keys in a namespace the current platform + understands, but current scan has no match. +- `foreignNamespace` — backup has keys in a namespace the current platform + cannot materialize. +- `ambiguousMaterialization` — key is understood but multiple physical targets + exist. + +**Acceptance criteria:** + +1. Windows scan sees Wine-created `` backup → `sameSemanticKey`. +2. Windows scan has no `` match but backup does → `sameNamespaceMissing`. +3. Linux scan without prefix sees `` backup → `foreignNamespace` + (no prefix configured). +4. Linux scan with two valid prefixes → `ambiguousMaterialization`. +5. `foreignNamespace` entries are NOT removed from backup. +6. `ambiguousMaterialization` blocks restore for that key. +7. Unit tests for each signal. + +--- + +### Task 13: [x] Implement preview/dry-run analysis (Phase 4.5) + +**File(s):** Extend CLI `--preview` output. + +**What to implement:** + +When running backup preview, additionally show: +- Which legacy keys would become semantic keys (and what they'd be). +- Which games would start a new full backup chain. +- Which configured prefixes failed validation (and why). +- Which semantic keys conflict. +- One-time notice about format switch. + +**Acceptance criteria:** + +1. Preview output includes "would switch to semantic-v1" notice per game. +2. Preview shows old physical key → new semantic key mapping. +3. Invalid prefix paths are reported with reason. +4. Conflicts are shown with both physical sources. +5. No actual backup data is written during preview. +6. Integration test with a mixed legacy/new scenario. + +--- + +### Task 14: [x] Update documentation and localization + +**File(s):** `docs/help/*.md`, `docs/schema/*.yaml`, `lang/*.ftl`. + +**What to implement:** + +Per the "Documentation updates" section of this plan: +- Update all listed help documents. +- Add schema fields for preferred prefix config and semantic key output. +- Add `lang/en-US.ftl` keys for all new user-facing messages: + - `semantic-prefix-ambiguous` + - `semantic-prefix-invalid` + - `semantic-drive-missing` + - `semantic-key-conflict` + - `semantic-foreign-namespace` + - `semantic-format-switch-notice` + - `semantic-preview-would-become` + +**Acceptance criteria:** + +1. All listed docs are updated with accurate information. +2. Schema files validate against the new config/output structure. +3. `en-US.ftl` has all new keys. +4. Other `lang/*.ftl` files have the same keys with English fallback (or + marked for translation). +5. No broken links in documentation. + +--- + +### Task 15: [x] Property-based and round-trip tests + +**File(s):** `tests/semantic_properties.rs` or similar. + +**What to implement:** + +Using `proptest` or `quickcheck`: +1. For every `SemanticBase` variant, generate random tails and verify: + - `parse(serialize(path)) == path` + - `materialize` then re-derive produces the same semantic key +2. Consecutive backups with unchanged files produce zero differential entries. +3. Changing Wine prefix host path does not change semantic key. +4. Changing Windows username does not change semantic key. +5. `storage_path` never contains OS-specific separators. + +**Acceptance criteria:** + +1. Property tests run in CI (`cargo test`). +2. At least 1000 iterations per property. +3. All properties pass. +4. Shrunk failure cases are human-readable. + +--- + +### Task 16: [x] Performance benchmark + +**File(s):** `benches/semantic_scan.rs` or similar. + +**What to implement:** + +- Create a benchmark corpus with 500+ games, multiple roots, multiple prefixes. +- Measure scan time with and without semantic derivation. +- Set threshold: semantic derivation must not increase scan time by more than + 15% compared to legacy-only scan on the same corpus. + +**Acceptance criteria:** + +1. Benchmark exists and runs via `cargo bench`. +2. Results are documented in PR. +3. Threshold is met on CI hardware. + +--- + +## Cross-task invariants (verify after ALL tasks complete) + +These are global properties that must hold across the entire implementation. +Run these checks as a final validation pass: + +1. `cargo test` passes with zero failures. +2. `cargo clippy` produces no warnings. +3. No `unsafe` code introduced. +4. No panics (`unwrap`/`expect`) on user-controlled input paths. +5. All new public APIs have doc comments. +6. Semantic key strings never appear in filesystem API calls without going + through `materialize_semantic` first. +7. The word `semantic` does not appear in any user-facing GUI text (use + "portable" or "cross-platform" instead). +8. Backup/restore of 10+ real games works end-to-end in a manual smoke test + covering: Windows native, Wine/Proton on Linux, legacy backup compatibility. +9. `mapping.yaml` files written by the new code can be read by the previous + release (graceful unknown-field handling). +10. Previous-release `mapping.yaml` files are read correctly by the new code. diff --git a/docs/help/backup-structure.md b/docs/help/backup-structure.md index 64227949..1fdde299 100644 --- a/docs/help/backup-structure.md +++ b/docs/help/backup-structure.md @@ -45,3 +45,26 @@ For example, consider these potential challenges: Using absolute paths is the safest way to ensure that backups are always restored to the same place on the same system. The trade-off is that you must define redirects to help Ludusavi understand your unique setup. + +## Semantic paths (cross-platform backups) + +For Windows and Wine/Proton saves, Ludusavi supports a portable path format +called **semantic paths**. Instead of storing the literal file path +(e.g., `/home/deck/Prefixes/Game/drive_c/users/steamuser/Documents/Game/save.dat`), +the backup stores a portable identity like `/Game/save.dat`. + +This format: + +- Ignores Windows usernames (`Alice`, `Bob`) and Wine usernames (`steamuser`, `deck`) +- Ignores Wine prefix locations (`/home/deck/Prefixes/Game`) +- Preserves the semantic meaning of the save location (Documents, AppData, etc.) +- Enables cloud-sync deduplication across machines + +When restoring a semantic backup: + +- On Windows: files go to the current user's actual Documents/AppData/etc. folders +- In Wine: files go to the selected prefix's `drive_c/users//Documents` etc. + +Semantic backups use `pathFormat: semantic-v1` in `mapping.yaml` +and store files under a `__ludusavi_semantic__/` namespace +within the backup folder. diff --git a/docs/help/backup-validation.md b/docs/help/backup-validation.md index d2bc7241..80e86e90 100644 --- a/docs/help/backup-validation.md +++ b/docs/help/backup-validation.md @@ -7,6 +7,13 @@ Specifically, this checks the following: * Is mapping.yaml malformed? * Is any file declared in mapping.yaml, but missing from the actual backup? +* For portable Windows/Wine backups, is `pathFormat: semantic-v1` readable, + and do semantic entries point to files under the `__ludusavi_semantic__/` + storage namespace? + +Ludusavi also preserves `registryFormat` metadata on portable backup chains. +Registry transfer between Windows and Wine is not supported yet, so portable +file backups mark registry data as unsupported instead of trying to translate it. If it finds problems, then it will prompt you to create new full backups for the games in question. At this time, it will not remove the invalid backups, outside of your normal retention settings. diff --git a/docs/help/configuration-file.md b/docs/help/configuration-file.md index 115e809c..cea5f35c 100644 --- a/docs/help/configuration-file.md +++ b/docs/help/configuration-file.md @@ -20,4 +20,13 @@ backup: path: ~/ludusavi-backup restore: path: ~/ludusavi-backup + preferredWinePrefixes: + "Example Game": + path: ~/Games/Prefixes/Example Game + wineUser: steamuser ``` + +`restore.preferredWinePrefixes` is optional. Use it when a portable Windows +backup should always restore into a specific Wine/Proton prefix for a game. +The CLI `--wine-prefix` option is still available as a per-command override, +but Ludusavi rejects it if it conflicts with a saved preference for the game. diff --git a/docs/help/redirects.md b/docs/help/redirects.md index 00508275..e6aa9342 100644 --- a/docs/help/redirects.md +++ b/docs/help/redirects.md @@ -47,3 +47,9 @@ that may lead to an undesired result: `D:/Games/Title/save.dat` won't trigger the first redirect, so it would restore to `C:/Games/Title/save.dat`. You can enable the "reverse sequence of redirects when restoring" option to change this behavior. + +Portable Windows/Wine backups apply redirects after Ludusavi has translated the +portable save identity back into a physical path for the current Windows user or +selected Wine prefix. Redirects remain useful for custom layouts and unsupported +cases, but they are no longer needed just to remove Windows usernames or Wine +prefix paths from those portable backup keys. diff --git a/docs/help/roots.md b/docs/help/roots.md index 99ac1755..a388954d 100644 --- a/docs/help/roots.md +++ b/docs/help/roots.md @@ -44,6 +44,14 @@ along with the root's type: * For a Wine prefix root, this should be the folder containing `drive_c`. Currently, Ludusavi does not back up registry-based saves from the prefix, but will back up any file-based saves. + + When semantic (portable) backups are enabled, the Wine prefix is used + as a scan and restore target, but the prefix path itself is not stored + as the backup identity. This means backups from one prefix location + can be restored into a different prefix on another machine. + + For Steam Proton, you do not need to add a separate Wine prefix root. + The Steam root acts as the prefix provider for Proton compatdata prefixes. * The Windows, Linux, and Mac drive roots can be used to make Ludusavi scan external hard drives with a separate OS installation. For example, let's say you had a Windows laptop that broke, diff --git a/docs/help/transfer-between-operating-systems.md b/docs/help/transfer-between-operating-systems.md index 60d2e6d5..16a5bb65 100644 --- a/docs/help/transfer-between-operating-systems.md +++ b/docs/help/transfer-between-operating-systems.md @@ -1,6 +1,7 @@ # Transfer between operating systems Although Ludusavi itself runs on Windows, Linux, and Mac, -it does not automatically support backing up on one OS and restoring on another. +it does not automatically support backing up on one OS and restoring on another +for native platform paths. This is a complex problem to solve because games do not necessarily store data in the same way on each OS. @@ -9,12 +10,53 @@ but does not know which save locations correspond to each other, or even if any of them do correspond. Some games store data in completely different and incompatible ways on different OSes. +## Windows and Wine/Proton (supported) + +Ludusavi supports portable backups between Windows and Wine/Proton prefixes. +When a game's saves are stored under standard Windows locations +(Documents, AppData, ProgramData, etc.), +Ludusavi automatically uses a portable semantic path format +that works across Windows and Wine environments. + +This means: + +- **Windows → Wine/Proton:** Back up on Windows, restore into a Wine prefix on Linux + without needing per-game redirects. +- **Wine/Proton → Windows:** Back up from a Wine prefix on Linux, restore on Windows. + The backup identity is the same regardless of the Wine prefix location or username. + +Usernames (Windows `Alice`, Wine `steamuser`, SteamOS `deck`) and +Wine prefix paths are intentionally excluded from the backup identity. + +### Requirements + +- The game must store saves under a recognized Windows location + (Documents, AppData, LocalAppData, Saved Games, Public, ProgramData, Windows directory). +- When restoring into Wine on Linux, you need a configured Wine prefix + (via game-specific prefix roots, Heroic/Lutris/Steam Proton discovery, + or the `--wine-prefix` CLI option). +- If a game should always restore into one prefix, set + `restore.preferredWinePrefixes..path` in `config.yaml`. A CLI + `--wine-prefix` value must match that saved preference for the game; + otherwise Ludusavi stops instead of silently restoring into a different + prefix. + +### Limitations + +- Steam userdata paths use store-specific identity + (different Steam accounts = different save entries). +- Non-C drive paths require matching `dosdevices` mappings on the target. +- Registry-based saves are not yet portable across Windows and Wine. +- Native Windows to native Linux path equivalence is not supported + unless the manifest explicitly declares the relationship. + +## Native cross-OS (limited) + +For native Windows, Linux, and macOS paths that are not through Wine, +Ludusavi cannot automatically determine if saves are equivalent. In simple cases, you may be able to configure [redirects](/docs/help/redirects.md) to translate between specific Windows and Linux paths, but this would generally require multiple redirects tailored to each game. -In more complex cases, this is not practical or feasible. -A subset of cross-OS transfer is under consideration for Windows and Wine prefixes, -but there is no timeline for this. -You can follow this ticket for any future updates: +You can follow this ticket for future updates on native cross-OS support: https://github.com/mtkennerly/ludusavi/issues/194 diff --git a/docs/schema/config.yaml b/docs/schema/config.yaml index 69b83880..ae573a65 100644 --- a/docs/schema/config.yaml +++ b/docs/schema/config.yaml @@ -178,6 +178,10 @@ definitions: description: "Don't create a new backup if there are only removed saves and no new/edited ones." default: false type: boolean + semanticPaths: + description: "Use portable semantic paths for Windows/Wine saves instead of absolute paths. When enabled, backups are cross-platform between Windows and Wine." + default: true + type: boolean path: description: Full path to a directory in which to save backups. default: "C:/Users/mtken/ludusavi-backup" @@ -390,6 +394,23 @@ definitions: format: int32 FilePath: type: string + GameWinePrefixPreference: + type: object + properties: + path: + description: Wine/Proton prefix to use for this game when restoring portable Windows saves. + allOf: + - $ref: "#/definitions/FilePath" + wineUser: + description: "Preferred Wine user inside the prefix, if the prefix has multiple user profiles." + type: + - string + - "null" + driveMappings: + description: Optional non-C drive mappings for this game. + type: object + additionalProperties: + $ref: "#/definitions/FilePath" Integration: type: string enum: @@ -557,6 +578,23 @@ definitions: items: type: string uniqueItems: true + preferredWinePrefixes: + description: Preferred Wine/Proton prefixes for restoring portable Windows saves on non-Windows systems. + type: object + additionalProperties: + $ref: "#/definitions/GameWinePrefixPreference" + winePrefix: + description: "Global Wine prefix for restoring Windows semantic backups on Linux. Used as a fallback when no game-specific prefix is configured." + default: null + anyOf: + - $ref: "#/definitions/FilePath" + - type: "null" + driveMappings: + description: "Manual drive letter mappings for WinDrive semantic keys when dosdevices symlinks are not available. Keys are lowercase drive letters (a-z), values are target paths." + default: {} + type: object + additionalProperties: + type: string path: description: Full path to a directory from which to restore data. default: "C:/Users/mtken/ludusavi-backup" diff --git a/docs/schema/general-output.yaml b/docs/schema/general-output.yaml index dfd1b5ce..93504bd8 100644 --- a/docs/schema/general-output.yaml +++ b/docs/schema/general-output.yaml @@ -26,6 +26,11 @@ properties: anyOf: - $ref: "#/definitions/OperationStatus" - type: "null" + semanticPreview: + description: Portable backup changes detected during preview. + anyOf: + - $ref: "#/definitions/SemanticPreviewAnalysis" + - type: "null" definitions: ApiBackup: type: object @@ -126,6 +131,11 @@ definitions: type: - string - "null" + semanticKey: + description: "Portable semantic identity used for cross-platform backups, if available." + type: + - string + - "null" ApiGame: anyOf: - description: "Used by the `backup` and `restore` commands." @@ -247,6 +257,20 @@ definitions: - $ref: "#/definitions/ScanChange" CloudSyncFailed: type: object + InvalidPrefix: + description: A configured prefix that failed validation. + type: object + required: + - gameName + - path + - reason + properties: + gameName: + type: string + path: + type: string + reason: + type: string OperationStatus: type: object required: @@ -293,6 +317,22 @@ definitions: - linux - mac - other + PreviewConflict: + description: A duplicate semantic key produced by multiple physical files. + type: object + required: + - gameName + - semanticKey + - physicalPaths + properties: + gameName: + type: string + semanticKey: + type: string + physicalPaths: + type: array + items: + type: string SaveError: type: object required: @@ -328,3 +368,46 @@ definitions: type: integer format: uint minimum: 0.0 + SemanticMigration: + description: A pending migration from a legacy physical key to a semantic key. + type: object + required: + - gameName + - legacyKey + - semanticKey + properties: + gameName: + type: string + legacyKey: + type: string + semanticKey: + type: string + SemanticPreviewAnalysis: + description: Analysis result for semantic backup preview/dry-run. + type: object + required: + - migrations + - newFullChains + - invalidPrefixes + - conflicts + properties: + migrations: + description: Legacy keys that would become semantic keys. + type: array + items: + $ref: "#/definitions/SemanticMigration" + newFullChains: + description: Games that would start a new full backup chain. + type: array + items: + type: string + invalidPrefixes: + description: Configured prefixes that failed validation. + type: array + items: + $ref: "#/definitions/InvalidPrefix" + conflicts: + description: Semantic key conflicts. + type: array + items: + $ref: "#/definitions/PreviewConflict"