From c99d69573014c7025f8309c8ba1c8faf9329423c Mon Sep 17 00:00:00 2001 From: guenhter Date: Sun, 16 Jun 2024 12:05:48 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 10 + .github/workflows/release.yml | 26 ++ .gitignore | 2 + Cargo.lock | 330 ++++++++++++++++++ Cargo.toml | 25 ++ LICENSE | 21 ++ README.md | 39 +++ build.bat | 7 + package.wxs | 15 + src/main.rs | 638 ++++++++++++++++++++++++++++++++++ 10 files changed, 1113 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.bat create mode 100644 package.wxs create mode 100644 src/main.rs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bb8cf29 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[{*.yml,*.yaml}] +indent_size = 2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..79547f0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +--- +name: Rust Build and MSI Release + +on: + push: + branches: + - main + +jobs: + build: + name: Build with Cargo + runs-on: windows-latest + + steps: + - uses: actions/checkout@v2 + - uses: dtolnay/rust-toolchain@stable + - name: Build release + run: cargo build --verbose + - name: Install WiX + run: dotnet tool install --global wix + - name: Create MSI + run: wix build .\package.wxs -o SimpleFolderSyncer.msi + - name: Upload WiX Installer + uses: actions/upload-artifact@v4 + with: + path: SimpleFolderSyncer.msi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de358ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.vscode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0adaba1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,330 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "assertor" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71da9fbad72fe17f4904c6dfe0cc9988cec215ed69d30ff278b1a475e868763d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "simple-folder-syncer" +version = "0.1.0" +dependencies = [ + "anyhow", + "assertor", + "home", + "serde", + "serde_yaml", + "tempfile", + "walkdir", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b117a43 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +description = "Small folder mirror tool for Windows using robocopy under the hood" +name = "simple-folder-syncer" +version = "0.1.0" +authors = ["Günther Grill "] +license = "MIT" +repository = "https://github.com/guenhter/robocopy-folder-mirror" +categories = ["command-line-utilities"] +keywords = ["backup", "robocopy", "sync", "mirror"] +readme = "README.md" +edition = "2021" +rust-version = "1.79.0" + +[dependencies] +anyhow = "1.0.86" +assertor = "0.0.2" +home = "0.5.9" +serde = { version = "1.0.203", features = ["derive"] } +serde_yaml = "0.9.34" +tempfile = "3.10.1" +walkdir = "2.5.0" +windows-sys = { version = "0.52.0", features = [ + "Win32_System_Threading", + "Win32_Storage_FileSystem", +] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20fb7d0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Günther Grill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d964de --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Simple Folder Syncer + + +## Prerequisites + + +### MSI Prerequisites +To build a new MSI, the following tools are needed + +* .Net SDK (https://learn.microsoft.com/en-us/dotnet/core/install/windows) +* Wix Cargo Plugin +* Wix Tools (https://wixtoolset.org/docs/intro/#msbuild) + +The tools can be installed with + +```ps1 +# Install .Net +winget install Microsoft.DotNet.SDK.8 + +# Install Wix +dotnet tool install --global wix +``` + + + +## Create a new MSI + +```ps1 +cargo wix +``` + + +```ps1 +# Install an MSI and create the log for the installation process +msiexec /i my.msi /l*v install.log + +# Unsinstall the MSI +msiexec /x my.msi /l*v uninstall.log +``` diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..a97c2e5 --- /dev/null +++ b/build.bat @@ -0,0 +1,7 @@ +@echo off + +echo "Building..." +cargo build --release + +echo "Packaging..." +wix build .\package.wxs -o SimpleFolderSyncer.msi diff --git a/package.wxs b/package.wxs new file mode 100644 index 0000000..89a3d33 --- /dev/null +++ b/package.wxs @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3a3e52c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,638 @@ +#![cfg_attr(not(test), windows_subsystem = "windows")] + +use std::{ + fs::{self, File}, + io::Write, + os::windows::{fs::MetadataExt, process::CommandExt}, + path::{self, Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use windows_sys::Win32::{ + Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN, System::Threading::CREATE_NO_WINDOW, +}; + +const DEFAULT_CONFIG_FILE_NAME: &str = "backup_config.yaml"; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct Configuration { + source: String, + target: String, + create_last_backup_result_file: bool, + ignore_root_source_hidden_entries: bool, + additional_ignores: Vec, +} + +fn main() -> anyhow::Result<()> { + let home_dir = find_config_path().unwrap(); + run_backup(&home_dir)?; + + Ok(()) +} + +fn run_backup(config_path: &Path) -> anyhow::Result<()> { + let config = read_config(config_path)?; + + let output = run_backup_with_config(config.clone())?; + + if config.create_last_backup_result_file { + let target = path::absolute(PathBuf::from(config.target))?; + write_backup_result(&target, &output)?; + } + + Ok(()) +} + +fn run_backup_with_config(config: Configuration) -> anyhow::Result { + let source = path::absolute(PathBuf::from(config.source))?; + let target = path::absolute(PathBuf::from(config.target))?; + + let additional_ignores: Vec = config + .additional_ignores + .into_iter() + .map(PathBuf::from) + .map(path::absolute) + .collect::, _>>()?; + + let all_ignore_paths = create_ignore_paths( + &source, + config.ignore_root_source_hidden_entries, + additional_ignores, + )?; + let mut exclude_args = build_robocopy_exclude_arguments(&all_ignore_paths)?; + + let mut args = vec![ + source.display().to_string(), + target.display().to_string(), + "/mir".to_string(), + "/z".to_string(), + "/r:1".to_string(), + "/w:1".to_string(), + "/sl".to_string(), + "/unilog:C:\\temp\\backup_robocopy.log".to_string(), + ]; + args.append(&mut exclude_args); + + let output = std::process::Command::new("robocopy") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output()?; + + remove_all_files_and_folders_in_target(&source, &target, all_ignore_paths)?; + + let full_output = format!( + "=== STDOUT ===\n{}\n=== STDERR ===\n{}", + String::from_utf8(output.stdout)?, + String::from_utf8(output.stderr)? + ); + + Ok(full_output) +} + +fn find_config_path() -> Option { + match home::home_dir() { + Some(path) => Some(path.join(DEFAULT_CONFIG_FILE_NAME)), + None => None, + } +} + +fn read_config(config_path: &Path) -> anyhow::Result { + let config_file = fs::File::open(config_path)?; + let config: Configuration = serde_yaml::from_reader(config_file)?; + + let additional_ignores: Vec = config + .additional_ignores + .iter() + .map(|p| to_nice_windows_path(p)) + .collect(); + + let config = Configuration { + source: to_nice_windows_path(&config.source), + target: to_nice_windows_path(&config.target), + additional_ignores, + ..config + }; + Ok(config) +} + +fn to_nice_windows_path(path: &str) -> String { + path.to_string() + .replace("/", "\\") + .trim_end_matches("\\") + .to_string() +} + +fn write_backup_result(backup_target: &Path, content: &str) -> anyhow::Result<()> { + let mut file = File::create(backup_target.join("last-backup-result.txt"))?; + file.write_all(content.as_bytes())?; + + Ok(()) +} + +fn create_ignore_paths( + source: &Path, + ignore_root_source_hidden_entries: bool, + additional_ignores: Vec, +) -> anyhow::Result> { + let hidden_entries: Vec = if ignore_root_source_hidden_entries { + list_dir_entries(source)? + .into_iter() + .filter(|e| is_hidden(&e.path()).unwrap_or(false)) + .map(|e| e.path()) + .collect() + } else { + vec![] + }; + + let mut all_entries = hidden_entries; + let mut additional_ignores = additional_ignores; + all_entries.append(&mut additional_ignores); + + Ok(all_entries) +} + +fn build_robocopy_exclude_arguments(ignore_paths: &Vec) -> anyhow::Result> { + let mut args = vec![]; + + let ignore_file_paths: Vec = ignore_paths + .iter() + .filter(|e| e.is_file()) + .map(|e| e.display().to_string()) + .collect(); + let ignore_folder_paths: Vec = ignore_paths + .iter() + .filter(|e| e.is_dir()) + .map(|e| e.display().to_string()) + .collect(); + + if !ignore_file_paths.is_empty() { + args.push("/XF".to_string()); + args.append(&mut ignore_file_paths.clone()); + } + + if !ignore_folder_paths.is_empty() { + args.push("/XD".to_string()); + args.append(&mut ignore_folder_paths.clone()); + } + + Ok(args) +} + +fn list_dir_entries(dir: &Path) -> anyhow::Result> { + let paths = fs::read_dir(dir)? + .into_iter() + .filter_map(|e| e.ok()) + .collect(); + Ok(paths) +} + +fn is_hidden(dir_entry: &Path) -> std::io::Result { + let metadata = fs::metadata(dir_entry)?; + let attributes = metadata.file_attributes(); + + Ok(attributes & FILE_ATTRIBUTE_HIDDEN > 0) +} + +fn remove_all_files_and_folders_in_target( + source: &Path, + target: &Path, + paths_to_delete: Vec, +) -> anyhow::Result<()> { + let source = path::absolute(source)?; + let target = path::absolute(target)?; + + let paths_to_delete: Vec = replace_root_path(&source, &target, &paths_to_delete)? + .into_iter() + .map(|e| path::absolute(e)) + .collect::, _>>()?; + + // This check is actually unnecessary because logically it cannot happen due to `replace_root_path` + // but I anyway make it as a final safety net, in case something changes in the future, so this is + // the last gate which must be passed. + if paths_to_delete.iter().any(|e| !e.starts_with(&target)) { + return Err(anyhow::Error::msg( + "Some paths to delete are not prefixed with the target path", + )); + } + + let folders_to_delete = paths_to_delete.iter().filter(|e| e.is_dir()); + let files_to_delete = paths_to_delete.iter().filter(|e| e.is_file()); + + // Perform the actual deletion + for entry in folders_to_delete { + println!(" -- Removing {}", entry.display()); + + match remove_dir_all_alternative(entry) { + Ok(_) => {} + Err(e) => { + println!("Error deleting folder {}", entry.display()); + return Err(e.into()); + } + } + } + for entry in files_to_delete { + fs::remove_file(entry)?; + } + + Ok(()) +} + +// https://github.com/rust-lang/rust/issues/126576 +fn remove_dir_all_alternative(path: &Path) -> anyhow::Result<()> { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + + if path.is_dir() { + remove_dir_all_alternative(&path)?; + } else { + fs::remove_file(path)?; + } + } + + fs::remove_dir(path)?; + + Ok(()) +} + +fn replace_root_path( + current_root: &Path, + new_root: &Path, + paths: &Vec, +) -> anyhow::Result> { + paths + .iter() + .map(|path| { + let relative_path = path.strip_prefix(current_root)?; + Ok(new_root.join(relative_path)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::fs; + + use anyhow::Ok; + use assertor::{assert_that, BooleanAssertion, EqualityAssertion, VecAssertion}; + use tempfile::tempdir; + use walkdir::WalkDir; + use windows_sys::Win32::Storage::FileSystem::SetFileAttributesA; + + // Note this useful idiom: importing names from outer (for mod tests) scope. + use super::*; + + #[test] + fn test_run_without_special_parameters() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + let target_dir_path: PathBuf = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], + }, + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let expected_dir_hierarchy = vec![ + "file1.txt".to_string(), + "hidden-file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + "some-hidden-folder".to_string(), + "some-hidden-folder\\file3.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_run_with_hidden_files_in_root_ignored() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + let target_dir_path: PathBuf = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: true, + additional_ignores: vec![], + }, + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let expected_dir_hierarchy = vec![ + "file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_create_ignore_paths() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + + prepare_test_folder(&source_dir_path).unwrap(); + + let ignore_paths = create_ignore_paths( + &source_dir_path, + true, + vec![source_dir_path.join("foobar.txt")], + ) + .unwrap(); + + assert_that!(ignore_paths).contains_exactly(vec![ + source_dir_path.join("hidden-file1.txt"), + source_dir_path.join("some-hidden-folder"), + source_dir_path.join("foobar.txt"), + ]); + } + + #[test] + fn test_create_ignore_paths_nothing_ignored() { + let ignore_paths = create_ignore_paths(Path::new("/tmp/foo"), false, vec![]).unwrap(); + + assert_that!(ignore_paths).is_empty(); + } + + #[test] + fn test_backup_result_file_written_to_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path = temp_dir.path().join("source"); + let target_dir_path = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: true, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], + }, + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let backup_result_file = target_dir_path.join("last-backup-result.txt"); + assert_that!(backup_result_file.exists()).is_true(); + } + } + + #[test] + fn test_ignored_files_or_folders_get_deleted_on_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let source_dir_path = temp_dir.path().join("source"); + let target_dir_path = temp_dir.path().join("target"); + + let config = Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], + }; + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup_with_config(config.clone()).unwrap(); + + // Run again, but this time ignore hidden files + let config = Configuration { + ignore_root_source_hidden_entries: true, + ..config + }; + run_backup_with_config(config).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let source_dir_hierarchy = list_files_and_folders(&source_dir_path).unwrap(); + let expected_target_dir_hierarchy = vec![ + "file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + ]; + let expected_source_dir_hierarchy = vec![ + "file1.txt".to_string(), + "hidden-file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + "some-hidden-folder".to_string(), + "some-hidden-folder\\file3.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_target_dir_hierarchy); + assert_that!(source_dir_hierarchy).contains_exactly(expected_source_dir_hierarchy); + } + } + + #[test] + fn test_read_config() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + + store_config( + &config_path, + &Configuration { + source: "C:\\temp\\source\\".to_string(), + target: "C:\\temp\\target\\".to_string(), + create_last_backup_result_file: true, + ignore_root_source_hidden_entries: true, + additional_ignores: vec!["C:\\temp\\source\\some-folder\\".to_string()], + }, + ) + .unwrap(); + + let read_config = read_config(&config_path).unwrap(); + + assert_that!(read_config).is_equal_to(Configuration { + source: "C:\\temp\\source".to_string(), + target: "C:\\temp\\target".to_string(), + create_last_backup_result_file: true, + ignore_root_source_hidden_entries: true, + additional_ignores: vec!["C:\\temp\\source\\some-folder".to_string()], + }); + } + + #[test] + fn test_remove_all_files_and_folders_in_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let temp_dir = temp_dir.path(); + + fs::File::create(temp_dir.join("f1.txt")).unwrap(); + fs::File::create(temp_dir.join("f2.txt")).unwrap(); + fs::create_dir_all(temp_dir.join("stay")).unwrap(); + fs::File::create(temp_dir.join("stay").join("f3.txt")).unwrap(); + + fs::create_dir_all(temp_dir.join("tbr").join("foo").join("bar")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("f4.txt")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("foo").join("f5.txt")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("foo").join("bar").join("f6.txt")).unwrap(); + + let paths_to_remove = vec![temp_dir.join("f2.txt"), temp_dir.join("tbr")]; + + // Function under test + { + remove_all_files_and_folders_in_target(&temp_dir, &temp_dir, paths_to_remove).unwrap(); + } + + // Assertions + { + let actual_dir_hierarchy = list_files_and_folders(&temp_dir).unwrap(); + let expected_dir_hierarchy = vec![ + "f1.txt".to_string(), + "stay".to_string(), + "stay\\f3.txt".to_string(), + ]; + assert_that!(actual_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_replace_root_path() { + let current_root = Path::new("/tmp/foo"); + let paths_to_replace = vec![ + PathBuf::from("/tmp/foo/file1.txt"), + PathBuf::from("/tmp/foo/subdir/file2.txt"), + ]; + let new_root = Path::new("/foo/bar"); + + let updated_paths = replace_root_path(current_root, new_root, &paths_to_replace).unwrap(); + + assert_that!(updated_paths.len()).is_equal_to(2); + assert_that!(updated_paths[0]).is_equal_to(PathBuf::from("/foo/bar/file1.txt")); + assert_that!(updated_paths[1]).is_equal_to(PathBuf::from("/foo/bar/subdir/file2.txt")); + } + + fn store_config(config_path: &PathBuf, test_config: &Configuration) -> anyhow::Result<()> { + let config_file = fs::File::create(config_path)?; + serde_yaml::to_writer(config_file, test_config)?; + Ok(()) + } + + fn list_files_and_folders(dir: &Path) -> anyhow::Result> { + println!("== List {}", dir.display()); + + WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.path().to_path_buf()) + .filter(|e| *e != dir) + .map(|path| { + let relative_path = path.strip_prefix(dir)?; + Ok(relative_path.display().to_string()) + }) + .collect() + } + + fn prepare_test_folder(temp_dir: &Path) -> anyhow::Result<()> { + // | + // - some-folder + // - file2.txt + // - hidden-file2.txt + // - some-hidden-folder + // - file3.txt + // - file1.txt + // - hidden-file1.txt + + fs::create_dir_all(temp_dir)?; + + let vis_folder = temp_dir.join("some-folder"); + let hidden_folder = temp_dir.join("some-hidden-folder"); + + create_folder(&vis_folder, false).unwrap(); + create_folder(&hidden_folder, true).unwrap(); + + create_file(&temp_dir.join("file1.txt"), false).unwrap(); + create_file(&temp_dir.join("hidden-file1.txt"), true).unwrap(); + + create_file(&vis_folder.join("file2.txt"), false).unwrap(); + create_file(&vis_folder.join("hidden-file2.txt"), true).unwrap(); + + create_file(&hidden_folder.join("file3.txt"), true).unwrap(); + + Ok(()) + } + + fn create_folder(path: &Path, hidden: bool) -> anyhow::Result<()> { + fs::create_dir_all(path)?; + + if hidden { + add_file_attributes(path, FILE_ATTRIBUTE_HIDDEN)?; + } + + Ok(()) + } + + fn create_file(path: &Path, hidden: bool) -> anyhow::Result<()> { + fs::File::create(path)?; + + if hidden { + add_file_attributes(path, FILE_ATTRIBUTE_HIDDEN)?; + } + + Ok(()) + } + + fn add_file_attributes(path: &Path, new_attributes: u32) -> anyhow::Result<()> { + let metadata = fs::metadata(path)?; + let existing_attributes = metadata.file_attributes(); + + let c_str = CString::new(path.display().to_string())?; + + unsafe { + SetFileAttributesA( + c_str.as_ptr() as *const u8, + existing_attributes | new_attributes, + ); + } + + Ok(()) + } +}