diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36ae8847..9b5ad110 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer - exclude: ^tests/(backup|root) + exclude: ^tests/ - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.1.7 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index ea298d52..08c7c6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,63 @@ +## v0.7.0 (2020-08-01) + +**The backup structure has changed! Read below for more detail.** + +* Added: + * Backup option to exclude save locations that are only confirmed for + another operating system. + * Backup option to exclude store screenshots. + * `--try-update` flag for backups via CLI. +* Fixed: + * When starting the GUI, if Ludusavi could not check for a manifest update + (e.g., because your Internet is down), then it would default to an empty + manifest even if you already had a local copy that was downloaded before. + Now, it will use the local copy even if it can't check for updates. +* Changed: + * Backup structure is now human-readable. + * App window now has a minimum size, 640x480. + (Note: For now, the crates.io release will not have a minimum size.) + * File size units are now adjusted based on the size, rather than always using MiB. + ([contributed by wtjones](https://github.com/mtkennerly/ludusavi/pull/32)) + +### New backup structure +Previously, Ludusavi used Base64 to encode game names and original paths when +organizing backups. There were some technical advantages of that approach, but +it was not easy to understand, and there was a technical flaw because Base64 +output can include `/`, which isn't safe for folder or file names. + +Therefore, Ludusavi now organizes backups like this, in a way that is easier +to read and understand: + +``` +C:/somewhere/the-backup-folder/ + Game 1 Name/ + mapping.yaml + registry.yaml + drive-C/ # drive-0 on Linux and Mac + Users/ + ... + Program Files/ + Steam/ + ... +``` + +The name of each game's folder is as close to the real title as possible, +except for replacing some special characters with `_`. Ultimately, Ludusavi +doesn't care much about the folder name and mainly looks for `mapping.yaml`, +which contains some metadata that Ludusavi needs. If a game has any Windows +registry data to back up, then there will also be a `registry.yaml` file. +Within each drive folder, everything is simply organized exactly like it +already is on your computer. + +If you need to restore a previous backup, then please use Ludusavi v0.6.0 +to do the restoration first, then migrate to Ludusavi v0.7.0 and create a +new backup. + +You can [read more here](https://github.com/mtkennerly/ludusavi/issues/29) +about the background of this change. Be assured that this sort of disruptive +change is not taken lightly, but may happen in some cases until Ludusavi +reaches version 1.0.0. + ## v0.6.0 (2020-07-29) * Added: diff --git a/Cargo.lock b/Cargo.lock index d716048f..db134d14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,7 +90,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c69a8137596e84c22d57f3da1b5de1d4230b1742a710091c85f4d7ce50f00f38" dependencies = [ - "libloading 0.6.2", + "libloading", ] [[package]] @@ -163,6 +163,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820" +[[package]] +name = "byte-unit" +version = "4.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba58563da2fefa88ddca9db6347a1818fc224be2faf916cd4c5e210d2653f4c" +dependencies = [ + "utf8-width", +] + [[package]] name = "bytemuck" version = "1.2.0" @@ -546,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1324bc4eae34f03b0ced586da5ae2b1ab46acfdae68b5b26d2e23dadae376a2" dependencies = [ "bitflags", - "libloading 0.6.2", + "libloading", "winapi 0.3.9", ] @@ -630,7 +639,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b11f15d1e3268f140f68d390637d5e76d849782d971ae7063e0da69fe9709a76" dependencies = [ - "libloading 0.6.2", + "libloading", ] [[package]] @@ -933,14 +942,14 @@ dependencies = [ [[package]] name = "gfx-backend-dx11" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92de0ddc0fde1a89b2a0e92dcc6bbb554bd34af0135e53a28d5ef064611094a4" +checksum = "32d95d5fddfa596c0628be117a16979b273f676b4e5a037a417365f274349123" dependencies = [ "bitflags", "gfx-auxil", "gfx-hal", - "libloading 0.5.2", + "libloading", "log", "parking_lot", "range-alloc", @@ -1567,16 +1576,6 @@ version = "0.2.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49" -[[package]] -name = "libloading" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b111a074963af1d37a139918ac6d49ad1d0d5e47f72fd55388619691a7d753" -dependencies = [ - "cc", - "winapi 0.3.9", -] - [[package]] name = "libloading" version = "0.6.2" @@ -1638,9 +1637,10 @@ dependencies = [ [[package]] name = "ludusavi" -version = "0.6.0" +version = "0.7.0" dependencies = [ "base64 0.12.3", + "byte-unit", "copypasta", "dialoguer", "dirs 3.0.1", @@ -3008,9 +3008,9 @@ checksum = "e604eb7b43c06650e854be16a2a03155743d3752dd1c943f6829e26b7a36e382" [[package]] name = "ttf-parser" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9adb8aeeaf663a49ffdf9cdb548232849174b451a22bc90921868b8491ee901" +checksum = "d973cfa0e6124166b50a1105a67c85de40bbc625082f35c0f56f84cb1fb0a827" [[package]] name = "twox-hash" @@ -3089,6 +3089,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8-width" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2c54fe5e8d6907c60dc6fba532cc8529245d97ff4e26cb490cb462de114ba4" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 43a08fdb..8b32f8be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ludusavi" -version = "0.6.0" +version = "0.7.0" authors = ["mtkennerly "] edition = "2018" description = "Game save backup tool" @@ -10,6 +10,7 @@ license = "MIT" [dependencies] base64 = "0.12.3" +byte-unit = "4.0.8" copypasta = "0.7.0" dialoguer = "0.6.2" dirs = "3.0.0" diff --git a/README.md b/README.md index f91a0235..0dfe1504 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,6 @@ Ludusavi is a tool for backing up your PC video game save data, written in [Rust](https://www.rust-lang.org). It is cross-platform and supports multiple game stores. -This tool uses the [Ludusavi Manifest](https://github.com/mtkennerly/ludusavi-manifest) -for info on what to back up, and it will automatically download the latest version of -the primary manifest. To add or update game entries in the primary manifest, please refer -to that project. Data is ultimately sourced from [PCGamingWiki](https://www.pcgamingwiki.com/wiki/Home), -so you are encouraged to contribute any new or fixed data back to the wiki itself. - ## Features * Ability to back up data from more than 7,000 games plus your own custom entries. * Backup and restore for Steam as well as other game libraries. @@ -21,6 +15,14 @@ so you are encouraged to contribute any new or fixed data back to the wiki itsel * Saves that are stored as files and in the Windows registry. * Proton saves with Steam. * Steam screenshots. +* Available as a [Playnite](https://playnite.link) extension: + https://github.com/mtkennerly/ludusavi-playnite + +This tool uses the [Ludusavi Manifest](https://github.com/mtkennerly/ludusavi-manifest) +for info on what to back up, and it will automatically download the latest version of +the primary manifest. The data is ultimately sourced from [PCGamingWiki](https://www.pcgamingwiki.com/wiki/Home), +so please contribute any new or fixed data back to the wiki itself, and your +improvements will be incorporated into Ludusavi's data as well. ## Demo ### GUI @@ -91,21 +93,20 @@ If you are on Mac: * You can press `preview` to see what the backup will include, without actually performing it. * You can press `back up` to perform the backup for real. - * If the target folder already exists, it will be deleted first, - then recreated. However, if you've enabled the merge option, - then it will not be deleted first. - * Within the target folder, for every game with data to back up, - a subfolder will be created with the game's name encoded as - [Base64](https://en.wikipedia.org/wiki/Base64). - For example, files for `Celeste` would go into a folder named `Q2VsZXN0ZQ==`. - * Within each game's backup folder, any relevant files will be stored with - their name as the Base64 encoding of the full path to the original file. - For example, `D:/Steam/steamapps/common/Celeste/Saves/0.celeste` would be - backed up as `RDovU3RlYW0vc3RlYW1hcHBzL2NvbW1vbi9DZWxlc3RlL1NhdmVzLzAuY2VsZXN0ZQ==`. + * If the target folder already exists, it will be deleted first and + recreated, unless you've enabled the merge option. + * Within the target folder, for every game with data to back up, a subfolder + will be created based on the game's name, where some invalid characters are + replaced by `_`. In rare cases, if the whole name is invalid characters, + then it will be renamed to `ludusavi-renamed-`. + * Within each game's subfolder, there will be a `mapping.yaml` file that + Ludusavi needs to identify the game. There will be some drive folders + (e.g., `drive-C` on Windows or `drive-0` on Linux and Mac) containing the + backup files, matching the normal file locations on your computer. * If the game has save data in the registry and you are using Windows, then - the game's backup folder will also contain an `other/registry.yaml` file. + the game's subfolder will also contain a `registry.yaml` file. If you are using Steam and Proton instead of Windows, then the Proton `*.reg` - files will be backed up like other game files. + files will be backed up along with the other game files instead. * Roots are folders that Ludusavi can check for additional game data. When you first run Ludusavi, it will try to find some common roots on your system, but you may end up without any configured. You can click `add root` to configure @@ -130,11 +131,16 @@ If you are on Mac: * You can press `preview` to see what the restore will include, without actually performing it. * You can press `restore` to perform the restore for real. - * For all the files in the source directory, they will be decoded as Base64 - to get the target path and then copied to that location. Any necessary - parent directories will be created as well before the copy, but if the - directories already exist, their current files will be left alone (other - than overwriting the ones that are being restored from the backup). + * For each subfolder in the source directory, Ludusavi looks for a `mapping.yaml` + file in order to identify each game. Subfolders without that file, or with an + invalid one, are ignored. + * All files from the drive folders are copied back to their original locations + on the respective drive. Any necessary parent directories will be created + as well before the copy, but if the directories already exist, then their + current files will be left alone (other than overwriting the ones that are + being restored from the backup). + * If the game subfolder includes a `registry.yaml` file, then the Windows + registry data will be restored as well. * You can use redirects to restore to a different location than the original file. Click `add redirect`, and then enter both the old and new location. For example, if you backed up some saves from `C:/Games`, but then you moved it to `D:/Games`, @@ -165,6 +171,10 @@ If you are on Mac: If the game name matches one from Ludusavi's primary data set, then your custom entry will override it. +#### Other settings +* Switch to this screen by clicking the `other` button. +* This screen contains some additional settings that are less commonly used. + ### CLI Run `ludusavi --help` for the full usage information. @@ -267,6 +277,13 @@ Here are the available settings (all are required unless otherwise noted): This can be overridden in the CLI by passing a list of games. * `merge` (optional, boolean): Whether to merge save data into the target directory rather than deleting the directory first. Default: false. + * `filter` (optional, map): + * `excludeOtherOsData` (optional, boolean): If true, then the backup should + exclude any files that have only been confirmed for a different operating + system than the one you're using. On Linux, Proton saves will still be + backed up regardless of this setting. Default: false. + * `excludeStoreScreenshots` (optional, boolean): If true, then the backup + should exclude screenshots from stores like Steam. Default: false. * `restore` (map): * `path` (string): Full path to a directory from which to restore data. This can be overridden in the CLI with `--path`. diff --git a/docs/demo-cli.gif b/docs/demo-cli.gif index 8dbc6e03..d7f42d5c 100644 Binary files a/docs/demo-cli.gif and b/docs/demo-cli.gif differ diff --git a/docs/demo-gui.gif b/docs/demo-gui.gif index 1b4c9cad..8895e9ea 100644 Binary files a/docs/demo-gui.gif and b/docs/demo-gui.gif differ diff --git a/src/cli.rs b/src/cli.rs index b768b28e..b3982475 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,11 +1,11 @@ use crate::{ config::{Config, RedirectConfig}, lang::Translator, + layout::BackupLayout, manifest::{Game, Manifest, SteamMetadata}, prelude::{ - app_dir, back_up_game, game_file_restoration_target, prepare_backup_target, restore_game, - scan_dir_for_restorable_games, scan_dir_for_restoration, scan_game_for_backup, BackupInfo, Error, - OperationStatus, OperationStepDecision, ScanInfo, StrictPath, + app_dir, back_up_game, game_file_restoration_target, prepare_backup_target, restore_game, scan_game_for_backup, + scan_game_for_restoration, BackupInfo, Error, OperationStatus, OperationStepDecision, ScanInfo, StrictPath, }, }; use indicatif::ParallelProgressIterator; @@ -52,10 +52,16 @@ pub enum Subcommand { #[structopt(long, conflicts_with("merge"))] no_merge: bool, - /// Download the latest copy of the manifest. + /// Check for any manifest updates and download if available. + /// If the check fails, report an error. #[structopt(long)] update: bool, + /// Check for any manifest updates and download if available. + /// If the check fails, continue anyway. + #[structopt(long, conflicts_with("update"))] + try_update: bool, + /// When naming specific games to process, this means that you'll /// provide the Steam IDs instead of the manifest names, and Ludusavi will /// look up those IDs in the manifest to find the corresponding names. @@ -213,7 +219,6 @@ impl Reporter { name: &str, scan_info: &ScanInfo, backup_info: &BackupInfo, - restoring: bool, decision: &OperationStepDecision, redirects: &[RedirectConfig], ) -> bool { @@ -235,29 +240,22 @@ impl Reporter { &decision, )); for entry in itertools::sorted(&scan_info.found_files) { - let mut entry_failed = backup_info.failed_files.contains(entry); let mut redirected_from = None; - let readable = if restoring { - if let Ok((original_target, redirected_target)) = - game_file_restoration_target(&entry.path, &redirects) - { - if original_target != redirected_target { - redirected_from = Some(original_target); - } - redirected_target - } else { - entry_failed = true; - entry.path.to_owned() - } + let readable = if let Some(original_path) = &entry.original_path { + let (target, original_target) = game_file_restoration_target(&original_path, &redirects); + redirected_from = original_target; + target } else { entry.path.to_owned() }; - if entry_failed { + + if backup_info.failed_files.contains(entry) { successful = false; parts.push(translator.cli_game_line_item_failed(&readable.render())); } else { parts.push(translator.cli_game_line_item_successful(&readable.render())); } + if let Some(redirected_from) = redirected_from { parts.push(translator.cli_game_line_item_redirected(&redirected_from.render())); } @@ -289,18 +287,10 @@ impl Reporter { let mut api_file = ApiFile::default(); api_file.bytes = entry.size; api_file.failed = backup_info.failed_files.contains(entry); - let readable = if restoring { - if let Ok((original_target, redirected_target)) = - game_file_restoration_target(&entry.path, &redirects) - { - if original_target != redirected_target { - api_file.original_path = Some(original_target.render()); - } - redirected_target - } else { - api_file.failed = true; - entry.path.to_owned() - } + let readable = if let Some(original_path) = &entry.original_path { + let (target, original_target) = game_file_restoration_target(&original_path, &redirects); + api_file.original_path = original_target.map(|x| x.render()); + target } else { entry.path.to_owned() }; @@ -372,6 +362,7 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { merge, no_merge, update, + try_update, by_steam_id, api, games, @@ -382,7 +373,14 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { Reporter::standard(translator) }; - let manifest = Manifest::load(&mut config, update)?; + let manifest = if try_update { + match Manifest::load(&mut config, true) { + Ok(x) => x, + Err(_) => Manifest::load(&mut config, false)?, + } + } else { + Manifest::load(&mut config, update)? + }; let backup_dir = match path { None => config.backup.path.clone(), @@ -457,6 +455,9 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { }; subjects.sort(); + let layout = BackupLayout::new(backup_dir.clone()); + let filter = config.backup.filter.clone(); + let info: Vec<_> = subjects .par_iter() .progress_count(subjects.len() as u64) @@ -470,6 +471,7 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { &roots, &StrictPath::from_std_path_buf(&app_dir()), &steam_id, + &filter, ); let ignored = !&config.is_game_enabled_for_backup(&name) && !games_specified; let decision = if ignored { @@ -480,14 +482,14 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { let backup_info = if preview || ignored { crate::prelude::BackupInfo::default() } else { - back_up_game(&scan_info, &backup_dir, &name) + back_up_game(&scan_info, &name, &layout) }; (name, scan_info, backup_info, decision) }) .collect(); for (name, scan_info, backup_info, decision) in info { - if !reporter.add_game(&name, &scan_info, &backup_info, false, &decision, &[]) { + if !reporter.add_game(&name, &scan_info, &backup_info, &decision, &[]) { failed = true; } } @@ -525,9 +527,10 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { } } + let layout = BackupLayout::new(restore_dir.clone()); + let steam_ids_to_names = &manifest.map_steam_ids_to_names(); - let restorables = scan_dir_for_restorable_games(&restore_dir); - let restorable_names: Vec<_> = restorables.iter().map(|(name, _)| name.to_owned()).collect(); + let restorable_names: Vec<_> = layout.mapping.games.keys().collect(); let games_specified = !games.is_empty(); let mut invalid_games: Vec<_> = games @@ -537,7 +540,7 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { match game.parse::() { Ok(id) => { if !steam_ids_to_names.contains_key(&id) - || !restorable_names.contains(&steam_ids_to_names[&id]) + || !restorable_names.contains(&&steam_ids_to_names[&id]) { Some(game.to_owned()) } else { @@ -546,7 +549,7 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { } Err(_) => Some(game.to_owned()), } - } else if !restorable_names.contains(game) { + } else if !restorable_names.contains(&game) { Some(game.to_owned()) } else { None @@ -561,11 +564,11 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { } let mut subjects: Vec<_> = if !&games.is_empty() { - restorables + restorable_names .iter() .filter_map(|x| { - if (by_steam_id && steam_ids_to_names.values().cloned().any(|y| y == x.0)) - || (games.contains(&x.0)) + if (by_steam_id && steam_ids_to_names.values().cloned().any(|y| &y == *x)) + || (games.contains(&x)) { Some(x.to_owned()) } else { @@ -574,15 +577,15 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { }) .collect() } else { - restorables.iter().cloned().collect() + restorable_names }; subjects.sort(); let info: Vec<_> = subjects .par_iter() .progress_count(subjects.len() as u64) - .map(|(name, path)| { - let scan_info = scan_dir_for_restoration(&path); + .map(|name| { + let scan_info = scan_game_for_restoration(&name, &layout); let ignored = !&config.is_game_enabled_for_restore(&name) && !games_specified; let decision = if ignored { OperationStepDecision::Ignored @@ -599,14 +602,7 @@ pub fn run_cli(sub: Subcommand) -> Result<(), Error> { .collect(); for (name, scan_info, backup_info, decision) in info { - if !reporter.add_game( - &name, - &scan_info, - &backup_info, - true, - &decision, - &config.get_redirects(), - ) { + if !reporter.add_game(&name, &scan_info, &backup_info, &decision, &config.get_redirects()) { failed = true; } } @@ -659,6 +655,7 @@ mod tests { merge: false, no_merge: false, update: false, + try_update: false, by_steam_id: false, api: false, games: vec![], @@ -692,6 +689,7 @@ mod tests { merge: true, no_merge: false, update: true, + try_update: false, by_steam_id: true, api: true, games: vec![s("game1"), s("game2")], @@ -712,6 +710,7 @@ mod tests { merge: false, no_merge: false, update: false, + try_update: false, by_steam_id: false, api: false, games: vec![], @@ -732,6 +731,7 @@ mod tests { merge: false, no_merge: true, update: false, + try_update: false, by_steam_id: false, api: false, games: vec![], @@ -740,6 +740,35 @@ mod tests { ); } + #[test] + fn accepts_cli_backup_with_try_update() { + check_args( + &["ludusavi", "backup", "--try-update"], + Cli { + sub: Some(Subcommand::Backup { + preview: false, + path: None, + force: false, + merge: false, + no_merge: false, + update: false, + try_update: true, + by_steam_id: false, + api: false, + games: vec![], + }), + }, + ); + } + + #[test] + fn rejects_cli_backup_with_update_and_try_update() { + check_args_err( + &["ludusavi", "backup", "--update", "--try-update"], + structopt::clap::ErrorKind::ArgumentConflict, + ); + } + #[test] fn accepts_cli_restore_with_minimal_arguments() { check_args( @@ -815,7 +844,6 @@ mod tests { "foo", &ScanInfo::default(), &BackupInfo::default(), - false, &OperationStepDecision::Processed, &[], ); @@ -825,7 +853,7 @@ mod tests { Overall: Games: 0 - Size: 0.00 MiB + Size: 0 B Location: {}/dev/null "#, &drive() @@ -847,10 +875,12 @@ Overall: ScannedFile { path: StrictPath::new(s("/file1")), size: 102_400, + original_path: None, }, ScannedFile { path: StrictPath::new(s("/file2")), size: 51_200, + original_path: None, }, }, found_registry_keys: hashset! { @@ -864,19 +894,19 @@ Overall: ScannedFile { path: StrictPath::new(s("/file2")), size: 51_200, + original_path: None, }, }, failed_registry: hashset! { s("HKEY_CURRENT_USER/Key1") }, }, - false, &OperationStepDecision::Processed, &[], ); assert_eq!( r#" -foo [0.10 MiB]: +foo [100.00 KiB]: - /file1 - [FAILED] /file2 - [FAILED] HKEY_CURRENT_USER/Key1 @@ -884,7 +914,7 @@ foo [0.10 MiB]: Overall: Games: 1 of 1 - Size: 0.10 of 0.15 MiB + Size: 100.00 of 150.00 KiB Location: /dev/null "# .trim() @@ -903,31 +933,32 @@ Overall: game_name: s("foo"), found_files: hashset! { ScannedFile { - path: StrictPath::new(s("L2ZpbGUx")), + path: StrictPath::new(format!("{}/backup/file1", drive())), size: 102_400, + original_path: Some(StrictPath::new(format!("{}/original/file1", drive()))), }, ScannedFile { - path: StrictPath::new(s("L2ZpbGUy")), + path: StrictPath::new(format!("{}/backup/file2", drive())), size: 51_200, + original_path: Some(StrictPath::new(format!("{}/original/file2", drive()))), }, }, found_registry_keys: hashset! {}, registry_file: None, }, &BackupInfo::default(), - true, &OperationStepDecision::Processed, &[], ); assert_eq!( r#" -foo [0.15 MiB]: - - /file1 - - /file2 +foo [150.00 KiB]: + - /original/file1 + - /original/file2 Overall: Games: 1 - Size: 0.15 MiB + Size: 150.00 KiB Location: /dev/null "# .trim() @@ -944,7 +975,6 @@ Overall: "foo", &ScanInfo::default(), &BackupInfo::default(), - false, &OperationStepDecision::Processed, &[], ); @@ -977,10 +1007,12 @@ Overall: ScannedFile { path: StrictPath::new(s("/file1")), size: 100, + original_path: None, }, ScannedFile { path: StrictPath::new(s("/file2")), size: 50, + original_path: None, }, }, found_registry_keys: hashset! { @@ -994,13 +1026,13 @@ Overall: ScannedFile { path: StrictPath::new(s("/file2")), size: 50, + original_path: None, }, }, failed_registry: hashset! { s("HKEY_CURRENT_USER/Key1") }, }, - false, &OperationStepDecision::Processed, &[], ); @@ -1054,19 +1086,20 @@ Overall: game_name: s("foo"), found_files: hashset! { ScannedFile { - path: StrictPath::new(s("L2ZpbGUx")), + path: StrictPath::new(format!("{}/backup/file1", drive())), size: 100, + original_path: Some(StrictPath::new(format!("{}/original/file1", drive()))), }, ScannedFile { - path: StrictPath::new(s("L2ZpbGUy")), + path: StrictPath::new(format!("{}/backup/file2", drive())), size: 50, + original_path: Some(StrictPath::new(format!("{}/original/file2", drive()))), }, }, found_registry_keys: hashset! {}, registry_file: None, }, &BackupInfo::default(), - true, &OperationStepDecision::Processed, &[], ); @@ -1083,10 +1116,10 @@ Overall: "foo": { "decision": "Processed", "files": { - "/file1": { + "/original/file1": { "bytes": 100 }, - "/file2": { + "/original/file2": { "bytes": 50 } }, diff --git a/src/config.rs b/src/config.rs index e97dab17..41e8cabf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,22 @@ pub struct RedirectConfig { pub target: StrictPath, } +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct BackupFilter { + #[serde( + default, + skip_serializing_if = "crate::serialization::is_false", + rename = "excludeOtherOsData" + )] + pub exclude_other_os_data: bool, + #[serde( + default, + skip_serializing_if = "crate::serialization::is_false", + rename = "excludeStoreScreenshots" + )] + pub exclude_store_screenshots: bool, +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct BackupConfig { pub path: StrictPath, @@ -50,6 +66,8 @@ pub struct BackupConfig { pub ignored_games: std::collections::HashSet, #[serde(default)] pub merge: bool, + #[serde(default)] + pub filter: BackupFilter, } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -89,6 +107,7 @@ impl Default for BackupConfig { path: default_backup_dir(), ignored_games: std::collections::HashSet::new(), merge: false, + filter: BackupFilter::default(), } } } @@ -275,6 +294,10 @@ mod tests { path: StrictPath::new(s("~/backup")), ignored_games: std::collections::HashSet::new(), merge: false, + filter: BackupFilter { + exclude_other_os_data: false, + exclude_store_screenshots: false, + }, }, restore: RestoreConfig { path: StrictPath::new(s("~/restore")), @@ -306,6 +329,9 @@ mod tests { - Backup Game 2 - Backup Game 2 merge: true + filter: + excludeOtherOsData: true + excludeStoreScreenshots: true restore: path: ~/restore ignoredGames: @@ -353,6 +379,10 @@ mod tests { s("Backup Game 2"), }, merge: true, + filter: BackupFilter { + exclude_other_os_data: true, + exclude_store_screenshots: true, + }, }, restore: RestoreConfig { path: StrictPath::new(s("~/restore")), @@ -417,6 +447,10 @@ mod tests { path: StrictPath::new(s("~/backup")), ignored_games: std::collections::HashSet::new(), merge: false, + filter: BackupFilter { + exclude_other_os_data: false, + exclude_store_screenshots: false, + }, }, restore: RestoreConfig { path: StrictPath::new(s("~/restore")), @@ -449,6 +483,9 @@ backup: - Backup Game 2 - Backup Game 3 merge: true + filter: + excludeOtherOsData: true + excludeStoreScreenshots: true restore: path: ~/restore ignoredGames: @@ -496,6 +533,10 @@ customGames: s("Backup Game 2"), }, merge: true, + filter: BackupFilter { + exclude_other_os_data: true, + exclude_store_screenshots: true, + }, }, restore: RestoreConfig { path: StrictPath::new(s("~/restore")), diff --git a/src/gui.rs b/src/gui.rs index 3e899adb..0f0cd5ef 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,11 +1,11 @@ use crate::{ config::{Config, RootsConfig}, lang::Translator, + layout::BackupLayout, manifest::{Game, Manifest, SteamMetadata, Store}, prelude::{ - app_dir, back_up_game, game_file_restoration_target, prepare_backup_target, restore_game, - scan_dir_for_restorable_games, scan_dir_for_restoration, scan_game_for_backup, BackupInfo, Error, - OperationStatus, OperationStepDecision, ScanInfo, StrictPath, + app_dir, back_up_game, game_file_restoration_target, prepare_backup_target, restore_game, scan_game_for_backup, + scan_game_for_restoration, BackupInfo, Error, OperationStatus, OperationStepDecision, ScanInfo, StrictPath, }, shortcuts::{Shortcut, TextHistory}, }; @@ -83,6 +83,14 @@ fn set_app_icon(settings: &mut iced::Settings) { } } +#[realia::dep_from_registry("ludusavi", "iced")] +fn set_app_min_size(_settings: &mut iced::Settings) {} + +#[realia::not(dep_from_registry("ludusavi", "iced"))] +fn set_app_min_size(settings: &mut iced::Settings) { + settings.window.min_size = Some((640, 480)); +} + #[derive(Default)] struct App { config: Config, @@ -95,9 +103,11 @@ struct App { nav_to_backup_button: button::State, nav_to_restore_button: button::State, nav_to_custom_games_button: button::State, + nav_to_other_button: button::State, backup_screen: BackupScreenComponent, restore_screen: RestoreScreenComponent, custom_games_screen: CustomGamesScreenComponent, + other_screen: OtherScreenComponent, operation_should_cancel: std::sync::Arc, progress: DisappearingProgress, } @@ -136,6 +146,8 @@ enum Message { EditedCustomGame(EditAction), EditedCustomGameFile(usize, EditAction), EditedCustomGameRegistry(usize, EditAction), + EditedExcludeOtherOsData(bool), + EditedExcludeStoreScreenshots(bool), SwitchScreen(Screen), ToggleGameListEntryExpanded { name: String, @@ -169,6 +181,7 @@ enum Screen { Backup, Restore, CustomGames, + Other, } #[derive(Debug, Clone, PartialEq)] @@ -363,15 +376,11 @@ impl GameListEntry { for item in itertools::sorted(&self.scan_info.found_files) { let mut redirected_from = None; let mut line = item.path.render(); - if restoring { - if let Ok((original_target, redirected_target)) = - game_file_restoration_target(&item.path, &config.get_redirects()) - { - if original_target != redirected_target { - redirected_from = Some(original_target); - } - line = redirected_target.render(); - } + if let Some(original_path) = &item.original_path { + let (target, original_target) = + game_file_restoration_target(&original_path, &config.get_redirects()); + redirected_from = original_target; + line = target.render(); } if let Some(backup_info) = &self.backup_info { if backup_info.failed_files.contains(&item) { @@ -394,7 +403,6 @@ impl GameListEntry { config.is_game_enabled_for_backup(&self.scan_info.game_name) }; let name_for_checkbox = self.scan_info.game_name.clone(); - Container::new( Column::new() .padding(5) @@ -434,7 +442,7 @@ impl GameListEntry { ) .push( Container::new(Text::new( - translator.mib(self.scan_info.sum_bytes(&self.backup_info), false), + translator.adjusted_size(self.scan_info.sum_bytes(&self.backup_info)), )) .width(Length::Units(115)) .center_x(), @@ -1311,6 +1319,51 @@ impl CustomGamesScreenComponent { } } +#[derive(Default)] +struct OtherScreenComponent { + scroll: scrollable::State, +} + +impl OtherScreenComponent { + fn view(&mut self, config: &Config, translator: &Translator) -> Container { + Container::new( + Scrollable::new(&mut self.scroll) + .width(Length::Fill) + .padding(10) + .style(style::Scrollable) + .push( + Column::new() + .padding(5) + .push( + Row::new() + .padding(20) + .spacing(20) + .align_items(Align::Center) + .push(Checkbox::new( + config.backup.filter.exclude_other_os_data, + translator.explanation_for_exclude_other_os_data(), + Message::EditedExcludeOtherOsData, + )), + ) + .push( + Row::new() + .padding(20) + .spacing(20) + .align_items(Align::Center) + .push(Checkbox::new( + config.backup.filter.exclude_store_screenshots, + translator.explanation_for_exclude_store_screenshots(), + Message::EditedExcludeStoreScreenshots, + )), + ), + ), + ) + .height(Length::Fill) + .width(Length::Fill) + .center_x() + } +} + impl Application for App { type Executor = executor::Default; type Message = Message; @@ -1330,7 +1383,10 @@ impl Application for App { Ok(x) => x, Err(x) => { modal_theme = Some(ModalTheme::Error { variant: x }); - Manifest::default() + match Manifest::load(&mut config, false) { + Ok(y) => y, + Err(_) => Manifest::default(), + } } }; @@ -1403,11 +1459,15 @@ impl Application for App { OngoingOperation::Backup }); + let layout = std::sync::Arc::new(BackupLayout::new(backup_path.clone())); + let filter = std::sync::Arc::new(self.config.backup.filter.clone()); + let mut commands: Vec> = vec![]; for key in all_games.iter().map(|(k, _)| k.clone()) { let game = all_games[&key].clone(); let roots = self.config.roots.clone(); - let backup_path2 = backup_path.clone(); + let layout2 = layout.clone(); + let filter2 = filter.clone(); let steam_id = game.steam.clone().unwrap_or(SteamMetadata { id: None }).id; let cancel_flag = self.operation_should_cancel.clone(); let ignored = !self.config.is_game_enabled_for_backup(&key); @@ -1428,13 +1488,14 @@ impl Application for App { &roots, &StrictPath::from_std_path_buf(&app_dir()), &steam_id, + &filter2, ); if ignored { return (Some(scan_info), None, OperationStepDecision::Ignored); } let backup_info = if !preview { - Some(back_up_game(&scan_info, &backup_path2, &key)) + Some(back_up_game(&scan_info, &key, &layout2)) } else { None }; @@ -1465,7 +1526,8 @@ impl Application for App { return Command::none(); } - let restorables = scan_dir_for_restorable_games(&restore_path); + let layout = std::sync::Arc::new(BackupLayout::new(restore_path.clone())); + let restorables: Vec<_> = layout.mapping.games.keys().cloned().collect(); self.restore_screen.status.clear(); self.restore_screen.log.entries.clear(); @@ -1484,8 +1546,9 @@ impl Application for App { self.progress.max = restorables.len() as f32; let mut commands: Vec> = vec![]; - for (name, subdir) in restorables { + for name in restorables { let redirects = self.config.get_redirects(); + let layout2 = layout.clone(); let cancel_flag = self.operation_should_cancel.clone(); let ignored = !self.config.is_game_enabled_for_restore(&name); commands.push(Command::perform( @@ -1496,7 +1559,7 @@ impl Application for App { return (None, None, OperationStepDecision::Cancelled); } - let scan_info = scan_dir_for_restoration(&subdir); + let scan_info = scan_game_for_restoration(&name, &layout2); if ignored { return (Some(scan_info), None, OperationStepDecision::Ignored); } @@ -1762,6 +1825,16 @@ impl Application for App { self.config.save(); Command::none() } + Message::EditedExcludeOtherOsData(enabled) => { + self.config.backup.filter.exclude_other_os_data = enabled; + self.config.save(); + Command::none() + } + Message::EditedExcludeStoreScreenshots(enabled) => { + self.config.backup.filter.exclude_store_screenshots = enabled; + self.config.save(); + Command::none() + } Message::SwitchScreen(screen) => { self.screen = screen; Command::none() @@ -2005,7 +2078,7 @@ impl Application for App { .horizontal_alignment(HorizontalAlignment::Center), ) .on_press(Message::SwitchScreen(Screen::Backup)) - .width(Length::Units(200)) + .width(Length::Units(175)) .style(match self.screen { Screen::Backup => style::NavButton::Active, _ => style::NavButton::Inactive, @@ -2019,7 +2092,7 @@ impl Application for App { .horizontal_alignment(HorizontalAlignment::Center), ) .on_press(Message::SwitchScreen(Screen::Restore)) - .width(Length::Units(200)) + .width(Length::Units(175)) .style(match self.screen { Screen::Restore => style::NavButton::Active, _ => style::NavButton::Inactive, @@ -2033,11 +2106,25 @@ impl Application for App { .horizontal_alignment(HorizontalAlignment::Center), ) .on_press(Message::SwitchScreen(Screen::CustomGames)) - .width(Length::Units(200)) + .width(Length::Units(175)) .style(match self.screen { Screen::CustomGames => style::NavButton::Active, _ => style::NavButton::Inactive, }), + ) + .push( + Button::new( + &mut self.nav_to_other_button, + Text::new(self.translator.nav_other_button()) + .size(16) + .horizontal_alignment(HorizontalAlignment::Center), + ) + .on_press(Message::SwitchScreen(Screen::Other)) + .width(Length::Units(175)) + .style(match self.screen { + Screen::Other => style::NavButton::Active, + _ => style::NavButton::Inactive, + }), ), ) .push( @@ -2050,6 +2137,7 @@ impl Application for App { self.custom_games_screen .view(&self.config, &self.translator, &self.operation) } + Screen::Other => self.other_screen.view(&self.config, &self.translator), } .height(Length::FillPortion(10_000)), ) @@ -2203,5 +2291,6 @@ mod style { pub fn run_gui() { let mut settings = iced::Settings::default(); set_app_icon(&mut settings); + set_app_min_size(&mut settings); App::run(settings) } diff --git a/src/lang.rs b/src/lang.rs index 86a1def1..88496591 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -1,3 +1,5 @@ +use byte_unit::Byte; + use crate::{ manifest::Store, prelude::{Error, OperationStatus, OperationStepDecision, StrictPath}, @@ -102,11 +104,11 @@ impl Translator { pub fn cli_game_header(&self, name: &str, bytes: u64, decision: &OperationStepDecision) -> String { if *decision == OperationStepDecision::Processed { match self.language { - Language::English => format!("{} [{}]:", name, self.mib(bytes, false)), + Language::English => format!("{} [{}]:", name, self.adjusted_size(bytes)), } } else { match self.language { - Language::English => format!("{} [{}] {}:", name, self.mib(bytes, false), self.label_ignored()), + Language::English => format!("{} [{}] {}:", name, self.adjusted_size(bytes), self.label_ignored()), } } } @@ -135,7 +137,7 @@ impl Translator { Language::English => format!( "\nOverall:\n Games: {}\n Size: {}\n Location: {}", status.total_games, - self.mib(status.total_bytes, true), + self.adjusted_size(status.total_bytes), location.render() ), } @@ -145,8 +147,8 @@ impl Translator { "\nOverall:\n Games: {} of {}\n Size: {} of {}\n Location: {}", status.processed_games, status.total_games, - self.mib_unlabelled(status.processed_bytes), - self.mib(status.total_bytes, true), + self.adjusted_size_unlabelled(status.processed_bytes), + self.adjusted_size(status.total_bytes), location.render() ), } @@ -213,6 +215,13 @@ impl Translator { .into() } + pub fn nav_other_button(&self) -> String { + match self.language { + Language::English => "OTHER", + } + .into() + } + pub fn add_root_button(&self) -> String { match self.language { Language::English => "Add root", @@ -297,7 +306,9 @@ impl Translator { pub fn manifest_cannot_be_updated(&self) -> String { match self.language { - Language::English => "Error: Unable to download an update to the manifest file.", + Language::English => { + "Error: Unable to check for an update to the manifest file. Is your Internet connection down?" + } } .into() } @@ -330,28 +341,26 @@ impl Translator { .into() } - pub fn mib(&self, bytes: u64, show_zero: bool) -> String { - let mib = self.mib_unlabelled(bytes); - if !show_zero && mib == "0.00" { - match self.language { - Language::English => "~ 0", - } - .into() - } else { - match self.language { - Language::English => format!("{} MiB", mib), - } - } + pub fn adjusted_size(&self, bytes: u64) -> String { + let byte = Byte::from_bytes(bytes.into()); + let adjusted_byte = byte.get_appropriate_unit(true); + adjusted_byte.to_string() } - pub fn mib_unlabelled(&self, bytes: u64) -> String { - format!("{:.2}", bytes as f64 / 1024.0 / 1024.0) + pub fn adjusted_size_unlabelled(&self, bytes: u64) -> String { + let byte = Byte::from_bytes(bytes.into()); + let adjusted_byte = byte.get_appropriate_unit(true); + format!("{:.2}", adjusted_byte.get_value()) } pub fn processed_games(&self, status: &OperationStatus) -> String { if status.completed() { match self.language { - Language::English => format!("{} games | {}", status.total_games, self.mib(status.total_bytes, true)), + Language::English => format!( + "{} games | {}", + status.total_games, + self.adjusted_size(status.total_bytes) + ), } } else { match self.language { @@ -359,8 +368,8 @@ impl Translator { "{} of {} games | {} of {}", status.processed_games, status.total_games, - self.mib_unlabelled(status.processed_bytes), - self.mib(status.total_bytes, true) + self.adjusted_size_unlabelled(status.processed_bytes), + self.adjusted_size(status.total_bytes) ), } } @@ -432,6 +441,20 @@ impl Translator { .into() } + pub fn explanation_for_exclude_other_os_data(&self) -> String { + match self.language { + Language::English => "In backups, exclude save locations that have only been confirmed on another operating system. Some games always put saves in the same place, but the locations may have only been confirmed for a different OS, so it can help to check them anyway. Excluding that data may help to avoid false positives, but may also mean missing out on some saves. On Linux, Proton saves will still be backed up regardless of this setting.", + } + .into() + } + + pub fn explanation_for_exclude_store_screenshots(&self) -> String { + match self.language { + Language::English => "In backups, exclude store-specific screenshots. Right now, this only applies to Steam screenshots that you've taken. If a game has its own built-in screenshot functionality, this setting will not affect whether those screenshots are backed up.", + } + .into() + } + pub fn modal_confirm_backup(&self, target: &StrictPath, target_exists: bool, merge: bool) -> String { match (self.language, target_exists, merge) { (Language::English, false, _) => format!("Are you sure you want to proceed with the backup? The target folder will be created: {}", target.render()), diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 00000000..df7786e4 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,346 @@ +use crate::{path::StrictPath, prelude::ScannedFile}; + +const SAFE: &str = "_"; + +fn encode_base64_for_folder(name: &str) -> String { + base64::encode(&name).replace("/", SAFE) +} + +fn escape_folder_name(name: &str) -> String { + let mut escaped = String::from(name); + + // Technically, dots should be fine as long as the folder name isn't + // exactly `.` or `..`. However, leading dots will often cause items + // to be hidden by default, which could be confusing for users, so we + // escape those. And Windows Explorer has a fun bug where, if you try + // to open a folder whose name ends with a dot, then it will say that + // the folder no longer exists at that location, so we also escape dots + // at the end of the name. The combination of these two rules also + // happens to cover the `.` and `..` cases. + if escaped.starts_with('.') { + escaped.replace_range(..1, SAFE); + } + if escaped.ends_with('.') { + escaped.replace_range(escaped.len() - 1.., SAFE); + } + + escaped + .replace("\\", SAFE) + .replace("/", SAFE) + .replace(":", SAFE) + .replace("*", SAFE) + .replace("?", SAFE) + .replace("\"", SAFE) + .replace("<", SAFE) + .replace(">", SAFE) + .replace("|", SAFE) + .replace("\0", SAFE) +} + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct IndividualMapping { + pub name: String, + #[serde(serialize_with = "crate::serialization::ordered_map")] + pub drives: std::collections::HashMap, +} + +impl IndividualMapping { + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + fn reversed_drives(&self) -> std::collections::HashMap { + self.drives.iter().map(|(k, v)| (v.to_owned(), k.to_owned())).collect() + } + + pub fn drive_folder_name(&mut self, drive: &str) -> String { + let reversed = self.reversed_drives(); + match reversed.get::(&drive) { + Some(mapped) => mapped.to_string(), + None => { + let key = if drive.is_empty() { + "drive-0".to_string() + } else { + // Simplify "C:" to "drive-C" instead of "drive-C_" for the common case. + format!("drive-{}", escape_folder_name(&drive.replace(":", ""))) + }; + self.drives.insert(key.to_string(), drive.to_string()); + key + } + } + } + + pub fn save(&self, file: &StrictPath) { + std::fs::write(file.interpret(), self.serialize().as_bytes()).unwrap(); + } + + pub fn serialize(&self) -> String { + serde_yaml::to_string(&self).unwrap() + } + + pub fn load(file: &StrictPath) -> Result { + if !file.is_file() { + return Err(()); + } + let content = std::fs::read_to_string(&file.interpret()).unwrap(); + Self::load_from_string(&content) + } + + pub fn load_from_string(content: &str) -> Result { + match serde_yaml::from_str(&content) { + Ok(x) => Ok(x), + Err(_) => Err(()), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct OverallMapping { + pub games: std::collections::HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct OverallMappingGame { + pub drives: std::collections::HashMap, + pub base: StrictPath, +} + +impl OverallMapping { + pub fn load(base: &StrictPath) -> Self { + let mut overall = Self::default(); + + for game_dir in walkdir::WalkDir::new(base.interpret()) + .max_depth(1) + .follow_links(false) + .into_iter() + .skip(1) // the base path itself + .filter_map(|e| e.ok()) + .filter(|x| x.file_type().is_dir()) + { + let individual_file = &mut game_dir.path().to_path_buf(); + individual_file.push("mapping.yaml"); + if individual_file.is_file() { + let game = match IndividualMapping::load(&StrictPath::from_std_path_buf(&individual_file)) { + Ok(x) => x, + Err(_) => continue, + }; + overall.games.insert( + game.name, + OverallMappingGame { + base: StrictPath::from_std_path_buf(&game_dir.path().to_path_buf()), + drives: game.drives, + }, + ); + } + } + + overall + } +} + +#[derive(Clone, Debug, Default)] +pub struct BackupLayout { + pub base: StrictPath, + pub mapping: OverallMapping, +} + +impl BackupLayout { + pub fn new(base: StrictPath) -> Self { + let mapping = OverallMapping::load(&base); + Self { base, mapping } + } + + fn generate_total_rename(original_name: &str) -> String { + format!("ludusavi-renamed-{}", encode_base64_for_folder(&original_name)) + } + + pub fn game_folder(&self, game_name: &str) -> StrictPath { + match self.mapping.games.get::(&game_name) { + Some(game) => game.base.clone(), + None => { + let mut safe_name = escape_folder_name(game_name); + + if safe_name.matches(SAFE).count() == safe_name.len() { + // It's unreadable now, so do a total rename. + safe_name = Self::generate_total_rename(&game_name); + } + + self.base.joined(&safe_name) + } + } + } + + pub fn game_file( + &self, + game_folder: &StrictPath, + original_file: &StrictPath, + mapping: &mut IndividualMapping, + ) -> StrictPath { + let (drive, plain_path) = original_file.split_drive(); + let drive_folder = mapping.drive_folder_name(&drive); + StrictPath::relative( + format!("{}/{}", drive_folder, plain_path), + Some(game_folder.interpret()), + ) + } + + pub fn game_mapping_file(&self, game_folder: &StrictPath) -> StrictPath { + game_folder.joined("mapping.yaml") + } + + #[allow(dead_code)] + pub fn game_registry_file(&self, game_folder: &StrictPath) -> StrictPath { + game_folder.joined("registry.yaml") + } + + pub fn restorable_files( + &self, + game_name: &str, + game_folder: &StrictPath, + ) -> std::collections::HashSet { + let mut files = std::collections::HashSet::new(); + for drive_dir in walkdir::WalkDir::new(game_folder.interpret()) + .max_depth(1) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let raw_drive_dir = drive_dir.path().display().to_string(); + let drive_mapping = match self.mapping.games.get::(&game_name) { + Some(x) => match x.drives.get::(&drive_dir.file_name().to_string_lossy()) { + Some(y) => y, + None => continue, + }, + None => continue, + }; + + for file in walkdir::WalkDir::new(drive_dir.path()) + .max_depth(100) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|x| x.file_type().is_file()) + { + let raw_file = file.path().display().to_string(); + let original_path = Some(StrictPath::new(raw_file.replace(&raw_drive_dir, drive_mapping))); + files.insert(ScannedFile { + path: StrictPath::new(raw_file), + size: match file.metadata() { + Ok(m) => m.len(), + _ => 0, + }, + original_path, + }); + } + } + files + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repo() -> String { + env!("CARGO_MANIFEST_DIR").to_string() + } + + mod individual_mapping { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn can_generate_drive_folder_name() { + let mut mapping = IndividualMapping::new("foo".to_owned()); + assert_eq!("drive-0", mapping.drive_folder_name("")); + assert_eq!("drive-C", mapping.drive_folder_name("C:")); + assert_eq!("drive-D", mapping.drive_folder_name("D:")); + assert_eq!("drive-____C", mapping.drive_folder_name(r#"\\?\C:"#)); + assert_eq!("drive-__remote", mapping.drive_folder_name(r#"\\remote"#)); + } + } + + mod backup_layout { + use super::*; + use pretty_assertions::assert_eq; + + fn layout() -> BackupLayout { + BackupLayout::new(StrictPath::new(format!("{}/tests/backup", repo()))) + } + + #[test] + fn can_find_existing_game_folder_with_matching_name() { + assert_eq!( + StrictPath::new(if cfg!(target_os = "windows") { + format!("\\\\?\\{}\\tests\\backup\\game1", repo()) + } else { + format!("{}/tests/backup/game1", repo()) + }), + layout().game_folder("game1") + ); + } + + #[test] + fn can_find_existing_game_folder_with_rename() { + assert_eq!( + StrictPath::new(if cfg!(target_os = "windows") { + format!("\\\\?\\{}\\tests\\backup\\game3-renamed", repo()) + } else { + format!("{}/tests/backup/game3-renamed", repo()) + }), + layout().game_folder("game3") + ); + } + + #[test] + fn can_determine_game_folder_that_does_not_exist_without_rename() { + assert_eq!( + if cfg!(target_os = "windows") { + StrictPath::new(format!("\\\\?\\{}\\tests\\backup/nonexistent", repo())) + } else { + StrictPath::new(format!("{}/tests/backup/nonexistent", repo())) + }, + layout().game_folder("nonexistent") + ); + } + + #[test] + fn can_determine_game_folder_that_does_not_exist_with_partial_rename() { + assert_eq!( + if cfg!(target_os = "windows") { + StrictPath::new(format!("\\\\?\\{}\\tests\\backup/foo_bar", repo())) + } else { + StrictPath::new(format!("{}/tests/backup/foo_bar", repo())) + }, + layout().game_folder("foo:bar") + ); + } + + #[test] + fn can_determine_game_folder_that_does_not_exist_with_total_rename() { + assert_eq!( + if cfg!(target_os = "windows") { + StrictPath::new(format!("\\\\?\\{}\\tests\\backup/ludusavi-renamed-Kioq", repo())) + } else { + StrictPath::new(format!("{}/tests/backup/ludusavi-renamed-Kioq", repo())) + }, + layout().game_folder("***") + ); + } + + #[test] + fn can_determine_game_folder_by_escaping_dots_at_start_and_end() { + assert_eq!( + if cfg!(target_os = "windows") { + StrictPath::new(format!("\\\\?\\{}\\tests\\backup/_._", repo())) + } else { + StrictPath::new(format!("{}/tests/backup/_._", repo())) + }, + layout().game_folder("...") + ); + } + } +} diff --git a/src/main.rs b/src/main.rs index 4003503e..1aea74c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod gui; mod lang; +mod layout; mod manifest; mod path; mod prelude; diff --git a/src/path.rs b/src/path.rs index c482dc9d..cf08b183 100644 --- a/src/path.rs +++ b/src/path.rs @@ -8,7 +8,10 @@ const TYPICAL_SEPARATOR: &str = "/"; #[cfg(not(target_os = "windows"))] const ATYPICAL_SEPARATOR: &str = "\\"; -const UNC_PREFIX: &str = "\\\\?\\"; +#[allow(dead_code)] +const UNC_PREFIX: &str = "\\\\"; +#[allow(dead_code)] +const UNC_LOCAL_PREFIX: &str = "\\\\?\\"; fn parse_home(path: &str) -> String { if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { @@ -79,7 +82,11 @@ fn interpret>(path: P, basis: &Option) -> String { ); format!( "{}{}", - if cfg!(target_os = "windows") { UNC_PREFIX } else { "" }, + if cfg!(target_os = "windows") && !dedotted.starts_with(UNC_LOCAL_PREFIX) { + UNC_LOCAL_PREFIX + } else { + "" + }, dedotted.replace(ATYPICAL_SEPARATOR, TYPICAL_SEPARATOR) ) } @@ -89,7 +96,7 @@ fn interpret>(path: P, basis: &Option) -> String { /// Convert a path into a nice form for display and storage. /// On Windows, this produces non-UNC paths. fn render>(path: P) -> String { - path.into().replace(UNC_PREFIX, "").replace("\\", "/") + path.into().replace(UNC_LOCAL_PREFIX, "").replace("\\", "/") } fn render_pathbuf(value: &std::path::PathBuf) -> String { @@ -158,6 +165,54 @@ impl StrictPath { } Ok(()) } + + pub fn joined(&self, other: &str) -> Self { + Self::new(format!("{}/{}", self.interpret(), other)) + } + + pub fn create_parent_dir(&self) -> std::io::Result<()> { + let mut pb = self.as_std_path_buf(); + pb.pop(); + std::fs::create_dir_all(&pb)?; + Ok(()) + } + + /// This splits a path into a drive (e.g., `C:` or `\\?\D:`) and the remainder. + /// This is only used during backups to record drives in mapping.yaml, so it + /// only has to deal with paths that can occur on the host OS. + #[cfg(target_os = "windows")] + pub fn split_drive(&self) -> (String, String) { + let interpreted = self.interpret(); + + if interpreted.starts_with(UNC_LOCAL_PREFIX) { + // Local UNC path - simplify to a classic drive for user-friendliness: + let split: Vec<_> = interpreted[UNC_LOCAL_PREFIX.len()..].splitn(2, '\\').collect(); + if split.len() == 2 { + return (split[0].to_owned(), split[1].replace("\\", "/")); + } + } else if interpreted.starts_with(UNC_PREFIX) { + // Remote UNC path - can't simplify to classic drive: + let split: Vec<_> = interpreted[UNC_PREFIX.len()..].splitn(2, '\\').collect(); + if split.len() == 2 { + return (format!("{}{}", UNC_PREFIX, split[0]), split[1].replace("\\", "/")); + } + } + + // This shouldn't normally happen, but we have a fallback just in case. + ("".to_owned(), self.raw.replace("\\", "/")) + } + + #[cfg(not(target_os = "windows"))] + pub fn split_drive(&self) -> (String, String) { + ( + "".to_owned(), + if self.raw.starts_with('/') { + self.raw[1..].to_string() + } else { + self.raw.to_string() + }, + ) + } } // Based on: @@ -187,6 +242,10 @@ impl<'de> serde::Deserialize<'de> for StrictPath { mod tests { use super::*; + fn s(text: &str) -> String { + text.to_string() + } + fn repo() -> String { env!("CARGO_MANIFEST_DIR").to_owned() } @@ -382,5 +441,39 @@ mod tests { assert!(StrictPath::new(format!("{}/README.md", repo())).exists()); assert!(!StrictPath::new(format!("{}/fake", repo())).exists()); } + + #[test] + #[cfg(target_os = "windows")] + fn can_split_drive_for_windows_path() { + assert_eq!((s("C:"), s("foo/bar")), StrictPath::new(s("C:/foo/bar")).split_drive()); + } + + #[test] + #[cfg(target_os = "windows")] + fn can_split_drive_for_local_unc_path() { + assert_eq!( + (s("C:"), s("foo/bar")), + StrictPath::new(s(r#"\\?\C:\foo\bar"#)).split_drive() + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn can_split_drive_for_remote_unc_path() { + // TODO: Should be `\\remote` and `foo\bar`. + // Despite this, when backing up to a machine-local network share, + // it gets resolved to the actual local drive and therefore works. + // Unsure about behavior for a remote network share at this time. + assert_eq!( + (s(""), s("/remote/foo/bar")), + StrictPath::new(s(r#"\\remote\foo\bar"#)).split_drive() + ); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn can_split_drive_for_nonwindows_path() { + assert_eq!((s(""), s("foo/bar")), StrictPath::new(s("/foo/bar")).split_drive()); + } } } diff --git a/src/prelude.rs b/src/prelude.rs index 22100cf9..1a4d05be 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -1,6 +1,7 @@ use crate::{ - config::{RedirectConfig, RootsConfig}, - manifest::{Game, Os, Store}, + config::{BackupFilter, RedirectConfig, RootsConfig}, + layout::{BackupLayout, IndividualMapping}, + manifest::{Game, GameFileConstraint, Os, Store}, }; pub use crate::path::StrictPath; @@ -48,16 +49,12 @@ pub enum Error { UnableToBrowseFileSystem, } -#[derive(Clone, Debug, thiserror::Error)] -pub enum OtherError { - #[error("Cannot determine restoration target")] - BadRestorationTarget, -} - #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct ScannedFile { pub path: StrictPath, pub size: u64, + /// This is the restoration target path, without redirects applied. + pub original_path: Option, } #[derive(Clone, Debug, Default, PartialEq)] @@ -65,7 +62,7 @@ pub struct ScanInfo { pub game_name: String, pub found_files: std::collections::HashSet, pub found_registry_keys: std::collections::HashSet, - pub registry_file: Option, + pub registry_file: Option, } impl ScanInfo { @@ -155,30 +152,16 @@ pub fn app_dir() -> std::path::PathBuf { path } -pub fn game_backup_dir(start: &StrictPath, game: &str) -> std::path::PathBuf { - let mut path = std::path::PathBuf::new(); - path.push(start.interpret()); - path.push(base64::encode(game)); - path -} - -pub fn game_file_backup_target(start: &StrictPath, game: &str, original_path: &StrictPath) -> std::path::PathBuf { - let mut path = game_backup_dir(&start, &game); - path.push(base64::encode(original_path.render())); - path -} - +/// Returns the effective target and the original target (if different) pub fn game_file_restoration_target( - file: &StrictPath, + original_target: &StrictPath, redirects: &[RedirectConfig], -) -> Result<(StrictPath, StrictPath), Box> { - let file_pb = file.as_std_path_buf(); - let base_name = file_pb.file_name().ok_or(OtherError::BadRestorationTarget)?; - let decoded = base64::decode(base_name.to_string_lossy().as_bytes())?; - let original_target = std::str::from_utf8(&decoded)?.to_string(); - - let mut redirected_target = original_target.clone(); +) -> (StrictPath, Option) { + let mut redirected_target = original_target.render(); for redirect in redirects { + if redirect.source.raw().trim().is_empty() || redirect.target.raw().trim().is_empty() { + continue; + } let source = redirect.source.render(); let target = redirect.target.render(); if !source.is_empty() && !target.is_empty() && redirected_target.starts_with(&source) { @@ -186,7 +169,12 @@ pub fn game_file_restoration_target( } } - Ok((StrictPath::new(original_target), StrictPath::new(redirected_target))) + let redirected_target = StrictPath::new(redirected_target); + if original_target.render() != redirected_target.render() { + (redirected_target, Some(original_target.clone())) + } else { + (original_target.clone(), None) + } } pub fn get_os() -> Os { @@ -317,12 +305,21 @@ fn glob_any(path: &StrictPath) -> Result { Ok(entries) } +fn should_exclude_as_other_os_data(constraints: &[GameFileConstraint], host: Os, maybe_proton: bool) -> bool { + let constrained = !constraints.is_empty(); + let unconstrained_by_os = constraints.iter().any(|x| x.os == None); + let matches_os = constraints.iter().any(|x| x.os == Some(host.clone())); + let suitable_for_proton = maybe_proton && constraints.iter().any(|x| x.os == Some(Os::Windows)); + constrained && !unconstrained_by_os && !matches_os && !suitable_for_proton +} + pub fn scan_game_for_backup( game: &Game, name: &str, roots: &[RootsConfig], manifest_dir: &StrictPath, steam_id: &Option, + filter: &BackupFilter, ) -> ScanInfo { let mut found_files = std::collections::HashSet::new(); #[allow(unused_mut)] @@ -342,15 +339,23 @@ pub fn scan_game_for_backup( continue; } if let Some(files) = &game.files { + let maybe_proton = get_os() == Os::Linux && root.store == Store::Steam && steam_id.is_some(); let default_install_dir = name.to_string(); let install_dirs: Vec<_> = match &game.install_dir { Some(x) => x.keys().collect(), _ => vec![&default_install_dir], }; - for raw_path in files.keys() { + for (raw_path, path_info) in files { if raw_path.trim().is_empty() { continue; } + if filter.exclude_other_os_data { + if let Some(constraints) = &path_info.when { + if should_exclude_as_other_os_data(&constraints, get_os(), maybe_proton) { + continue; + } + } + } let candidates = parse_paths(raw_path, &root, &install_dirs, &steam_id, &manifest_dir); for candidate in candidates { if candidate.raw().contains(SKIP) { @@ -368,14 +373,16 @@ pub fn scan_game_for_backup( )); // Screenshots: - paths_to_check.insert(StrictPath::relative( - format!( - "{}/userdata/*/760/remote/{}/screenshots/*.*", - root.path.interpret(), - &steam_id.unwrap() - ), - Some(manifest_dir.interpret()), - )); + if !filter.exclude_store_screenshots { + paths_to_check.insert(StrictPath::relative( + format!( + "{}/userdata/*/760/remote/{}/screenshots/*.*", + root.path.interpret(), + &steam_id.unwrap() + ), + Some(manifest_dir.interpret()), + )); + } // Registry: if game.registry.is_some() { @@ -407,6 +414,7 @@ pub fn scan_game_for_backup( Ok(m) => m.len(), _ => 0, }, + original_path: None, }); } else if p.is_dir() { for child in walkdir::WalkDir::new(p) @@ -422,6 +430,7 @@ pub fn scan_game_for_backup( Ok(m) => m.len(), _ => 0, }, + original_path: None, }); } } @@ -454,96 +463,22 @@ pub fn scan_game_for_backup( } } -pub fn scan_dir_for_restorable_games(source: &StrictPath) -> std::collections::HashSet<(String, StrictPath)> { - let mut games = std::collections::HashSet::new(); - for subdir in walkdir::WalkDir::new(source.interpret()) - .max_depth(1) - .follow_links(false) - .into_iter() - .skip(1) // the restore path itself - .filter_map(|e| e.ok()) - { - let path = &subdir.path(); - let base_name = match path.file_name() { - None => continue, - Some(x) => x, - }; - - let decoded_base_name = match base64::decode(base_name.to_string_lossy().as_bytes()) { - Err(_) => continue, - Ok(x) => x, - }; - let name = match std::str::from_utf8(&decoded_base_name) { - Err(_) => continue, - Ok(x) => x.to_string(), - }; - - games.insert((name, StrictPath::from_std_path_buf(&subdir.into_path()))); - } - games -} - -fn get_restore_name_and_parent(source: &StrictPath) -> Option<(String, StrictPath)> { - let path = source.as_std_path_buf(); - let base_name = match path.file_name() { - None => return None, - Some(x) => x, - }; - let parent = match path.parent() { - None => return None, - Some(x) => x.to_string_lossy(), - }; - - let decoded_base_name = match base64::decode(base_name.to_string_lossy().as_bytes()) { - Err(_) => return None, - Ok(x) => x, - }; - let name = match std::str::from_utf8(&decoded_base_name) { - Err(_) => return None, - Ok(x) => x.to_string(), - }; - - Some((name, StrictPath::new(parent.to_string()))) -} - -pub fn scan_dir_for_restoration(source: &StrictPath) -> ScanInfo { - match get_restore_name_and_parent(&source) { - None => ScanInfo::default(), - Some((name, parent)) => scan_game_for_restoration(&name, &parent), - } -} - -fn scan_game_for_restoration(name: &str, source: &StrictPath) -> ScanInfo { +pub fn scan_game_for_restoration(name: &str, layout: &BackupLayout) -> ScanInfo { let mut found_files = std::collections::HashSet::new(); #[allow(unused_mut)] let mut found_registry_keys = std::collections::HashSet::new(); #[allow(unused_mut)] let mut registry_file = None; - let target_game = game_backup_dir(&source, &name); - if target_game.as_path().is_dir() { - for child in walkdir::WalkDir::new(target_game.as_path()) - .max_depth(1) - .into_iter() - .filter_map(|e| e.ok()) - { - if child.file_type().is_file() { - let source = StrictPath::new(child.path().display().to_string()); - found_files.insert(ScannedFile { - path: source, - size: match child.metadata() { - Ok(m) => m.len(), - _ => 0, - }, - }); - } - } + let target_game = layout.game_folder(&name); + if target_game.is_dir() { + found_files = layout.restorable_files(&name, &target_game); } #[cfg(target_os = "windows")] { - if let Some(hives) = crate::registry::Hives::load(&crate::registry::game_registry_backup_file(&source, &name)) { - registry_file = Some(crate::registry::game_registry_backup_file(&source, &name)); + if let Some(hives) = crate::registry::Hives::load(&layout.game_registry_file(&target_game)) { + registry_file = Some(layout.game_registry_file(&target_game)); for (hive_name, keys) in hives.0.iter() { for (key_name, _) in keys.0.iter() { found_registry_keys.insert(format!("{}/{}", hive_name, key_name).replace("\\", "/")); @@ -575,17 +510,21 @@ pub fn prepare_backup_target(target: &StrictPath, merge: bool) -> Result<(), Err Ok(()) } -pub fn back_up_game(info: &ScanInfo, target: &StrictPath, name: &str) -> BackupInfo { +pub fn back_up_game(info: &ScanInfo, name: &str, layout: &BackupLayout) -> BackupInfo { let mut failed_files = std::collections::HashSet::new(); #[allow(unused_mut)] let mut failed_registry = std::collections::HashSet::new(); + let target_game = layout.game_folder(&name); + // Since we delete the game folder first, we don't need to worry about + // loading its existing mapping: + let mut mapping = IndividualMapping::new(name.to_string()); + let mut unable_to_prepare = false; - if !info.found_files.is_empty() || !info.found_registry_keys.is_empty() { - let target_game = game_backup_dir(&target, &name); - match StrictPath::from_std_path_buf(&target_game).remove() { + if info.found_anything() { + match target_game.remove() { Ok(_) => { - if std::fs::create_dir(target_game).is_err() { + if std::fs::create_dir(target_game.interpret()).is_err() { unable_to_prepare = true; } } @@ -601,8 +540,12 @@ pub fn back_up_game(info: &ScanInfo, target: &StrictPath, name: &str) -> BackupI continue; } - let target_file = game_file_backup_target(&target, &name, &file.path); - if std::fs::copy(&file.path.interpret(), &target_file).is_err() { + let target_file = layout.game_file(&target_game, &file.path, &mut mapping); + if target_file.create_parent_dir().is_err() { + failed_files.insert(file.clone()); + continue; + } + if std::fs::copy(&file.path.interpret(), &target_file.interpret()).is_err() { failed_files.insert(file.clone()); continue; } @@ -625,12 +568,16 @@ pub fn back_up_game(info: &ScanInfo, target: &StrictPath, name: &str) -> BackupI failed_registry.insert(reg_path.to_string()); } _ => { - hives.save(&crate::registry::game_registry_backup_file(&target, &name)); + hives.save(&layout.game_registry_file(&target_game)); } } } } + if info.found_anything() && !unable_to_prepare { + mapping.save(&layout.game_mapping_file(&target_game)); + } + BackupInfo { failed_files, failed_registry, @@ -642,29 +589,25 @@ pub fn restore_game(info: &ScanInfo, redirects: &[RedirectConfig]) -> BackupInfo let failed_registry = std::collections::HashSet::new(); 'outer: for file in &info.found_files { - match game_file_restoration_target(&file.path, &redirects) { - Err(_) => { - failed_files.insert(file.clone()); - continue; - } - Ok((_, target)) => { - let mut p = std::path::PathBuf::from(&target.interpret()); - p.pop(); - if std::fs::create_dir_all(&p.as_path().display().to_string()).is_err() { - failed_files.insert(file.clone()); - continue; - } - for i in 0..99 { - if std::fs::copy(&file.path.interpret(), &target.interpret()).is_ok() { - continue 'outer; - } - // File might be busy, especially if multiple games share a file, - // like in a collection, so retry after a delay: - std::thread::sleep(std::time::Duration::from_millis(i * info.game_name.len() as u64)); - } - failed_files.insert(file.clone()); + let original_path = match &file.original_path { + Some(x) => x, + None => continue, + }; + let (target, _) = game_file_restoration_target(&original_path, &redirects); + + if target.create_parent_dir().is_err() { + failed_files.insert(file.clone()); + continue; + } + for i in 0..99 { + if std::fs::copy(&file.path.interpret(), &target.interpret()).is_ok() { + continue 'outer; } + // File might be busy, especially if multiple games share a file, + // like in a collection, so retry after a delay: + std::thread::sleep(std::time::Duration::from_millis(i * info.game_name.len() as u64)); } + failed_files.insert(file.clone()); } #[cfg(target_os = "windows")] @@ -744,6 +687,107 @@ mod tests { .unwrap() } + #[test] + fn should_not_exclude_as_other_os_data_when_os_matches() { + assert_eq!( + false, + should_exclude_as_other_os_data( + &[GameFileConstraint { + os: Some(Os::Windows), + store: None + }], + Os::Windows, + false + ) + ); + } + + #[test] + fn should_exclude_as_other_os_data_when_os_does_not_match() { + assert_eq!( + true, + should_exclude_as_other_os_data( + &[GameFileConstraint { + os: Some(Os::Linux), + store: None + }], + Os::Windows, + false + ) + ); + } + + #[test] + fn should_not_exclude_as_other_os_data_when_no_os_constraint() { + assert_eq!( + false, + should_exclude_as_other_os_data( + &[GameFileConstraint { + os: None, + store: Some(Store::Steam) + }], + Os::Windows, + false + ) + ); + } + + #[test] + fn should_not_exclude_as_other_os_data_when_any_constraint_lacks_os() { + assert_eq!( + false, + should_exclude_as_other_os_data( + &[ + GameFileConstraint { + os: Some(Os::Linux), + store: None + }, + GameFileConstraint { + os: None, + store: Some(Store::Steam) + } + ], + Os::Windows, + false + ) + ); + } + + #[test] + fn should_exclude_as_other_os_data_when_constraint_has_store_and_other_os() { + assert_eq!( + true, + should_exclude_as_other_os_data( + &[GameFileConstraint { + os: Some(Os::Linux), + store: Some(Store::Steam) + }], + Os::Windows, + false + ) + ); + } + + #[test] + fn should_not_exclude_as_other_os_data_when_no_constraints() { + assert_eq!(false, should_exclude_as_other_os_data(&[], Os::Windows, false)); + } + + #[test] + fn should_not_exclude_as_other_os_data_when_suitable_for_proton() { + assert_eq!( + false, + should_exclude_as_other_os_data( + &[GameFileConstraint { + os: Some(Os::Windows), + store: Some(Store::Steam) + }], + Os::Linux, + true + ) + ); + } + #[test] fn can_scan_game_for_backup_with_file_matches() { assert_eq!( @@ -753,10 +797,12 @@ mod tests { ScannedFile { path: StrictPath::new(format!("{}/tests/root1/game1/subdir/file2.txt", repo())), size: 2, + original_path: None, }, ScannedFile { path: StrictPath::new(format!("{}/tests/root2/game1/file1.txt", repo())), size: 1, + original_path: None, }, }, found_registry_keys: hashset! {}, @@ -768,6 +814,7 @@ mod tests { &config().roots, &StrictPath::new(repo()), &None, + &BackupFilter::default(), ), ); @@ -778,6 +825,7 @@ mod tests { ScannedFile { path: StrictPath::new(format!("{}/tests/root2/game2/file1.txt", repo())), size: 1, + original_path: None, }, }, found_registry_keys: hashset! {}, @@ -789,6 +837,7 @@ mod tests { &config().roots, &StrictPath::new(repo()), &None, + &BackupFilter::default(), ), ); } @@ -811,6 +860,7 @@ mod tests { &config().roots, &StrictPath::new(repo()), &None, + &BackupFilter::default(), ), ); } @@ -833,37 +883,22 @@ mod tests { &config().roots, &StrictPath::new(repo()), &None, + &BackupFilter::default(), ), ); } #[test] - fn can_scan_dir_for_restorable_games() { - let make_path = |x| { - if cfg!(target_os = "windows") { - StrictPath::new(format!("\\\\?\\{}\\tests\\backup\\{}", repo().replace("/", "\\"), x)) - } else { - StrictPath::new(format!("{}/tests/backup/{}", repo(), x)) - } - }; - - assert_eq!( - hashset! {(s("game1"), make_path("Z2FtZTE=")), (s("game3"), make_path("Z2FtZTM=")) }, - scan_dir_for_restorable_games(&StrictPath::new(format!("{}/tests/backup", repo())),), - ); - } - - #[test] - fn can_scan_dir_for_restoration_with_files() { + fn can_scan_game_for_restoration_with_files() { let make_path = |x| { if cfg!(target_os = "windows") { StrictPath::new(format!( - "\\\\?\\{}\\tests\\backup\\Z2FtZTE=\\{}", + "\\\\?\\{}\\tests\\backup\\game1\\drive-X\\{}", repo().replace("/", "\\"), x )) } else { - StrictPath::new(format!("{}/tests/backup/Z2FtZTE=/{}", repo(), x)) + StrictPath::new(format!("{}/tests/backup/game1/drive-X/{}", repo(), x)) } }; @@ -871,18 +906,20 @@ mod tests { ScanInfo { game_name: s("game1"), found_files: hashset! { - ScannedFile { path: make_path("invalid.txt"), size: 0 }, - ScannedFile { path: make_path("ZmlsZTEudHh0"), size: 1 }, - ScannedFile { path: make_path("ZmlsZTIudHh0"), size: 2 }, + ScannedFile { path: make_path("file1.txt"), size: 1, original_path: Some(StrictPath::new(s(if cfg!(target_os = "windows") { "X:\\file1.txt" } else { "X:/file1.txt" }))) }, + ScannedFile { path: make_path("file2.txt"), size: 2, original_path: Some(StrictPath::new(s(if cfg!(target_os = "windows") { "X:\\file2.txt" } else { "X:/file2.txt" }))) }, }, ..Default::default() }, - scan_dir_for_restoration(&StrictPath::new(format!("{}/tests/backup/Z2FtZTE=", repo())),), + scan_game_for_restoration( + "game1", + &BackupLayout::new(StrictPath::new(format!("{}/tests/backup", repo()))) + ), ); } #[test] - fn can_scan_dir_for_restoration_with_registry() { + fn can_scan_game_for_restoration_with_registry() { if cfg!(target_os = "windows") { assert_eq!( ScanInfo { @@ -890,16 +927,16 @@ mod tests { found_registry_keys: hashset! { s("HKEY_CURRENT_USER/Software/Ludusavi/game3") }, - registry_file: Some( - StrictPath::new(format!( - "\\\\?\\{}\\tests\\backup\\Z2FtZTM=\\other\\registry.yaml", - repo().replace("/", "\\") - )) - .as_std_path_buf() - ), + registry_file: Some(StrictPath::new(format!( + "\\\\?\\{}\\tests\\backup\\game3-renamed/registry.yaml", + repo().replace("/", "\\") + ))), ..Default::default() }, - scan_dir_for_restoration(&StrictPath::new(format!("{}/tests/backup/Z2FtZTM=", repo())),), + scan_game_for_restoration( + "game3", + &BackupLayout::new(StrictPath::new(format!("{}/tests/backup", repo()))) + ), ); } else { assert_eq!( @@ -907,7 +944,10 @@ mod tests { game_name: s("game3"), ..Default::default() }, - scan_dir_for_restoration(&StrictPath::new(format!("{}/tests/backup/Z2FtZTM=", repo())),), + scan_game_for_restoration( + "game3", + &BackupLayout::new(StrictPath::new(format!("{}/tests/backup", repo()))) + ), ); } } diff --git a/src/registry.rs b/src/registry.rs index 35305443..c2a450f2 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -35,23 +35,25 @@ pub struct RegistryInfo { } impl Hives { - pub fn load(file: &std::path::PathBuf) -> Option { - if StrictPath::from_std_path_buf(&file).is_file() { - let content = std::fs::read_to_string(&file).ok()?; + pub fn load(file: &StrictPath) -> Option { + if file.is_file() { + let content = std::fs::read_to_string(&file.interpret()).ok()?; serde_yaml::from_str(&content).ok() } else { None } } - pub fn save(&self, file: &std::path::PathBuf) { - let mut folder = file.clone(); - folder.pop(); - if std::fs::create_dir_all(folder).is_ok() { - std::fs::write(file, serde_yaml::to_string(self).unwrap().as_bytes()).unwrap(); + pub fn save(&self, file: &StrictPath) { + if file.create_parent_dir().is_ok() { + std::fs::write(file.interpret(), self.serialize().as_bytes()).unwrap(); } } + pub fn serialize(&self) -> String { + serde_yaml::to_string(self).unwrap() + } + pub fn store_key_from_full_path(&mut self, path: &str) -> Result { let path = path.replace('/', "\\"); @@ -220,13 +222,6 @@ fn get_hkey_from_name(name: &str) -> Option { } } -pub fn game_registry_backup_file(start: &StrictPath, game: &str) -> std::path::PathBuf { - let mut path = crate::prelude::game_backup_dir(&start, &game); - path.push("other"); - path.push("registry.yaml"); - path -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 00000000..78b36ca0 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/tests/backup/Z2FtZTE=/ZmlsZTEudHh0 b/tests/backup/Z2FtZTE=/ZmlsZTEudHh0 deleted file mode 100644 index 945c9b46..00000000 --- a/tests/backup/Z2FtZTE=/ZmlsZTEudHh0 +++ /dev/null @@ -1 +0,0 @@ -. \ No newline at end of file diff --git a/tests/backup/ignored/file1.txt b/tests/backup/game1/drive-X/file1.txt similarity index 100% rename from tests/backup/ignored/file1.txt rename to tests/backup/game1/drive-X/file1.txt diff --git a/tests/backup/Z2FtZTE=/ZmlsZTIudHh0 b/tests/backup/game1/drive-X/file2.txt similarity index 100% rename from tests/backup/Z2FtZTE=/ZmlsZTIudHh0 rename to tests/backup/game1/drive-X/file2.txt diff --git a/tests/backup/game1/mapping.yaml b/tests/backup/game1/mapping.yaml new file mode 100644 index 00000000..bdc45a74 --- /dev/null +++ b/tests/backup/game1/mapping.yaml @@ -0,0 +1,3 @@ +name: game1 +drives: + drive-X: 'X:' diff --git a/tests/backup/game3-renamed/mapping.yaml b/tests/backup/game3-renamed/mapping.yaml new file mode 100644 index 00000000..279dd48e --- /dev/null +++ b/tests/backup/game3-renamed/mapping.yaml @@ -0,0 +1,2 @@ +name: game3 +drives: {} diff --git a/tests/backup/Z2FtZTM=/other/registry.yaml b/tests/backup/game3-renamed/registry.yaml similarity index 100% rename from tests/backup/Z2FtZTM=/other/registry.yaml rename to tests/backup/game3-renamed/registry.yaml diff --git a/tests/backup/ignored-invalid-mapping/mapping.yaml b/tests/backup/ignored-invalid-mapping/mapping.yaml new file mode 100644 index 00000000..053006c0 --- /dev/null +++ b/tests/backup/ignored-invalid-mapping/mapping.yaml @@ -0,0 +1,2 @@ +name: Ignored because file is invalid +drives: [] diff --git a/tests/backup/Z2FtZTE=/invalid.txt b/tests/backup/ignored-no-mapping/.keep similarity index 100% rename from tests/backup/Z2FtZTE=/invalid.txt rename to tests/backup/ignored-no-mapping/.keep