├── src ├── mutators.rs ├── command │ ├── list.rs │ ├── cleanup.rs │ ├── upgrade.rs │ ├── init.rs │ ├── diff.rs │ ├── status.rs │ ├── edit.rs │ └── mod.rs ├── hoard_item │ └── mod.rs ├── hoard │ ├── iter │ │ ├── mod.rs │ │ └── operation.rs │ └── mod.rs ├── main.rs ├── filters │ ├── mod.rs │ └── ignore.rs ├── newtypes │ ├── mod.rs │ ├── environment_name.rs │ ├── hoard_name.rs │ └── non_empty_pile_name.rs ├── dirs │ ├── unix.rs │ ├── win.rs │ └── mod.rs ├── config │ └── builder │ │ ├── environment │ │ ├── hostname.rs │ │ ├── os.rs │ │ ├── envvar.rs │ │ └── path.rs │ │ └── var_defaults.rs ├── test.rs ├── checksum │ └── mod.rs ├── diff.rs ├── lib.rs └── checkers │ ├── mod.rs │ └── history │ └── mod.rs ├── book ├── .gitignore ├── src │ ├── cli │ │ ├── checks │ │ │ └── last-paths.md │ │ ├── README.md │ │ ├── logging.md │ │ ├── checks.md │ │ └── flags-subcommands.md │ ├── config │ │ ├── envs.md │ │ ├── README.md │ │ ├── envvars.md │ │ └── environments.md │ ├── getting-started │ │ ├── README.md │ │ ├── initial-setup.md │ │ ├── create-config │ │ │ ├── hoard.md │ │ │ ├── README.md │ │ │ ├── vim.md │ │ │ └── games.md │ │ └── installation.md │ ├── SUMMARY.md │ ├── README.md │ ├── permissions.md │ ├── terminology.md │ └── file-locations.md └── book.toml ├── .gitignore ├── renovate.json ├── tests ├── fake_editors │ ├── fake-error-editor.sh │ ├── fake-error-editor.ps1 │ ├── fake-editor.sh │ ├── fake-editor.ps1 │ ├── mod.rs │ ├── windows.rs │ └── unix.rs ├── hoard_list.rs ├── common │ ├── toml.rs │ ├── mod.rs │ ├── test_subscriber.rs │ └── file.rs ├── hoard_init.rs ├── config_precedence_conflict.rs ├── ignore_filter.rs ├── expected_errors.rs ├── operation_checksums.rs ├── config_yaml_support.rs ├── operations.rs └── last_paths.rs ├── .github ├── actions-rs │ └── grcov.yml └── workflows │ ├── clippy.yml │ ├── audit.yml │ ├── release.yml │ └── publish-mdbook.yml ├── Dockerfile ├── clippy.toml ├── knope.toml ├── LICENSE ├── README.md ├── Cargo.toml ├── cliff.toml ├── operation_upgrade_test.sh ├── Makefile.toml ├── CONTRIBUTING.md └── config.toml.sample /src/mutators.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /book/src/cli/checks/last-paths.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /book/src/config/envs.md: -------------------------------------------------------------------------------- 1 | # Environments 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | /target 3 | *.profraw 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "assignees": ["Shadow53"] 6 | } 7 | -------------------------------------------------------------------------------- /book/src/cli/README.md: -------------------------------------------------------------------------------- 1 | # Command-Line Tool 2 | 3 | This section describes the usage and behavior of the command-line tool `hoard`. 4 | -------------------------------------------------------------------------------- /tests/fake_editors/fake-error-editor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ "$1" = "--" ] && shift 4 | echo "opened $1 in fake editor" 1>&2 5 | exit 1 6 | 7 | -------------------------------------------------------------------------------- /tests/fake_editors/fake-error-editor.ps1: -------------------------------------------------------------------------------- 1 | param ( [Parameter(Mandatory=$true)][string]$target ) 2 | Write-Error "opened $target in fake editor" 3 | Exit 1 4 | 5 | -------------------------------------------------------------------------------- /tests/fake_editors/fake-editor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ "$1" = "--" ] && shift 4 | echo "opened $1 in fake editor" > "$HOME/watchdog.txt" 5 | echo "opened $1 in fake editor" > "$1" 6 | -------------------------------------------------------------------------------- /.github/actions-rs/grcov.yml: -------------------------------------------------------------------------------- 1 | branch: true 2 | ignore-not-existing: true 3 | llvm: true 4 | filter: covered 5 | output-type: lcov 6 | output-path: ./lcov.info 7 | prefix-dir: /home/user/build/ 8 | ignore: 9 | - "/*" 10 | - "C:/*" 11 | - "../*" 12 | -------------------------------------------------------------------------------- /tests/fake_editors/fake-editor.ps1: -------------------------------------------------------------------------------- 1 | param ( [Parameter(Mandatory=$true)][string]$target ) 2 | Set-Content -NoNewline -Path $target -Value "opened $target in fake editor" 3 | Set-Content -NoNewline -Path "$Env:HOARD_TMP\watchdog.txt" -Value "opened $target in fake editor" 4 | -------------------------------------------------------------------------------- /book/src/getting-started/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This section will guide you through installing Hoard and setting up your first hoards. 4 | 5 | > **NOTE:** The examples use TOML as the config file format. Users looking to use YAML should be able to translate 6 | > the configuration from TOML. See also [this other note](../config/). 7 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Clippy check 3 | jobs: 4 | clippy_check: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - run: rustup component add clippy 9 | - uses: actions-rs/clippy-check@v1 10 | with: 11 | token: ${{ secrets.GITHUB_TOKEN }} 12 | args: --all-features 13 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | push: 4 | paths: 5 | - '**/Cargo.toml' 6 | - '**/Cargo.lock' 7 | jobs: 8 | security_audit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions-rs/audit-check@v1 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /book/src/cli/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | Output verbosity is controlled by the logging level. You can set the logging level with the 4 | `HOARD_LOG` environment variable. Valid values (in decreasing verbosity) are: 5 | 6 | - `trace` 7 | - `debug` 8 | - `info` 9 | - `warn` 10 | - `error` 11 | 12 | The default logging level is `info` for release builds and `debug` for debugging builds. 13 | 14 | -------------------------------------------------------------------------------- /tests/hoard_list.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod common; 3 | 4 | use common::tester::Tester; 5 | use hoard::command::Command; 6 | 7 | #[tokio::test] 8 | async fn test_hoard_list() { 9 | let tester = Tester::new(common::base::BASE_CONFIG).await; 10 | let expected = "anon_dir\nanon_file\nnamed\n"; 11 | tester.expect_command(Command::List).await; 12 | tester.assert_has_output(expected); 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:alpine 2 | 3 | ENV RUSTFLAGS="-Cinstrument-coverage" 4 | ENV LLVM_PROFILE_FILE="profraw/hoard-test-%p-%9m.profraw" 5 | ENV CI=true GITHUB_ACTIONS=true HOARD_LOG=trace 6 | WORKDIR /hoard-tests 7 | 8 | RUN apk add build-base xdg-utils file busybox 9 | COPY Cargo.toml Cargo.lock config.toml.sample ./ 10 | COPY src ./src 11 | RUN cargo test --no-run 12 | CMD cargo test -- --test-threads=1 13 | -------------------------------------------------------------------------------- /src/command/list.rs: -------------------------------------------------------------------------------- 1 | use crate::newtypes::HoardName; 2 | 3 | #[allow(single_use_lifetimes)] 4 | #[tracing::instrument(skip_all)] 5 | pub(crate) fn run_list<'a>(hoard_names_iter: impl IntoIterator) { 6 | let mut hoards: Vec<_> = hoard_names_iter.into_iter().map(AsRef::as_ref).collect(); 7 | hoards.sort_unstable(); 8 | let list = hoards.join("\n"); 9 | tracing::info!("{}", list); 10 | } 11 | -------------------------------------------------------------------------------- /book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Michael Bryant"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "The Hoard Book" 7 | description = "The official documentation for the Hoard dotfile manager" 8 | 9 | [rust] 10 | edition = "2021" 11 | 12 | [output.html] 13 | preferred-dark-theme = "ayu" 14 | mathjax-support = false 15 | cname = "hoard.shadow53.dev" 16 | edit-url-template = "https://github.com/Shadow53/hoard/edit/main/book/{path}" 17 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | doc-valid-idents = [ 2 | "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "DirectX", "ECMAScript", "GPLv2", "GPLv3", "GitHub", "GitLab", "IPv4", 3 | "IPv6", "ClojureScript", "CoffeeScript", "JavaScript", "PureScript", "TypeScript", "NaN", "NaNs", "OAuth", 4 | "GraphQL", "OCaml", "OpenGL", "OpenMP", "OpenSSH", "OpenSSL", "OpenStreetMap", "OpenDNS", "WebGL", "TensorFlow", 5 | "TrueType", "iOS", "macOS", "TeX", "LaTeX", "BibTeX", "BibLaTeX", "MinGW", "CamelCase", "FreeBSD" 6 | ] -------------------------------------------------------------------------------- /src/command/cleanup.rs: -------------------------------------------------------------------------------- 1 | use crate::checkers::history::operation::cleanup_operations; 2 | 3 | #[tracing::instrument] 4 | pub(crate) async fn run_cleanup() -> Result<(), super::Error> { 5 | match cleanup_operations().await { 6 | Ok(count) => { 7 | tracing::info!("cleaned up {} log files", count); 8 | Ok(()) 9 | } 10 | Err((count, error)) => Err(super::Error::Cleanup { 11 | success_count: count, 12 | error, 13 | }), 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /book/src/config/README.md: -------------------------------------------------------------------------------- 1 | # Configuration File 2 | 3 | This section describes the configuration file structure, with examples in TOML. 4 | 5 | > **NOTE:** Hoard supports YAML as well as TOML, for those who prefer the former format. This guide 6 | > assumes that those using YAML already know the format and expects that said users are able to 7 | > translate from TOML examples to YAML for their own configurations. 8 | > 9 | > All other users should use TOML, as it is the default format and the one used by the author. 10 | -------------------------------------------------------------------------------- /src/hoard_item/mod.rs: -------------------------------------------------------------------------------- 1 | //! Types for working with files that are managed by Hoard. 2 | //! 3 | //! [`HoardItem`] manages only the related paths. All checks for existence, content, etc. are done 4 | //! in the methods that return the value. 5 | //! 6 | //! [`CachedHoardItem`] reads all of the relevant information at creation time and returns cached 7 | //! values for content, etc. It provides the same interface as [`HoardItem`]. 8 | 9 | mod cached; 10 | #[allow(clippy::module_inception)] 11 | mod hoard_item; 12 | 13 | pub use hoard_item::HoardItem; 14 | 15 | #[allow(clippy::useless_attribute)] 16 | #[allow(clippy::module_name_repetitions)] 17 | pub use cached::CachedHoardItem; 18 | -------------------------------------------------------------------------------- /src/command/upgrade.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | use crate::checkers::history::operation::util::upgrade_operations; 4 | use crate::checkers::history::operation::Error as OperationError; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum Error { 8 | #[error("failed to upgrade operation logs: {0}")] 9 | Operations(OperationError), 10 | } 11 | 12 | #[tracing::instrument] 13 | pub(crate) async fn run_upgrade() -> Result<(), super::Error> { 14 | tracing::info!("Upgrading operation logs to the latest format..."); 15 | upgrade_operations() 16 | .await 17 | .map_err(Error::Operations) 18 | .map_err(super::Error::Upgrade)?; 19 | tracing::info!("Successfully upgraded all operation logs"); 20 | Ok(()) 21 | } 22 | -------------------------------------------------------------------------------- /tests/common/toml.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | use std::path::Path; 3 | use tokio::fs; 4 | 5 | pub use ::toml::*; 6 | use serde::de::DeserializeOwned; 7 | 8 | pub async fn assert_file_contains_deserializable(path: &Path, expected: &T) 9 | where 10 | T: PartialEq + Debug + DeserializeOwned, 11 | { 12 | let content_str = fs::read_to_string(path).await.unwrap_or_else(|err| { 13 | panic!( 14 | "failed to read from file at {}: {}", 15 | path.to_string_lossy(), 16 | err 17 | ) 18 | }); 19 | 20 | let content: T = from_str(&content_str).expect("failed to deserialize file contents"); 21 | 22 | assert_eq!( 23 | expected, &content, 24 | "file contents do not match expected contents\nDeserialized from: {content_str}" 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /book/src/getting-started/initial-setup.md: -------------------------------------------------------------------------------- 1 | # Initial Setup 2 | 3 | Hoard v0.5.2 added the `init` subcommand to create the folders and files necessary for Hoard to run. 4 | 5 | > This command only needs to be run the first time you set Hoard up. After that, including on new 6 | > machines, it is enough to synchronize the [hoard data directory][hoard-data-dir] to the new 7 | > machine and setup or restore the [configuration file][hoard-config-file]. 8 | 9 | ## Initializing Hoard 10 | 11 | Run `hoard init`. Everything necessary will be created, including a sample configuration file. 12 | 13 | Then, run `hoard edit` to [edit the new configuration file][edit-config-file]. 14 | 15 | [hoard-data-dir]: ../file-locations.md#hoard-data-directroy 16 | [hoard-config-file]: ../file-locations.md#config-file 17 | [edit-config-file]: ./create-config/ 18 | -------------------------------------------------------------------------------- /knope.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | versioned_files = ["Cargo.toml"] 3 | changelog = "CHANGELOG.md" 4 | 5 | [[workflows]] 6 | name = "release" 7 | 8 | [[workflows.steps]] 9 | type = "PrepareRelease" 10 | 11 | [[workflows.steps]] 12 | type = "Command" 13 | command = "cargo update -w" 14 | 15 | [[workflows.steps]] 16 | type = "Command" 17 | command = "git add Cargo.lock" 18 | 19 | [[workflows.steps]] 20 | type = "Command" 21 | command = "git commit -m \"chore: prepare release $version\"" 22 | 23 | [workflows.steps.variables] 24 | "$version" = "Version" 25 | 26 | [[workflows.steps]] 27 | type = "Command" 28 | command = "git push" 29 | 30 | [[workflows.steps]] 31 | type = "Release" 32 | 33 | [[workflows.steps]] 34 | type = "Command" 35 | command = "cargo publish --token \"${CARGO_TOKEN}\"" 36 | 37 | [github] 38 | owner = "Shadow53" 39 | repo = "hoard" 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch 5 | 6 | jobs: 7 | create-release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | - uses: actions-rs/toolchain@v1 15 | with: 16 | toolchain: stable 17 | override: true 18 | - name: Install Knope 19 | uses: knope-dev/action@v2.1.0 20 | - uses: Swatinem/rust-cache@v2 21 | - run: | 22 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 23 | git config --global user.name "github-actions[bot]" 24 | knope release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.KNOPE_TOKEN }} 27 | CARGO_TOKEN: ${{ secrets.CARGO_TOKEN }} 28 | -------------------------------------------------------------------------------- /src/hoard/iter/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module provides async streams of hoard-managed files and associated information. 2 | 3 | use thiserror::Error; 4 | 5 | pub use all_files::all_files_stream; 6 | pub use diff_files::{changed_diff_only_stream, diff_stream, DiffSource, HoardFileDiff}; 7 | pub use operation::operation_stream; 8 | 9 | use crate::checkers::history::operation::Error as OperationError; 10 | 11 | mod all_files; 12 | mod diff_files; 13 | mod operation; 14 | 15 | /// Errors that may occur while using a stream. 16 | #[derive(Debug, Error)] 17 | #[allow(variant_size_differences)] 18 | pub enum Error { 19 | /// Some I/O error occurred. 20 | #[error("I/O error occurred: {0}")] 21 | IO(#[from] tokio::io::Error), 22 | /// Error occurred while loading operation logs. 23 | #[error("failed to check hoard operations: {0}")] 24 | Operation(#[from] Box), 25 | } 26 | -------------------------------------------------------------------------------- /book/src/cli/checks.md: -------------------------------------------------------------------------------- 1 | # Pre-Operation Checks 2 | 3 | To help protect against accidentally overwriting or deleting files, `hoard` runs some consistency 4 | checks prior to running any operations. 5 | 6 | To skip running the checks, run `hoard` with the `--force` flag. There is not currently a way to disable 7 | individual checks. 8 | 9 | ## Last Paths 10 | 11 | This check compares the paths used previously with a given hoard to the ones resolved for the current 12 | operation. If any of these paths differ, a warning is displayed and the operation(s) canceled. 13 | 14 | ## Remote Operations 15 | 16 | By default, `hoard` logs information about successful operations to a directory that is intended to be 17 | synchronized with the main hoards directory. This information is used to determine if a given file was 18 | last modified by a remote system. If so, a warning is displayed and the operation(s) canceled. 19 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use hoard::Config; 2 | use tracing_subscriber::util::SubscriberInitExt; 3 | mod logging; 4 | 5 | fn error_and_exit(err: E) -> ! { 6 | // Ignore error if default subscriber already exists 7 | // This just helps ensure that logging happens and is 8 | // consistent. 9 | let _guard = logging::get_subscriber().set_default(); 10 | tracing::error!("{}", err); 11 | std::process::exit(1); 12 | } 13 | 14 | #[tokio::main] 15 | async fn main() { 16 | // Set up default logging 17 | // There is no obvious way to set up a default logging level in case the env 18 | // isn't set, so use this match thing instead. 19 | let _guard = logging::get_subscriber().set_default(); 20 | 21 | // Get configuration 22 | let config = match Config::load().await { 23 | Ok(config) => config, 24 | Err(err) => error_and_exit(err), 25 | }; 26 | 27 | // Run command with config 28 | if let Err(err) = config.run().await { 29 | error_and_exit(err); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./README.md) 4 | - [Terminology](./terminology.md) 5 | - [File Locations](./file-locations.md) 6 | 7 | - [Getting Started](./getting-started/README.md) 8 | - [Installation](./getting-started/installation.md) 9 | - [Initial Setup](./getting-started/initial-setup.md) 10 | - [Creating the Config File](getting-started/create-config/README.md) 11 | - [Hoard](getting-started/create-config/hoard.md) 12 | - [Vim and Neovim](getting-started/create-config/vim.md) 13 | - [*Mindustry* and *Death and Taxes*](getting-started/create-config/games.md) 14 | 15 | - [Command-Line Tool](./cli/README.md) 16 | - [Flags and Subcommands](./cli/flags-subcommands.md) 17 | - [Logging](./cli/logging.md) 18 | - [Checks](./cli/checks.md) 19 | 20 | - [Configuration File](./config/README.md) 21 | - [Environments](./config/environments.md) 22 | - [Environment Variables](./config/envvars.md) 23 | - [Hoards and Piles](./config/hoards-piles.md) 24 | 25 | - [File Permissions](./permissions.md) 26 | -------------------------------------------------------------------------------- /book/src/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Hoard! 2 | 3 | `hoard` is a program for backing up files from across a filesystem into a single directory 4 | and restoring them later. 5 | 6 | Most people will know these programs as "dotfile managers," where dotfiles are configuration 7 | files on *nix (read: non-Windows) systems. Files on *nix systems are marked as hidden by 8 | starting the file name with a dot (`.`). 9 | 10 | `hoard` aims to be a little more useful than other dotfile managers: 11 | 12 | 1. Many dotfile managers store files in a structure based on their path relative to the user's home directory. This is 13 | useful in most cases, but can cause problems when wanted to share files across systems that don't use the same paths, 14 | e.g., Windows and Linux. `hoard` instead namespaces files based on the ["Hoard" and "Pile"](./terminology.md) they 15 | are configured in, then relative to the root of the Pile. This makes it easy to backup and restore files to very 16 | different locations. 17 | 18 | 2. Most dotfile managers do not prevent you from accidentally destructive behavior. See [Checks](cli/checks.md) for more 19 | information. 20 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | 3 | use std::path::Path; 4 | use tokio::fs; 5 | use tokio::io::AsyncWriteExt; 6 | 7 | use rand::RngCore; 8 | use tempfile::NamedTempFile; 9 | 10 | pub mod base; 11 | pub mod file; 12 | pub mod test_subscriber; 13 | pub mod tester; 14 | pub mod toml; 15 | 16 | pub async fn create_random_file() -> NamedTempFile { 17 | let mut file = NamedTempFile::new().expect("failed to create temporary file"); 18 | create_file_with_random_data::(file.path()).await; 19 | file 20 | } 21 | 22 | pub async fn create_file_with_random_data(path: &Path) { 23 | if let Some(parent) = path.parent() { 24 | fs::create_dir_all(parent) 25 | .await 26 | .expect("failed to ensure parent directories"); 27 | } 28 | 29 | let mut content = [0; SIZE]; 30 | rand::thread_rng().fill_bytes(&mut content); 31 | fs::write(path, content) 32 | .await 33 | .expect("failed to write random data to file"); 34 | } 35 | 36 | #[derive(Copy, Clone, Hash, PartialEq, Eq)] 37 | pub enum UuidLocation { 38 | Local, 39 | Remote, 40 | } 41 | -------------------------------------------------------------------------------- /tests/hoard_init.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::tester::Tester; 4 | use hoard::command::Command; 5 | use tokio::fs; 6 | 7 | #[tokio::test] 8 | async fn test_hoard_init() { 9 | let tester = Tester::new("").await; 10 | 11 | fs::remove_dir(tester.config_dir()) 12 | .await 13 | .expect("should have deleted hoard config dir"); 14 | if tester.data_dir().exists() { 15 | // config dir and data dir are the same on macos 16 | fs::remove_dir(tester.data_dir()) 17 | .await 18 | .expect("should have deleted hoard data dir"); 19 | } 20 | 21 | assert!( 22 | !tester.config_dir().exists(), 23 | "hoard config directory should not exist" 24 | ); 25 | assert!( 26 | !tester.data_dir().exists(), 27 | "hoard data directory should not exist" 28 | ); 29 | 30 | tester 31 | .run_command(Command::Init) 32 | .await 33 | .expect("initialization should succeed"); 34 | 35 | assert!( 36 | tester.config_dir().exists(), 37 | "hoard config directory should exist" 38 | ); 39 | assert!( 40 | tester.data_dir().exists(), 41 | "hoard data directory should exist" 42 | ); 43 | 44 | let config_file = tester.config_dir().join("config.toml"); 45 | 46 | assert!(config_file.exists(), "config file should have been created"); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/publish-mdbook.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: ["main"] 4 | pull_request: 5 | paths: ["/book"] 6 | 7 | name: Publish mdBook 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install mdBook 15 | uses: actions-rs/install@v0.1 16 | with: 17 | crate: mdbook 18 | version: latest 19 | use-tool-cache: true 20 | - name: Build mdBook 21 | run: mdbook build book 22 | - name: Publish to Netlify (Stable) 23 | if: ${{ github.ref == 'refs/heads/main' }} 24 | uses: nwtgck/actions-netlify@v3 25 | with: 26 | publish-dir: book/book 27 | production-branch: main 28 | production-deploy: true 29 | env: 30 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 31 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 32 | - name: Publish to Netlify (Preview) 33 | if: ${{ github.event_name == 'pull_request' }} 34 | uses: nwtgck/actions-netlify@v3 35 | with: 36 | publish-dir: book/book 37 | alias: ${{ github.head_ref }} 38 | production-branch: main 39 | production-deploy: false 40 | env: 41 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 42 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 43 | -------------------------------------------------------------------------------- /tests/config_precedence_conflict.rs: -------------------------------------------------------------------------------- 1 | use hoard::config::builder::envtrie::Error as TrieError; 2 | use hoard::config::builder::hoard::Error as HoardError; 3 | use hoard::config::builder::{Builder, Error as BuildError}; 4 | use maplit::hashset; 5 | 6 | const CONFIG: &str = r#" 7 | # Both foo and baz are preferred to bar 8 | exclusivity = [ 9 | ["foo", "bar"], 10 | ["baz", "bar"], 11 | ] 12 | 13 | # CARGO should be set when running tests 14 | [envs.bar] 15 | env = [{ var = "CARGO" }] 16 | [envs.baz] 17 | env = [{ var = "CARGO" }] 18 | [envs.foo] 19 | env = [{ var = "CARGO" }] 20 | 21 | # Two unrelated envs that do not conflict with each other 22 | # should have the same score and cause these paths to conflict. 23 | [hoards.test] 24 | "foo" = "/some/path" 25 | "baz" = "/some/other/path" 26 | "#; 27 | 28 | #[test] 29 | fn test_results_in_indecision() { 30 | let builder: Builder = toml::from_str(CONFIG).expect("parsing toml"); 31 | let err = builder.build().expect_err("determining paths should fail"); 32 | match err { 33 | BuildError::ProcessHoard(HoardError::EnvTrie(err)) => match err { 34 | TrieError::Indecision(left, right) => assert_eq!( 35 | hashset! { left, right }, 36 | hashset! { "foo".parse().unwrap(), "baz".parse().unwrap() } 37 | ), 38 | _ => panic!("Unexpected error: {err}"), 39 | }, 40 | _ => panic!("Unexpected error: {err}"), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Michael Bryant (Shadow53). All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 4 | following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 7 | disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote 13 | products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 16 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 18 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 20 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 21 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /book/src/config/envvars.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | ## Default Values 4 | 5 | You may define default values for environment variables, should they not be set when `hoard` is run. If the variable 6 | *is* set, the default value is ignored. Default values may include [interpolated](#interpolation) values of other 7 | environment variables, including other variables with assigned defaults. 8 | 9 | > Make sure there are no cyclical value definitions, as these will cause errors. 10 | > 11 | > ```toml 12 | > [defaults] 13 | > # This will never resolve 14 | > "SELF_CYCLICAL" = "I am ${SELF_CYCLICAL}" 15 | > # These will cause errors if both are undefined, but 16 | > # the errors will not be apparent if one is defined. 17 | > "MUTUALLY_CYCLICAL_1" = "I'm the sibling of ${MUTUTALLY_CYCLICAL_2}" 18 | > "MUTUALLY_CYCLICAL_2" = "I'm the sibling of ${MUTUALLY_CYCLICAL_1}" 19 | > ``` 20 | 21 | ### Examples 22 | 23 | This example sets `$XDG_CONFIG_HOME` and `$XDG_DATA_HOME`, two variables that are commonly used on Unix-y systems to 24 | determine where application configuration and data files should be kept. 25 | 26 | ```toml 27 | [defaults] 28 | XDG_CONFIG_HOME = "${HOME}/.config" 29 | XDG_DATA_HOME = "${HOME}/.local/share" 30 | ``` 31 | 32 | ## Interpolation 33 | 34 | Environment variables may be interpolated into certain parts of the configuration file. Namely, 35 | 36 | - [Hoard/Pile paths](./hoards-piles.md#environment-variables) 37 | - [Environment variable default values](#default-values) 38 | 39 | Interpolate a variable using `${VAR}`, where `VAR` is the name of the variable. See the above links for specific 40 | examples. -------------------------------------------------------------------------------- /book/src/permissions.md: -------------------------------------------------------------------------------- 1 | # File Permissions in Hoard 2 | 3 | Hoard supports the three most popular desktop operating systems: Windows, macOS, and Linux. 4 | One of these uses a very different implementation of file permissions compared to the others 5 | -- this is why Rust only provides one bit of support for all platforms: whether something is 6 | [readonly](https://doc.rust-lang.org/stable/std/fs/struct.Permissions.html) or not. 7 | 8 | Previous versions of Hoard ignored this and just hoped things would stay consistent. With the 9 | release of 0.5.0, though, Hoard added support for setting file permissions on restore. 10 | 11 | ## Configuration 12 | 13 | As of 0.5.0, Hoard supports setting [configurable permissions](config/hoards-piles.md#file-permissions) 14 | on files and folders on a `hoard restore`. 15 | 16 | ## When Permissions Are Set 17 | 18 | Permissions are set on both backup and restore. 19 | 20 | > Note: discussion of what permissions are set only apply to Unix-like systems, as Windows only 21 | > supports `readonly`, which always defaults to `false` for the owning user. 22 | 23 | ### Backing Up 24 | 25 | When backing up files, all files are given a mode of `0600` and all folders are given a mode of `0700`, 26 | i.e., owner-only access. This is done to provide a little extra filesystem-based security, since the 27 | permissions in the Hoard do not affect the permissions given on restore. 28 | 29 | ### Restoring 30 | 31 | When restoring files, all files and folders are given the permissions specified in the most-specific 32 | parent pile config. That is, the usual precedence holds, and permissions are not merged. 33 | 34 | If no permissions are configured, the defaults are `0600` for files and `0700` for folders. -------------------------------------------------------------------------------- /tests/fake_editors/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | 3 | #[cfg(windows)] 4 | mod windows; 5 | 6 | #[cfg(unix)] 7 | mod unix; 8 | 9 | #[cfg(unix)] 10 | use unix as sys; 11 | 12 | #[cfg(windows)] 13 | use self::windows as sys; 14 | 15 | pub use sys::EditorGuard; 16 | 17 | #[derive(Copy, Clone, PartialEq, Eq, Hash)] 18 | pub enum Editor { 19 | Good, 20 | Error, 21 | } 22 | 23 | impl Editor { 24 | #[cfg(unix)] 25 | pub const fn file_content(&self) -> &'static str { 26 | match self { 27 | Editor::Good => include_str!("fake-editor.sh"), 28 | Editor::Error => include_str!("fake-error-editor.sh"), 29 | } 30 | } 31 | 32 | #[cfg(windows)] 33 | pub const fn file_content(&self) -> &'static str { 34 | match self { 35 | Editor::Good => include_str!("fake-editor.ps1"), 36 | Editor::Error => include_str!("fake-error-editor.ps1"), 37 | } 38 | } 39 | 40 | #[inline] 41 | pub fn is_good(&self) -> bool { 42 | matches!(self, Editor::Good) 43 | } 44 | 45 | #[inline] 46 | pub fn is_bad(&self) -> bool { 47 | matches!(self, Editor::Error) 48 | } 49 | 50 | pub async fn set_as_default_cli_editor(&self) -> sys::EditorGuard { 51 | sys::set_default_cli_editor(*self).await 52 | } 53 | 54 | pub async fn set_as_default_gui_editor(&self) -> sys::EditorGuard { 55 | std::env::remove_var("EDITOR"); 56 | // xdg-open tries to open the file in a browser if the editor command does not 57 | // return success. This will cause it to short circuit for testing purposes. 58 | std::env::set_var("BROWSER", ":"); 59 | sys::set_default_gui_editor(*self).await 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/filters/mod.rs: -------------------------------------------------------------------------------- 1 | //! Provides filters for determining whether a path should be backed up or not. 2 | 3 | use crate::hoard::PileConfig; 4 | use crate::paths::{RelativePath, SystemPath}; 5 | 6 | pub(crate) mod ignore; 7 | 8 | /// The [`Filter`] trait provides a common interface for all filters. 9 | pub trait Filter: Sized { 10 | /// Creates a new instance of something that implements [`Filter`]. 11 | /// 12 | /// # Errors 13 | /// 14 | /// Any errors that may occur while creating the new filter. 15 | fn new(pile_config: &PileConfig) -> Self; 16 | /// Whether or not the file should be kept (backed up). 17 | fn keep(&self, prefix: &SystemPath, path: &RelativePath) -> bool; 18 | } 19 | 20 | /// A wrapper for all implmented filters. 21 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] 22 | pub struct Filters { 23 | ignore: ignore::IgnoreFilter, 24 | } 25 | 26 | impl Filter for Filters { 27 | #[tracing::instrument] 28 | fn new(pile_config: &PileConfig) -> Self { 29 | let ignore = ignore::IgnoreFilter::new(pile_config); 30 | Self { ignore } 31 | } 32 | 33 | #[tracing::instrument(name = "run_filters")] 34 | fn keep(&self, prefix: &SystemPath, path: &RelativePath) -> bool { 35 | self.ignore.keep(prefix, path) 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests { 41 | use super::*; 42 | 43 | #[test] 44 | fn test_filters_derives() { 45 | let config = PileConfig { 46 | ignore: vec![glob::Pattern::new("valid/**").unwrap()], 47 | ..PileConfig::default() 48 | }; 49 | let filters = Filters::new(&config); 50 | assert!(format!("{filters:?}").contains("Filters")); 51 | assert_eq!(filters.clone().ignore, filters.ignore); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hoard 2 | 3 | [![Netlify Status](https://api.netlify.com/api/v1/badges/b91e71ce-673e-466c-a6ff-2b877ec0dd97/deploy-status)](https://app.netlify.com/sites/hoard-docs/deploys) 4 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FShadow53%2Fhoard.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FShadow53%2Fhoard?ref=badge_shield) 5 | 6 | `hoard` is a program for backing up files from across a filesystem into a single directory 7 | and restoring them later. 8 | 9 | Most people will know these programs as "dotfile managers," where dotfiles are configuration 10 | files on *nix (read: non-Windows) systems. Files on *nix systems are marked as hidden by 11 | starting the file name with a dot (`.`). 12 | 13 | ## Documentation 14 | 15 | You can find all documentation at https://hoard.rs. 16 | 17 | ## Configuration 18 | 19 | See [`config.toml.sample`](config.toml.sample) for a documented example configuration file. 20 | 21 | ## Testing 22 | 23 | Hoard's runtime behavior depends on environment variables, which the tests override to prevent polluting the developer's 24 | system and/or home directory. Because of this, tests must be run in one of two ways: 25 | 26 | 1. Single-threaded, using `cargo make test-single-thread` or `cargo test -- --test-threads=1`. 27 | 2. As separate processes with their own environments, using `cargo make test-nextest` or `cargo nextest run`. 28 | - `cargo-make` should install the dependency automatically. Otherwise, run `cargo install cargo-nextest`. 29 | 30 | Tests can also be run in a container using `cargo make docker-tests`. 31 | 32 | ## License 33 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FShadow53%2Fhoard.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FShadow53%2Fhoard?ref=badge_large) 34 | -------------------------------------------------------------------------------- /src/newtypes/mod.rs: -------------------------------------------------------------------------------- 1 | //! Newtypes used to enforce invariants throughout this library. 2 | //! 3 | //! - Names (`*Name`) must contain only alphanumeric characters, dash (`-`), or underscore (`_`). 4 | //! - [`EnvironmentString`] has its own requirements. 5 | 6 | use thiserror::Error; 7 | 8 | pub use environment_name::EnvironmentName; 9 | pub use environment_string::EnvironmentString; 10 | pub use hoard_name::HoardName; 11 | pub use non_empty_pile_name::NonEmptyPileName; 12 | pub use pile_name::PileName; 13 | 14 | mod environment_name; 15 | mod environment_string; 16 | mod hoard_name; 17 | mod non_empty_pile_name; 18 | mod pile_name; 19 | 20 | /// Errors that may occur while creating an instance of one of this newtypes. 21 | #[derive(Debug, Error, PartialEq, Eq)] 22 | pub enum Error { 23 | /// The given string contains disallowed characters. 24 | #[error("invalid name \"{0}\": must contain only alphanumeric characters, '-', '_', or '.'")] 25 | DisallowedCharacters(String), 26 | /// The given string is a disallowed name. 27 | #[error("name \"{0}\" is not allowed")] 28 | DisallowedName(String), 29 | /// The given string was empty, which is not allowed. 30 | #[error("name cannot be empty (null, None, or the empty string)")] 31 | EmptyName, 32 | } 33 | 34 | const DISALLOWED_NAMES: [&str; 2] = ["", "config"]; 35 | 36 | #[tracing::instrument(level = "trace")] 37 | fn validate_name(name: String) -> Result { 38 | if !name 39 | .chars() 40 | .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') 41 | { 42 | return crate::create_log_error(Error::DisallowedCharacters(name)); 43 | } 44 | 45 | if DISALLOWED_NAMES 46 | .iter() 47 | .any(|disallowed| &name == disallowed) 48 | { 49 | return crate::create_log_error(Error::DisallowedName(name)); 50 | } 51 | 52 | Ok(name) 53 | } 54 | -------------------------------------------------------------------------------- /src/dirs/unix.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use super::{path_from_env, PROJECT}; 4 | #[cfg(target_os = "macos")] 5 | use super::{COMPANY, TLD}; 6 | 7 | #[tracing::instrument(level = "trace")] 8 | fn xdg_config_dir() -> Option { 9 | path_from_env("XDG_CONFIG_HOME").map(|path| path.join(PROJECT)) 10 | } 11 | 12 | #[tracing::instrument(level = "trace")] 13 | fn xdg_data_dir() -> Option { 14 | path_from_env("XDG_DATA_HOME").map(|path| path.join(PROJECT)) 15 | } 16 | 17 | #[must_use] 18 | #[tracing::instrument(level = "trace")] 19 | pub(super) fn home_dir() -> PathBuf { 20 | path_from_env("HOME").expect("could not determine user home directory") 21 | } 22 | 23 | #[cfg(target_os = "macos")] 24 | #[tracing::instrument(level = "trace")] 25 | fn mac_config_dir() -> PathBuf { 26 | tracing::trace!("using macos-specific config/data directory"); 27 | home_dir() 28 | .join("Library") 29 | .join("Application Support") 30 | .join(format!("{}.{}.{}", TLD, COMPANY, PROJECT)) 31 | } 32 | 33 | #[cfg(target_os = "macos")] 34 | #[must_use] 35 | #[tracing::instrument(level = "trace")] 36 | pub(super) fn config_dir() -> PathBuf { 37 | xdg_config_dir().unwrap_or_else(mac_config_dir) 38 | } 39 | 40 | #[cfg(target_os = "macos")] 41 | #[must_use] 42 | #[tracing::instrument(level = "trace")] 43 | pub(super) fn data_dir() -> PathBuf { 44 | xdg_data_dir().unwrap_or_else(mac_config_dir) 45 | } 46 | 47 | #[cfg(not(target_os = "macos"))] 48 | #[must_use] 49 | #[tracing::instrument(level = "trace")] 50 | pub(super) fn config_dir() -> PathBuf { 51 | xdg_config_dir().unwrap_or_else(|| { 52 | tracing::trace!("using fallback config directory"); 53 | home_dir().join(".config").join(PROJECT) 54 | }) 55 | } 56 | 57 | #[cfg(not(target_os = "macos"))] 58 | #[must_use] 59 | #[tracing::instrument(level = "trace")] 60 | pub(super) fn data_dir() -> PathBuf { 61 | xdg_data_dir().unwrap_or_else(|| { 62 | tracing::trace!("using fallback data directory"); 63 | home_dir().join(".local").join("share").join(PROJECT) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/command/init.rs: -------------------------------------------------------------------------------- 1 | use crate::checkers::history::{get_or_generate_uuid, get_uuid_file}; 2 | use crate::Config; 3 | use tokio::fs; 4 | 5 | use super::DEFAULT_CONFIG; 6 | 7 | #[tracing::instrument(skip_all)] 8 | pub(crate) async fn run_init(config: &Config) -> Result<(), super::Error> { 9 | let data_dir = crate::paths::hoards_dir(); 10 | let config_file = config.config_file.as_path(); 11 | 12 | tracing::info!("creating data directory: {}", data_dir.display()); 13 | fs::create_dir_all(&data_dir) 14 | .await 15 | .map_err(|error| super::Error::Init { 16 | path: data_dir.to_path_buf(), 17 | error, 18 | })?; 19 | 20 | if let Some(parent) = config_file.parent() { 21 | tracing::info!("creating config directory: {}", parent.display()); 22 | fs::create_dir_all(parent) 23 | .await 24 | .map_err(|error| super::Error::Init { 25 | path: parent.to_path_buf(), 26 | error, 27 | })?; 28 | } 29 | 30 | let uuid_file = get_uuid_file(); 31 | if !uuid_file.exists() { 32 | tracing::info!("device id not found, creating a new one"); 33 | get_or_generate_uuid() 34 | .await 35 | .map_err(|error| super::Error::Init { 36 | path: uuid_file, 37 | error, 38 | })?; 39 | } 40 | 41 | if !config_file.exists() { 42 | tracing::info!( 43 | "no configuration file found, creating default at {}", 44 | config_file.display() 45 | ); 46 | fs::write(config_file, DEFAULT_CONFIG) 47 | .await 48 | .map_err(|error| super::Error::Init { 49 | path: config_file.to_path_buf(), 50 | error, 51 | })?; 52 | } 53 | 54 | tracing::info!( 55 | "If you want to synchronize hoards between multiple machines, synchronize {}", 56 | data_dir.display() 57 | ); 58 | tracing::info!("To synchronize your Hoard configuration as well, add an entry that backs up {}, not the whole directory", config_file.display()); 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/filters/ignore.rs: -------------------------------------------------------------------------------- 1 | /// Provides a [`Filter`] based on glob ignore patterns. 2 | /// 3 | /// To use this filter, add an list of glob patterns to `ignore` under `config`. For example: 4 | /// 5 | /// ```ignore 6 | /// [config] 7 | /// ignore = ["some*glob"] 8 | /// ``` 9 | /// 10 | /// This can be put under global, hoard, or pile scope. 11 | use glob::Pattern; 12 | 13 | use crate::hoard::PileConfig; 14 | use crate::paths::{RelativePath, SystemPath}; 15 | 16 | use super::Filter; 17 | 18 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] 19 | pub(crate) struct IgnoreFilter { 20 | globs: Vec, 21 | } 22 | 23 | impl Filter for IgnoreFilter { 24 | fn new(pile_config: &PileConfig) -> Self { 25 | IgnoreFilter { 26 | globs: pile_config.ignore.clone(), 27 | } 28 | } 29 | 30 | #[tracing::instrument(name = "run_ignore_filter", skip(self, _prefix))] 31 | fn keep(&self, _prefix: &SystemPath, rel_path: &RelativePath) -> bool { 32 | self.globs.iter().all(|glob| { 33 | let matches = glob.matches_path(&rel_path.to_path_buf()); 34 | tracing::trace!( 35 | "{:?} {} glob {:?}", 36 | rel_path, 37 | if matches { "matches" } else { "does not match" }, 38 | glob 39 | ); 40 | !matches 41 | }) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | 49 | #[test] 50 | fn test_filter_derives() { 51 | let filter = { 52 | let config = PileConfig { 53 | ignore: vec![Pattern::new("testing/**").unwrap()], 54 | ..PileConfig::default() 55 | }; 56 | IgnoreFilter::new(&config) 57 | }; 58 | let other = { 59 | let config = PileConfig { 60 | ignore: vec![Pattern::new("test/**").unwrap()], 61 | ..PileConfig::default() 62 | }; 63 | IgnoreFilter::new(&config) 64 | }; 65 | assert!(format!("{filter:?}").contains("IgnoreFilter")); 66 | assert_eq!(filter, filter.clone()); 67 | assert_ne!(filter, other); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/common/test_subscriber.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io, 3 | ops::Deref, 4 | sync::{Arc, Mutex, MutexGuard}, 5 | }; 6 | use tracing::dispatcher::DefaultGuard; 7 | use tracing_subscriber::{fmt::MakeWriter, util::SubscriberInitExt}; 8 | 9 | #[derive(Clone)] 10 | pub struct MakeMemoryWriter { 11 | buffer: Arc>>, 12 | } 13 | 14 | pub struct GuardWrapper<'a>(MutexGuard<'a, Vec>); 15 | 16 | impl<'a> io::Write for GuardWrapper<'a> { 17 | fn write(&mut self, buf: &[u8]) -> io::Result { 18 | self.0.write(buf) 19 | } 20 | 21 | fn flush(&mut self) -> io::Result<()> { 22 | self.0.flush() 23 | } 24 | } 25 | 26 | impl<'a> MakeWriter<'a> for MakeMemoryWriter { 27 | type Writer = GuardWrapper<'a>; 28 | fn make_writer(&'a self) -> Self::Writer { 29 | self.buffer 30 | .lock() 31 | .map(GuardWrapper) 32 | .expect("memory writer mutex was poisoned") 33 | } 34 | } 35 | 36 | impl MakeMemoryWriter { 37 | fn clear(&self) { 38 | self.buffer 39 | .lock() 40 | .expect("memory writer lock was poisoned") 41 | .clear(); 42 | } 43 | } 44 | 45 | pub struct MemorySubscriber { 46 | writer: MakeMemoryWriter, 47 | guard: DefaultGuard, 48 | } 49 | 50 | impl MemorySubscriber { 51 | pub fn new(log_level: tracing::Level) -> Self { 52 | ::std::env::set_var("HOARD_LOG", log_level.to_string()); 53 | let writer = MakeMemoryWriter { 54 | buffer: Arc::new(Mutex::new(Vec::new())), 55 | }; 56 | let subscriber = hoard::logging::get_subscriber() 57 | .with_writer(writer.clone()) 58 | .finish(); 59 | let guard = subscriber.set_default(); 60 | MemorySubscriber { writer, guard } 61 | } 62 | 63 | pub fn output(&'_ self) -> impl Deref> + '_ { 64 | self.writer 65 | .buffer 66 | .lock() 67 | .expect("memory writer lock was poisoned") 68 | } 69 | 70 | pub fn clear(&self) { 71 | self.writer.clear(); 72 | } 73 | } 74 | 75 | impl Default for MemorySubscriber { 76 | fn default() -> Self { 77 | Self::new(tracing::Level::INFO) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /book/src/config/environments.md: -------------------------------------------------------------------------------- 1 | # Environments 2 | 3 | The path used for a given pile depends on the best matching environment(s) for a configured path. This page 4 | discusses how to define environments. For how to use them with hoards/piles, see 5 | [Hoards and Piles](hoards-piles.md). 6 | 7 | Environments can be matched on one or more of five possible factors: 8 | 9 | - `os`: [Operating System](https://doc.rust-lang.org/stable/std/env/consts/constant.OS.html) 10 | - `env`: Environment variables 11 | - Can match on just existence or also a specific value. 12 | - `hostname`: The system hostname. 13 | - `exe_exists`: Whether an executable file exists in `$PATH`. 14 | - `path_exists`: Whether something exists (one of) the given path(s). 15 | 16 | All the above factors can be written using two-dimensional array syntax. That is, 17 | `["foo", ["bar, "baz"]]` is interpreted as `(foo) OR (bar AND baz)`, in whatever way applies 18 | to that given factor. 19 | 20 | It is an error to include an `AND` condition for `os` or `hostname`, as a system can only have 21 | one of each. 22 | 23 | ```toml 24 | [envs] 25 | [envs.example_env] 26 | # Matching something *nix-y 27 | os = ["linux", "freebsd"] 28 | # Either sed and sh, or bash, must exist 29 | exe_exists = ["bash", ["sh", "sed"]] 30 | # Require both $HOME to exist and $HOARD_EXAMPLE_ENV to equal YES. 31 | # Note the double square brackets that indicate AND instead of OR. 32 | env = [[ 33 | { var = "HOME" }, 34 | { var = "HOARD_EXAMPLE_ENV", expected = "YES" }, 35 | ]] 36 | ``` 37 | 38 | ## Exclusivity 39 | 40 | The exclusivity lists indicate names of environments that are considered mutually exclusive to 41 | each other -- that is, cannot appear in the same environment condition -- and the order indicates 42 | which one(s) have precedence when matching environments. 43 | 44 | See the [example config file][example config] for a more thorough example. 45 | 46 | [example config]: https://github.com/Shadow53/hoard/tree/main/config.toml.sample 47 | 48 | ```toml 49 | exclusivity = [ 50 | # Assuming all else the same, an environment condition string with "neovim" will take 51 | # precedence over one with "vim", which takes precedence over one with "emacs". 52 | ["neovim", "vim", "emacs"] 53 | ] 54 | ``` 55 | -------------------------------------------------------------------------------- /tests/ignore_filter.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::base::DefaultConfigTester; 4 | use hoard::command::Command; 5 | use std::path::PathBuf; 6 | use tokio::fs; 7 | 8 | const GLOBAL_FILE: &str = "global_ignore"; 9 | const HOARD_FILE: &str = "ignore_for_hoard"; 10 | const PILE_FILE: &str = "spilem"; 11 | const NESTED_FILE: &str = "nested_dir/.hidden"; 12 | 13 | fn ignored_files(tester: &DefaultConfigTester) -> Vec { 14 | vec![ 15 | tester.home_dir().join("first_anon_dir").join(GLOBAL_FILE), 16 | tester.home_dir().join("first_named_dir1").join(GLOBAL_FILE), 17 | tester.home_dir().join("first_named_dir2").join(GLOBAL_FILE), 18 | tester.home_dir().join("first_named_dir1").join(HOARD_FILE), 19 | tester.home_dir().join("first_named_dir2").join(HOARD_FILE), 20 | tester.home_dir().join("first_named_dir1").join(PILE_FILE), 21 | tester.home_dir().join("first_named_dir2").join(NESTED_FILE), 22 | ] 23 | } 24 | 25 | fn all_extra_files(tester: &DefaultConfigTester) -> Vec { 26 | ["first_anon_dir", "first_named_dir1", "first_named_dir2"] 27 | .into_iter() 28 | .flat_map(|slug| { 29 | vec![ 30 | tester.home_dir().join(slug).join("global_ignore"), 31 | tester.home_dir().join(slug).join("ignore_for_hoard"), 32 | tester.home_dir().join(slug).join("spilem"), 33 | tester 34 | .home_dir() 35 | .join(slug) 36 | .join("nested_dir") 37 | .join(".hidden"), 38 | ] 39 | }) 40 | .collect() 41 | } 42 | 43 | #[tokio::test] 44 | async fn test_ignore_filter() { 45 | let mut tester = DefaultConfigTester::new().await; 46 | tester.setup_files().await; 47 | tester.use_first_env(); 48 | 49 | for home in all_extra_files(&tester) { 50 | common::create_file_with_random_data::<2048>(&home).await; 51 | } 52 | 53 | tester 54 | .expect_command(Command::Backup { hoards: Vec::new() }) 55 | .await; 56 | 57 | // Delete ignored files from home so assertion works 58 | for home in ignored_files(&tester) { 59 | fs::remove_file(&home) 60 | .await 61 | .expect("failed to remove ignored file"); 62 | } 63 | 64 | tester.assert_first_tree().await; 65 | } 66 | -------------------------------------------------------------------------------- /src/config/builder/environment/hostname.rs: -------------------------------------------------------------------------------- 1 | //! See [`Hostname`]. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::TryInto; 5 | use std::fmt; 6 | use std::fmt::Formatter; 7 | use tap::TapFallible; 8 | 9 | /// A conditional structure that compares the system's hostname to the given string. 10 | #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)] 11 | #[serde(transparent)] 12 | pub struct Hostname(pub String); 13 | 14 | impl TryInto for Hostname { 15 | type Error = super::Error; 16 | 17 | fn try_into(self) -> Result { 18 | let Hostname(expected) = self; 19 | let host = hostname::get() 20 | .map_err(super::Error::Hostname) 21 | .tap_err(crate::tap_log_error)?; 22 | 23 | // grcov: ignore-start 24 | tracing::trace!( 25 | hostname = host.to_string_lossy().as_ref(), 26 | %expected, 27 | "checking if system hostname matches expected", 28 | ); 29 | // grcov: ignore-end 30 | 31 | Ok(host == expected.as_str()) 32 | } 33 | } 34 | 35 | impl fmt::Display for Hostname { 36 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 37 | let Hostname(hostname) = self; 38 | write!(f, "HOSTNAME == {hostname}") 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::*; 45 | 46 | #[test] 47 | fn test_correct_hostname() { 48 | let host = hostname::get() 49 | .expect("failed to get hostname for testing") 50 | .to_str() 51 | .expect("failed to convert to str") 52 | .to_owned(); 53 | 54 | let hostname_test = Hostname(host); 55 | 56 | let has_hostname: bool = hostname_test.try_into().expect("checking hostname failed"); 57 | 58 | assert!(has_hostname); 59 | } 60 | 61 | #[test] 62 | fn test_incorrect_hostname() { 63 | let mut hostname = hostname::get() 64 | .expect("failed to get hostname for testing") 65 | .to_str() 66 | .expect("failed to convert to str") 67 | .to_owned(); 68 | hostname.push_str("-invalid"); 69 | 70 | let hostname_test = Hostname(hostname); 71 | 72 | let has_hostname: bool = hostname_test.try_into().expect("checking hostname failed"); 73 | 74 | assert!(!has_hostname); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/config/builder/environment/os.rs: -------------------------------------------------------------------------------- 1 | //! See [`OperatingSystem`]. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::{Infallible, TryInto}; 5 | use std::fmt; 6 | use std::fmt::Formatter; 7 | 8 | /// A conditional structure that checks against the operating system `hoard` was compiled for. 9 | /// 10 | /// This has the effect of "detecting" the operating system at compile time instead of runtime. 11 | /// The downside is that running `hoard` in [Wine](https://www.winehq.org/) will detect the system 12 | /// as Windows, while running in the Windows Subsystem for Linux or FreeBSD's Linuxulator will 13 | /// detect the system as Linux. 14 | /// 15 | /// For possible values to check against, see [`std::env::consts::OS`]. 16 | #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)] 17 | #[serde(transparent)] 18 | pub struct OperatingSystem(pub String); 19 | 20 | impl TryInto for OperatingSystem { 21 | type Error = Infallible; 22 | 23 | fn try_into(self) -> Result { 24 | let OperatingSystem(expected) = self; 25 | // grcov: ignore-start 26 | tracing::trace!( 27 | os = std::env::consts::OS, 28 | %expected, 29 | "checking if current operating system matches expected", 30 | ); 31 | // grcov: ignore-end 32 | Ok(expected == std::env::consts::OS) 33 | } 34 | } 35 | 36 | impl fmt::Display for OperatingSystem { 37 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 38 | let OperatingSystem(os) = self; 39 | write!(f, "OPERATING SYSTEM == {os}") 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | 47 | #[test] 48 | fn test_correct_os() { 49 | let os = OperatingSystem(std::env::consts::OS.to_owned()); 50 | let is_os: bool = os.try_into().expect("failed to check operating system"); 51 | assert!(is_os); 52 | } 53 | 54 | #[test] 55 | #[cfg(not(target_os = "windows"))] 56 | fn test_incorrect_os() { 57 | let os = OperatingSystem(String::from("windows")); 58 | let is_os: bool = os.try_into().expect("failed to check operating system"); 59 | assert!(!is_os); 60 | } 61 | 62 | #[test] 63 | #[cfg(target_os = "windows")] 64 | fn test_incorrect_os() { 65 | let os = OperatingSystem(String::from("linux")); 66 | let is_os: bool = os.try_into().expect("failed to check operating system"); 67 | assert!(!is_os); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hoard" 3 | version = "0.6.1" 4 | authors = ["Michael Bryant "] 5 | edition = "2021" 6 | license = "BSD-3-Clause" 7 | description = "Hoard backups of files across your filesystem into one location." 8 | homepage = "https://github.com/Shadow53/hoard" 9 | repository = "https://github.com/Shadow53/hoard" 10 | rust-version = "1.74.1" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | [features] 14 | default = ["yaml"] 15 | yaml = ["serde_yaml"] 16 | 17 | [dependencies] 18 | async-stream = "0.3" 19 | async-trait = "0.1" 20 | clap = { version = "4.3", features = ["derive", "wrap_help"] } 21 | digest = "0.10.7" 22 | futures = { version = "0.3", default-features = false, features = [] } 23 | glob = "0.3" 24 | hex = "0.4.3" 25 | hostname = "0.4" 26 | itertools = "0.13.0" 27 | md-5 = "0.10.5" 28 | once_cell = "1.15" 29 | open_cmd = { version = "0.1.0", features = ["tracing"] } 30 | petgraph = "0.6" 31 | regex = "1.8" 32 | # Use at least 1.0.184 because of serde-rs/serde#2538 33 | serde = { version = ">=1.0.184", features = ["derive"] } 34 | serde_json = "1.0" 35 | serde_yaml = { version = "0.9", optional = true } 36 | sha2 = "0.10.7" 37 | similar = { version = "2.2", default-features = false, features = ["text"] } 38 | tap = "1.0" 39 | tempfile = "3.6" 40 | thiserror = "1.0.40" 41 | time = { version = "0.3", default-features = false, features = ["formatting", "macros", "serde", "std"] } 42 | tokio = { version = "1.28", default-features = false, features = ["rt-multi-thread", "fs", "io-util", "macros"] } 43 | tokio-stream = { version = "0.1", default-features = false, features = ["fs"] } 44 | toml = "0.8.2" 45 | tracing = "0.1" 46 | tracing-subscriber = { version = "0.3", default-features = false, features = ["ansi", "fmt", "env-filter", "smallvec", "std"] } 47 | uuid = { version = "1.3", features = ["serde", "v4"] } 48 | 49 | [target.'cfg(windows)'.dependencies] 50 | windows = { version = "0.58", features = ["Storage", "Win32_UI_Shell", "Win32_Foundation", "Win32_Globalization"] } 51 | 52 | [dev-dependencies] 53 | maplit = "1.0" 54 | rand = "0.8" 55 | serde_test = "1.0" 56 | futures = { version = "0.3", default-features = false, features = ["executor"] } 57 | tokio = { version = "1.28", default-features = false, features = ["process"] } 58 | serial_test = "3.1.1" 59 | 60 | [target.'cfg(windows)'.dev-dependencies] 61 | registry = "1.2" 62 | 63 | [target.'cfg(unix)'.dev-dependencies] 64 | nix = "0.29" 65 | pty_closure = "0.1" 66 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # configuration file for git-cliff (0.1.0) 2 | 3 | [changelog] 4 | # changelog header 5 | header = """ 6 | # Changelog\n 7 | All notable changes to this project will be documented in this file.\n 8 | """ 9 | # template for the changelog body 10 | # https://tera.netlify.app/docs/#introduction 11 | body = """ 12 | {% if version %}\ 13 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 14 | {% else %}\ 15 | ## [unreleased] 16 | {% endif %}\ 17 | {% for group, commits in commits | group_by(attribute="group") %} 18 | ### {{ group | upper_first }} 19 | {% for commit in commits %} 20 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | split(pat="\n") | first | upper_first }}\ 21 | {% endfor %} 22 | {% endfor %}\n 23 | """ 24 | # remove the leading and trailing whitespace from the template 25 | trim = true 26 | # changelog footer 27 | footer = """ 28 | 29 | """ 30 | 31 | [git] 32 | # parse the commits based on https://www.conventionalcommits.org 33 | conventional_commits = true 34 | # filter out the commits that are not conventional 35 | filter_unconventional = false 36 | # regex for preprocessing the commit messages 37 | commit_preprocessors = [ 38 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/Shadow53/hoard/issues/${2}))"}, 39 | ] 40 | # regex for parsing and grouping commits 41 | commit_parsers = [ 42 | { message = "^feat", group = "Features"}, 43 | { message = "^fix\\(deps\\)", group = "Dependency Upgrades" }, 44 | { message = "^fix", group = "Bug Fixes"}, 45 | { message = "^doc", group = "Documentation"}, 46 | { message = "^perf", group = "Performance"}, 47 | { message = "^refactor", group = "Refactor"}, 48 | { message = "^style", group = "Styling"}, 49 | { message = "^test", group = "Testing"}, 50 | { message = "^chore\\(deps\\)", group = "Dependency Upgrades" }, 51 | { message = "^chore\\(release\\): prepare for", skip = true}, 52 | { message = "^chore", group = "Miscellaneous Tasks"}, 53 | { body = ".*security", group = "Security"}, 54 | { message = ".*", group = "Other", default_scope = "other" }, 55 | ] 56 | # filter out the commits that are not matched by commit parsers 57 | filter_commits = true 58 | # glob pattern for matching git tags 59 | tag_pattern = "v[0-9]*" 60 | # regex for skipping tags 61 | skip_tags = "v0.1.0-beta.1" 62 | # regex for ignoring tags 63 | ignore_tags = "" 64 | # sort the tags chronologically 65 | date_order = false 66 | # sort the commits inside sections by oldest/newest order 67 | sort_commits = "oldest" 68 | -------------------------------------------------------------------------------- /book/src/getting-started/create-config/hoard.md: -------------------------------------------------------------------------------- 1 | # Example: Hoard itself 2 | 3 | Let's start with Hoard itself as an example. This allows you to easily share your Hoard configuration across multiple 4 | systems with only a little initial setup. Being a single file, it also makes for a simple example. 5 | 6 | Depending on the operating system used, the configuration file can be in one of a 7 | [number of locations](../../file-locations.md#config-directory). You will want to make entries for each system you 8 | plan to use Hoard on. For this guide, we will use Windows and Linux as examples. 9 | 10 | ## 1. Choose files to back up 11 | 12 | - Windows: `%APPDATA%\shadow53\hoard\config.toml` 13 | - Linux: `$XDG_CONFIG_HOME/hoard/` or `$HOME/.config/hoard/` 14 | 15 | The author uses the `XDG_CONFIG_HOME` path on Linux, but this variable is not always set by default, so this guide will 16 | add some logic to cover both cases. 17 | 18 | ## 2. Add configuration for those files 19 | 20 | Since this example expects Hoard to be used on multiple operating systems, we will create environments for each OS. We 21 | will also add an extra environment for when the environment variable `XDG_CONFIG_HOME` is set. 22 | 23 | ```toml 24 | [envs] 25 | linux = { os = ["linux"] } 26 | windows = { os = ["windows"] } 27 | xdg_config_set = { env = [{ var = "XDG_CONFIG_HOME" }] } 28 | ``` 29 | 30 | The above configuration uses a shorthand syntax. The following is also valid TOML: 31 | 32 | ```toml 33 | [envs] 34 | [envs.linux] 35 | os = ["linux"] 36 | [envs.windows] 37 | os = ["windows"] 38 | [envs.xdg_config_set] 39 | env = [ 40 | { var = "XDG_CONFIG_HOME" } 41 | ] 42 | ``` 43 | 44 | Now that the environments are defined, we can create the hoard that will contain the configuration file. 45 | 46 | ```toml 47 | [hoards] 48 | [hoards.hoard_config] 49 | "windows" = "${APPDATA}/shadow53/hoard/config.toml" 50 | "linux" = "${HOME}/.config/hoard/config.toml" 51 | "linux|xdg_config_set" = "${XDG_CONFIG_HOME}/hoard/config.toml" 52 | ``` 53 | 54 | You will notice that the keys `"windows"`, `"linux"`, and `"linux|xdg_config_set"` are wrapped in double quotes. This is 55 | because of the pipe character `|`, which is not allowed by default in TOML identifiers. The pipe indicates that multiple 56 | environments must match -- in this case, `linux` and `xdg_config_set` must both match. The quotes around `"linux"` and 57 | `"windows"` are merely for consistency. 58 | 59 | # 3. Do an initial backup 60 | 61 | You can now run `hoard backup hoard_config` to back up the configuration file, and `hoard restore hoard_config` to 62 | restore the version from the hoard. 63 | -------------------------------------------------------------------------------- /book/src/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This page lists the supported methods of installing `hoard`. 4 | 5 | ## GitHub Releases (Recommended) 6 | 7 | The recommended method of installation is by downloading a prebuilt executable from the 8 | [latest release](https://github.com/Shadow53/hoard/releases/latest) on GitHub. All files are named after the type 9 | of system that it can run on. There are many options available, but most people will want one of the following: 10 | 11 | - Windows: ` hoard-x86_64-pc-windows-msvc.zip` 12 | - Mac: 13 | - Intel (Older): `hoard-x86_64-apple-darwin.tar.gz` 14 | - Apple M1 (Newer): `hoard-aarch64-apple-darwin.tar.gz` 15 | - Linux: 16 | - GNU libc (most distributions, dynamically linked): `hoard-x86_64-unknown-linux-gnu.tar.gz` 17 | - MUSL libc (Alpine Linux, statically linked): `hoard-x86_64-unknown-linux-musl.tar.gz` 18 | - Modern Android phones: `hoard-aarch64-linux-android.tar.gz` 19 | - (you may also want to install [Termux](https://termux.com/)) 20 | 21 | ### I've downloaded it, how do I install? 22 | 23 | There is no installer for these files -- Hoard is a self-contained executable. Just extract the archive (`.zip`, `.tar.gz`), 24 | rename the extracted file to `hoard`, and add it to your `$PATH`. 25 | 26 | What is the `$PATH`? It is a list of directories that the computer searches for programs when you enter a command on the 27 | command line. The process of adding a program to your path is beyond the scope of this guide. Instead, try searching 28 | online for "add executable to PATH `os name`" where `os name` is the operating system you are running: "Windows", "Mac", 29 | "Linux", "Ubuntu", etc. 30 | 31 | ### GNU or MUSL? 32 | 33 | Most Linux distributions use GNU libc, so it should be safe to use that version. Because the libc is dynamically linked, 34 | security updates to the libc are automatically applied to Hoard and all other programs that use it. This also means that 35 | there is a small chance of a libc update breaking programs linked against older versions, though. 36 | 37 | MUSL libc is statically compiled into the executable itself, so it can run more or less standalone, without fear of libc 38 | breakages. The downside of this is that you do not receive bugfix updates to libc until a newer version of Hoard is 39 | released. 40 | 41 | You get to decide which one is best to use. For most users (Ubuntu/Debian, etc.), I suggest the GNU libc. For users of 42 | fast-moving rolling release systems and systems without GNU libc (Arch, Alpine, etc.), I suggest MUSL. 43 | 44 | ## Cargo 45 | 46 | If you have `cargo` and the Rust toolchain installed, you can install `hoard` with the following command: 47 | 48 | ```bash 49 | cargo install hoard 50 | ``` 51 | -------------------------------------------------------------------------------- /book/src/terminology.md: -------------------------------------------------------------------------------- 1 | # Environment 2 | 3 | Not to be confused with an [environment variable][envvar]. An Environment is an identifiable system configuration 4 | consisting of zero or more each of the following: 5 | 6 | - Operating system 7 | - Hostname 8 | - Environment variable 9 | - Executables in `$PATH` 10 | - Existing paths (folders/files) on the system 11 | 12 | Multiple Environments can be mixed and matched in **Environment Strings** when defining what paths 13 | to use for a given [Pile](#pile). Some Environments may be *mutually exclusive* with certain others. 14 | 15 | # Pile 16 | 17 | A single file or directory with multiple possible places where it can be found, depending on the 18 | system configuration. The path to use is determined by the best matching Environment String. 19 | 20 | # Hoard 21 | 22 | A collection of one or more [Piles](#pile) that form a logical unit. 23 | 24 | # Examples 25 | 26 | Consider this configuration snippet (see [Configuration File](./config/index.md) for more explanation): 27 | 28 | ```toml 29 | exclusivity = [ 30 | ["neovim", "vim"], 31 | ] 32 | 33 | [envs] 34 | [envs.neovim] 35 | exe_exists = ["nvim", "nvim-qt"] 36 | [envs.unix] 37 | os = ["linux", "freebsd"] 38 | env = [ 39 | { var = "HOME" }, 40 | { var = "XDG_CONFIG_HOME" } 41 | ] 42 | [envs.vim] 43 | # Detect "vim" if AT LEAST one of `vim` or `gvim` exists in $PATH. 44 | exe_exists = ["vim", "gvim"] 45 | [envs.windows] 46 | os = ["windows"] 47 | 48 | [hoards] 49 | [hoards.vim] 50 | [hoards.vim.init] 51 | "unix|neovim" = "${XDG_CONFIG_HOME}/nvim/init.vim" 52 | "unix|vim" = "${HOME}/.vimrc" 53 | "windows|neovim" = "${LOCALAPPDATA}\\nvim\\init.vim" 54 | "windows|vim" = "${USERPROFILE}\\.vim\\_vimrc" 55 | [hoards.vim.configdir] 56 | "windows|neovim" = "${LOCALAPPDATA}\\nvim\\config" 57 | "windows|vim" = "${USERPROFILE}\\.vim\\config" 58 | "unix|neovim" = "${XDG_CONFIG_HOME}/nvim/config" 59 | "unix|vim" = "${HOME}/.vim/config" 60 | ``` 61 | 62 | - Environments: `neovim`, `unix`, `vim`, `windows`; `neovim` and `vim` are mutually exclusive. 63 | - Hoards: just one, called `vim`, containing two named Piles. 64 | - Piles: `init` and `configdir`; `init` is the entry config file for a Vim program, while `configdir` is a directory 65 | containing more config files loaded by `init`. 66 | 67 | Take a closer look at the `init` Pile. There are four possible paths the file can be at, based on a combination of which 68 | operating system it is running on and whether Neovim or Vim is installed. The `exclusivity` line tells Hoard to prefer 69 | Neovim if both are present. 70 | 71 | [envvar]: https://en.wikipedia.org/wiki/Environment_variable 72 | -------------------------------------------------------------------------------- /src/command/diff.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | use futures::TryStreamExt; 4 | 5 | use crate::hoard::iter::{changed_diff_only_stream, HoardFileDiff}; 6 | use crate::hoard::Hoard; 7 | use crate::newtypes::HoardName; 8 | use crate::paths::HoardPath; 9 | 10 | #[tracing::instrument(skip(hoard))] 11 | pub(crate) async fn run_diff( 12 | hoard: &Hoard, 13 | hoard_name: &HoardName, 14 | hoards_root: &HoardPath, 15 | verbose: bool, 16 | ) -> Result<(), super::Error> { 17 | let _span = tracing::trace_span!("run_diff").entered(); 18 | tracing::trace!("running the diff command"); 19 | let diffs: BTreeSet = 20 | changed_diff_only_stream(hoards_root, hoard_name.clone(), hoard) 21 | .await 22 | .map_err(|err| { 23 | tracing::error!("failed to create diff stream: {}", err); 24 | super::Error::Diff(err) 25 | })? 26 | .try_collect() 27 | .await 28 | .map_err(super::Error::Diff)?; 29 | for hoard_diff in diffs { 30 | tracing::trace!("printing diff: {:?}", hoard_diff); 31 | match hoard_diff { 32 | HoardFileDiff::BinaryModified { file, diff_source } => { 33 | tracing::info!( 34 | "{}: binary file changed {}", 35 | file.system_path().display(), 36 | diff_source 37 | ); 38 | } 39 | HoardFileDiff::TextModified { 40 | file, 41 | unified_diff, 42 | diff_source, 43 | } => { 44 | tracing::info!( 45 | "{}: text file changed {}", 46 | file.system_path().display(), 47 | diff_source 48 | ); 49 | if let (true, Some(unified_diff)) = (verbose, unified_diff) { 50 | tracing::info!("{}", unified_diff); 51 | } 52 | } 53 | HoardFileDiff::Created { 54 | file, 55 | diff_source, 56 | unified_diff, 57 | } => { 58 | tracing::info!( 59 | "{}: (re)created {}", 60 | file.system_path().display(), 61 | diff_source 62 | ); 63 | if let (true, Some(unified_diff)) = (verbose, unified_diff) { 64 | tracing::info!("{}", unified_diff); 65 | } 66 | } 67 | HoardFileDiff::Deleted { file, diff_source } => { 68 | tracing::info!("{}: deleted {}", file.system_path().display(), diff_source); 69 | } 70 | HoardFileDiff::Unchanged(file) => { 71 | tracing::debug!("{}: unmodified", file.system_path().display()); 72 | } 73 | HoardFileDiff::Nonexistent(_) => {} 74 | } 75 | } 76 | 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /tests/expected_errors.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use tokio::{fs, io::AsyncWriteExt}; 4 | 5 | use crate::common::base::DefaultConfigTester; 6 | use common::tester::Tester; 7 | use hoard::command::Command; 8 | use hoard::config::builder::{Builder, Error as BuilderError}; 9 | 10 | #[tokio::test] 11 | async fn test_invalid_uuid() { 12 | let tester = DefaultConfigTester::with_log_level(tracing::Level::INFO).await; 13 | let uuid_path = tester.config_dir().join("uuid"); 14 | let bad_content = "INVALID UUID"; 15 | { 16 | let mut file = fs::File::create(&uuid_path) 17 | .await 18 | .expect("failed to create uuid file"); 19 | file.write_all(bad_content.as_bytes()) 20 | .await 21 | .expect("failed to write to uuid file"); 22 | } 23 | 24 | tester 25 | .expect_command(Command::Backup { hoards: Vec::new() }) 26 | .await; 27 | 28 | let content = fs::read_to_string(&uuid_path) 29 | .await 30 | .expect("failed to read uuid file"); 31 | assert_ne!(content, bad_content); 32 | 33 | tester.assert_has_output("failed to parse uuid in file"); 34 | } 35 | 36 | #[tokio::test] 37 | async fn test_invalid_config_extensions() { 38 | let tester = Tester::new(common::base::BASE_CONFIG).await; 39 | let expected_output = "configuration file must have file extension \""; 40 | 41 | let path = tester.config_dir().join("config_file"); 42 | { 43 | fs::File::create(&path) 44 | .await 45 | .expect("failed to create config_file"); 46 | } 47 | let error = Builder::from_file(&path) 48 | .await 49 | .expect_err("config file without file extension should fail"); 50 | assert!(matches!(error, BuilderError::InvalidExtension(bad_path) if path == bad_path)); 51 | 52 | tester.assert_has_output(expected_output); 53 | tester.clear_output(); 54 | 55 | let path = tester.config_dir().join("config_file.txt"); 56 | { 57 | fs::File::create(&path) 58 | .await 59 | .expect("failed to create config_file.txt"); 60 | } 61 | let error = Builder::from_file(&path) 62 | .await 63 | .expect_err("config file with bad file extension should fail"); 64 | assert!(matches!(error, BuilderError::InvalidExtension(bad_path) if path == bad_path)); 65 | 66 | tester.assert_has_output(expected_output); 67 | } 68 | 69 | #[tokio::test] 70 | async fn test_missing_config_dir() { 71 | let tester = Tester::new(common::base::BASE_CONFIG).await; 72 | fs::remove_dir(tester.config_dir()) 73 | .await 74 | .expect("failed to delete config dir"); 75 | tester 76 | .run_command(Command::Backup { hoards: Vec::new() }) 77 | .await 78 | .expect("running backup without config dir should not fail"); 79 | tester.assert_not_has_output("error while saving uuid to file"); 80 | tester.assert_not_has_output("No such file or directory"); 81 | } 82 | -------------------------------------------------------------------------------- /tests/operation_checksums.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::tester::Tester; 4 | use hoard::checkers::history::operation::{Operation, OperationImpl}; 5 | use hoard::checksum::{Checksum, MD5, SHA256}; 6 | use hoard::command::Command; 7 | use hoard::newtypes::PileName; 8 | use hoard::paths::RelativePath; 9 | use tokio::fs; 10 | 11 | const CONFIG: &str = r#" 12 | exclusivity = [[ "unix", "windows" ]] 13 | 14 | [envs] 15 | [envs.unix] 16 | os = ["linux", "macos"] 17 | env = [{ var = "HOME" }] 18 | [envs.windows] 19 | os = ["windows"] 20 | env = [{ var = "HOARD_TMP" }] 21 | 22 | [hoards] 23 | [hoards.md5] 24 | config = { hash_algorithm = "md5" } 25 | "unix" = "${HOME}/testing.txt" 26 | "windows" = "${HOARD_TMP}/testing.txt" 27 | [hoards.sha256] 28 | config = { hash_algorithm = "sha256" } 29 | "unix" = "${HOME}/testing.txt" 30 | "windows" = "${HOARD_TMP}/testing.txt" 31 | [hoards.default] 32 | "unix" = "${HOME}/testing.txt" 33 | "windows" = "${HOARD_TMP}/testing.txt" 34 | "#; 35 | 36 | #[tokio::test] 37 | async fn test_operation_checksums() { 38 | let tester = Tester::new(CONFIG).await; 39 | let file_path = tester.home_dir().join("testing.txt"); 40 | // Relative path for a file is "" 41 | let rel_file = RelativePath::none(); 42 | common::create_file_with_random_data::<2048>(&file_path).await; 43 | 44 | tester 45 | .expect_command(Command::Backup { hoards: Vec::new() }) 46 | .await; 47 | 48 | let data = fs::read(&file_path) 49 | .await 50 | .expect("reading data from test file should succeed"); 51 | let md5 = Checksum::MD5(MD5::from_data(&data)); 52 | let sha256 = Checksum::SHA256(SHA256::from_data(&data)); 53 | 54 | let pile_name = PileName::anonymous(); 55 | let md5_op = Operation::latest_local(&"md5".parse().unwrap(), Some((&pile_name, &rel_file))) 56 | .await 57 | .expect("should not fail to load operation for md5 hoard") 58 | .expect("operation should exist") 59 | .checksum_for(&pile_name, &rel_file) 60 | .expect("checksum should exist for file"); 61 | let sha256_op = 62 | Operation::latest_local(&"sha256".parse().unwrap(), Some((&pile_name, &rel_file))) 63 | .await 64 | .expect("should not fail to load operation for sha256 hoard") 65 | .expect("operation should exist") 66 | .checksum_for(&pile_name, &rel_file) 67 | .expect("checksum should exist for file"); 68 | let default_op = 69 | Operation::latest_local(&"default".parse().unwrap(), Some((&pile_name, &rel_file))) 70 | .await 71 | .expect("should not fail to load operation for default hoard") 72 | .expect("operation should exist") 73 | .checksum_for(&pile_name, &rel_file) 74 | .expect("checksum should exist for file"); 75 | 76 | assert_eq!(md5_op, md5); 77 | assert_eq!(sha256_op, sha256); 78 | assert_eq!(default_op, sha256); 79 | } 80 | -------------------------------------------------------------------------------- /src/hoard/iter/operation.rs: -------------------------------------------------------------------------------- 1 | use super::diff_stream; 2 | use crate::checkers::history::operation::ItemOperation; 3 | use crate::hoard::iter::{DiffSource, HoardFileDiff}; 4 | use crate::hoard::{Direction, Hoard}; 5 | use crate::hoard_item::CachedHoardItem; 6 | use crate::newtypes::HoardName; 7 | use crate::paths::HoardPath; 8 | use futures::{TryStream, TryStreamExt}; 9 | 10 | /// Stream returning all [`ItemOperation`]s for the given hoard. 11 | /// 12 | /// # Errors 13 | /// 14 | /// Any errors that may occur while initially creating the stream. 15 | #[allow(clippy::module_name_repetitions)] 16 | #[tracing::instrument] 17 | pub async fn operation_stream( 18 | hoards_root: &HoardPath, 19 | hoard_name: HoardName, 20 | hoard: &Hoard, 21 | direction: Direction, 22 | ) -> Result, Error = super::Error>, super::Error> 23 | { 24 | diff_stream(hoards_root, hoard_name, hoard) 25 | .await 26 | .map(move |stream| { 27 | stream.and_then(move |diff| async move { 28 | tracing::trace!("found diff: {:?}", diff); 29 | #[allow(clippy::match_same_arms)] 30 | let op = match diff { 31 | HoardFileDiff::BinaryModified { file, .. } 32 | | HoardFileDiff::TextModified { file, .. } => ItemOperation::Modify(file), 33 | HoardFileDiff::Created { 34 | file, diff_source, .. 35 | } => match (direction, diff_source) { 36 | (_, DiffSource::Mixed) => ItemOperation::Create(file), 37 | (Direction::Backup, DiffSource::Local) => ItemOperation::Create(file), 38 | (Direction::Backup, DiffSource::Remote | DiffSource::Unknown) => { 39 | ItemOperation::Delete(file) 40 | } 41 | (Direction::Restore, DiffSource::Remote | DiffSource::Unknown) => { 42 | ItemOperation::Create(file) 43 | } 44 | (Direction::Restore, DiffSource::Local) => ItemOperation::Delete(file), 45 | }, 46 | HoardFileDiff::Deleted { 47 | file, diff_source, .. 48 | } => match (direction, diff_source) { 49 | (_, DiffSource::Mixed) => ItemOperation::Delete(file), 50 | (Direction::Backup, DiffSource::Local) 51 | | (Direction::Restore, DiffSource::Remote | DiffSource::Unknown) => { 52 | ItemOperation::Delete(file) 53 | } 54 | (Direction::Backup, DiffSource::Remote | DiffSource::Unknown) 55 | | (Direction::Restore, DiffSource::Local) => ItemOperation::Create(file), 56 | }, 57 | HoardFileDiff::Unchanged(file) => ItemOperation::Nothing(file), 58 | HoardFileDiff::Nonexistent(file) => ItemOperation::DoesNotExist(file), 59 | }; 60 | Ok(op) 61 | }) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /tests/config_yaml_support.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use crate::common::base::DefaultConfigTester; 4 | use common::tester::Tester; 5 | use hoard::config::builder::{environment::Environment, Builder}; 6 | use tokio::fs; 7 | 8 | #[tokio::test] 9 | async fn test_yaml_support() { 10 | let tester = Tester::new(common::base::BASE_CONFIG).await; 11 | let path = tester.config_dir().join("config.yaml"); 12 | 13 | let builder: Builder = toml::from_str(common::base::BASE_CONFIG).expect("failed to parse TOML"); 14 | let content = serde_yaml::to_string(&builder).expect("failed to serialize to YAML"); 15 | fs::write(&path, &content) 16 | .await 17 | .expect("failed to write to YAML config file"); 18 | 19 | let config = Builder::from_file(&path) 20 | .await 21 | .expect("failed to parse YAML config") 22 | .build() 23 | .expect("failed to build config"); 24 | 25 | assert_eq!(&config, tester.config()); 26 | 27 | let new_path = tester.config_dir().join("config.yml"); 28 | fs::rename(path, &new_path) 29 | .await 30 | .expect("renaming file should succeed"); 31 | 32 | let config = Builder::from_file(&new_path) 33 | .await 34 | .expect("failed to parse YAML config") 35 | .build() 36 | .expect("failed to build config"); 37 | 38 | assert_eq!(&config, tester.config()); 39 | } 40 | 41 | #[tokio::test] 42 | async fn test_toml_takes_precedence() { 43 | let tester = DefaultConfigTester::new().await; 44 | let yaml_path = tester.config_dir().join("config.yaml"); 45 | let yml_path = tester.config_dir().join("config.yml"); 46 | let toml_path = tester.config_dir().join("config.toml"); 47 | 48 | let toml_config = Builder::new() 49 | .set_environments(maplit::btreemap! { "toml".parse().unwrap() => Environment::default() }); 50 | let yaml_config = Builder::new() 51 | .set_environments(maplit::btreemap! { "yaml".parse().unwrap() => Environment::default() }); 52 | { 53 | let toml_bytes = toml::to_string(&toml_config).expect("failed to serialize TOML"); 54 | fs::write(&toml_path, &toml_bytes) 55 | .await 56 | .expect("failed to write TOML to file"); 57 | } 58 | { 59 | let content = serde_yaml::to_string(&yaml_config).expect("failed to serialize YAML"); 60 | fs::write(&yaml_path, &content) 61 | .await 62 | .expect("failed to write to YAML file"); 63 | } 64 | { 65 | let content = serde_yaml::to_string(&yaml_config).expect("failed to serialize YAML"); 66 | fs::write(&yml_path, &content) 67 | .await 68 | .expect("failed to write to YML file"); 69 | } 70 | 71 | std::thread::sleep(std::time::Duration::from_millis(500)); 72 | 73 | let config = Builder::from_default_file() 74 | .await 75 | .expect("failed to parse from default file"); 76 | 77 | assert_eq!(config, toml_config); 78 | 79 | fs::remove_file(toml_path) 80 | .await 81 | .expect("failed to delete TOML file"); 82 | 83 | let config = Builder::from_default_file() 84 | .await 85 | .expect("failed to parse YAML config"); 86 | 87 | assert_eq!(config, yaml_config); 88 | 89 | drop(tester); 90 | } 91 | -------------------------------------------------------------------------------- /tests/operations.rs: -------------------------------------------------------------------------------- 1 | use crate::common::base::{DefaultConfigTester, HOARD_ANON_FILE}; 2 | use hoard::checkers::history::operation::{Operation, OperationImpl}; 3 | use hoard::checksum::Checksum; 4 | use hoard::command::Command; 5 | use hoard::hoard_item::HoardItem; 6 | use hoard::newtypes::PileName; 7 | 8 | mod common; 9 | 10 | async fn last_op(file: &HoardItem) -> Operation { 11 | Operation::latest_local( 12 | &HOARD_ANON_FILE.parse().unwrap(), 13 | Some((&PileName::anonymous(), file.relative_path())), 14 | ) 15 | .await 16 | .expect("finding a recent operation should not fail") 17 | .expect("a recent operation should exist") 18 | } 19 | 20 | async fn last_checksum(file: &HoardItem) -> Checksum { 21 | last_op(file) 22 | .await 23 | .checksum_for(&PileName::anonymous(), file.relative_path()) 24 | .expect("checksum should exist for file") 25 | } 26 | 27 | async fn current_checksum(file: &HoardItem) -> Checksum { 28 | file.system_sha256() 29 | .await 30 | .expect("getting checksum should not fail") 31 | .expect("file should exist to checksum") 32 | } 33 | 34 | async fn assert_matching_checksum(file: &HoardItem) { 35 | assert_eq!(last_checksum(file).await, current_checksum(file).await); 36 | } 37 | 38 | async fn assert_not_matching_checksum(file: &HoardItem) { 39 | assert_ne!(last_checksum(file).await, current_checksum(file).await); 40 | } 41 | 42 | #[tokio::test] 43 | async fn test_operations() { 44 | let mut tester = DefaultConfigTester::new().await; 45 | tester.use_first_env(); 46 | tester.setup_files().await; 47 | 48 | let file = tester.anon_file(); 49 | let backup = Command::Backup { hoards: Vec::new() }; 50 | // 1 - Command should work because it is the first backup 51 | tester.use_local_uuid().await; 52 | tester.expect_command(backup.clone()).await; 53 | 54 | // 2 - Command should work because all files are the same 55 | tester.use_remote_uuid().await; 56 | tester.expect_command(backup.clone()).await; 57 | 58 | // 3 - Modify file and back up again. Should succeed because this id has the most recent backup 59 | assert_matching_checksum(&file).await; 60 | common::create_file_with_random_data::<2048>(file.system_path()).await; 61 | assert_not_matching_checksum(&file).await; 62 | tester.expect_command(backup.clone()).await; 63 | assert_matching_checksum(&file).await; 64 | 65 | // 4 - Swap UUIDs, change file content, try backup again. Should fail. 66 | tester.use_local_uuid().await; 67 | // latest checksum for this id should not match 68 | assert_not_matching_checksum(&file).await; 69 | common::create_file_with_random_data::<2048>(file.system_path()).await; 70 | 71 | // TODO: assert error 72 | let _error_1 = tester 73 | .run_command(backup.clone()) 74 | .await 75 | .expect_err("backup should fail because this id does not have latest backup"); 76 | 77 | // 5 - should fail when trying again 78 | let _error_2 = tester 79 | .run_command(backup.clone()) 80 | .await 81 | .expect_err("backup should *still* fail because this id does not have latest backup"); 82 | 83 | // 6 - should now work because it's forced 84 | tester.expect_forced_command(backup).await; 85 | assert_matching_checksum(&file).await; 86 | } 87 | -------------------------------------------------------------------------------- /src/command/status.rs: -------------------------------------------------------------------------------- 1 | use futures::TryStreamExt; 2 | 3 | use crate::hoard::iter::{diff_stream, DiffSource, HoardFileDiff}; 4 | use crate::hoard::Hoard; 5 | use crate::newtypes::HoardName; 6 | use crate::paths::HoardPath; 7 | 8 | #[tracing::instrument(skip(hoards))] 9 | pub(crate) async fn run_status<'a>( 10 | hoards_root: &HoardPath, 11 | hoards: impl IntoIterator, 12 | ) -> Result<(), super::Error> { 13 | for (hoard_name, hoard) in hoards { 14 | let _span = tracing::error_span!("run_status", hoard=%hoard_name).entered(); 15 | let source = diff_stream(hoards_root, hoard_name.clone(), hoard) 16 | .await 17 | .map_err(super::Error::Status)? 18 | .map_err(super::Error::Status) 19 | .try_filter_map(|hoard_diff| async move { 20 | #[allow(clippy::match_same_arms)] 21 | let source = match hoard_diff { 22 | HoardFileDiff::BinaryModified { diff_source, .. } => Some(diff_source), 23 | HoardFileDiff::TextModified { diff_source, .. } => Some(diff_source), 24 | HoardFileDiff::Created { diff_source, .. } => Some(diff_source), 25 | HoardFileDiff::Deleted { diff_source, .. } => Some(diff_source), 26 | HoardFileDiff::Unchanged(_) | HoardFileDiff::Nonexistent(_) => None, 27 | }; 28 | 29 | Ok(source) 30 | }) 31 | .try_fold(None, |acc, source| async move { 32 | match acc { 33 | None => Ok(Some(source)), 34 | Some(acc) => { 35 | let new_source = 36 | if acc == DiffSource::Unknown || source == DiffSource::Unknown { 37 | DiffSource::Unknown 38 | } else if acc == source { 39 | acc 40 | } else { 41 | DiffSource::Mixed 42 | }; 43 | 44 | Ok(Some(new_source)) 45 | } 46 | } 47 | }) 48 | .await?; 49 | 50 | match source { 51 | None => tracing::info!("{}: up to date", hoard_name), 52 | Some(source) => { 53 | match source { 54 | DiffSource::Local => tracing::info!( 55 | "{}: modified {} -- sync with `hoard backup {}`", 56 | hoard_name, source, hoard_name 57 | ), 58 | DiffSource::Remote => tracing::info!( 59 | "{}: modified {} -- sync with `hoard restore {}`", 60 | hoard_name, source, hoard_name 61 | ), 62 | DiffSource::Mixed => tracing::info!( 63 | "{0}: mixed changes -- manual intervention recommended (see `hoard diff {0}`)", 64 | hoard_name 65 | ), 66 | DiffSource::Unknown => tracing::info!( 67 | "{0}: unexpected changes -- manual intervention recommended (see `hoard diff {0}`)", 68 | hoard_name 69 | ), 70 | } 71 | } 72 | } 73 | } 74 | 75 | Ok(()) 76 | } 77 | -------------------------------------------------------------------------------- /operation_upgrade_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | die() { 4 | echo "$@" 1>&2 5 | exit 1 6 | } 7 | 8 | if [ $(uname -s) != "Linux" ] || [ $(uname -m) != "x86_64" ]; then 9 | die "This test is intended to run on x86_64 Linux" 10 | fi 11 | 12 | export TEST_ROOT="/tmp/hoard" 13 | export ARCHIVE_ROOT="${TEST_ROOT}/archives" 14 | export BIN_ROOT="${TEST_ROOT}/bin" 15 | export XDG_CONFIG_HOME="${TEST_ROOT}/config" 16 | export XDG_DATA_HOME="${TEST_ROOT}/data" 17 | export HOARD_CONFIG_DIR="${XDG_CONFIG_HOME}/hoard" 18 | export HOARD_DATA_DIR="${XDG_DATA_HOME}/hoard" 19 | export HOARD_FILES="${TEST_ROOT}/files" 20 | export CONFIG_FILE="${HOARD_CONFIG_DIR}/config.toml" 21 | export HOARD_LOG="trace" 22 | 23 | call_hoard() { 24 | version="$1" 25 | shift 26 | args=(--config-file "${CONFIG_FILE}" "${@}") 27 | if [ "${version}" = "cargo" ]; then 28 | cargo run -- "${args[@]}" 29 | else 30 | "${BIN_ROOT}/hoard-${version}" "${args[@]}" 31 | fi 32 | } 33 | 34 | download_hoard() { 35 | version="$1" 36 | mkdir -p "${BIN_ROOT}" 37 | mkdir -p "${ARCHIVE_ROOT}" 38 | curl -L -o "${ARCHIVE_ROOT}/hoard-${version}.tar.gz" "https://github.com/Shadow53/hoard/releases/download/${version}/hoard-x86_64-unknown-linux-gnu.tar.gz" 39 | tar -xzvf "${ARCHIVE_ROOT}/hoard-${version}.tar.gz" -C "${BIN_ROOT}" hoard 40 | mv "${BIN_ROOT}/hoard" "${BIN_ROOT}/hoard-${version}" 41 | chmod +x "${BIN_ROOT}/hoard-${version}" 42 | } 43 | 44 | reset_file() { 45 | mkdir -p "$(dirname "$1")" 46 | dd bs=1M count=1 if=/dev/urandom of="$1" 47 | } 48 | 49 | reset_files() { 50 | reset_file "${HOARD_FILES}/anon_file" 51 | reset_file "${HOARD_FILES}/named_file" 52 | reset_file "${HOARD_FILES}/anon_dir/some_file" 53 | reset_file "${HOARD_FILES}/anon_dir/some_dir/another_file" 54 | reset_file "${HOARD_FILES}/named_dir/some_file" 55 | reset_file "${HOARD_FILES}/named_dir/some_dir/another_file" 56 | } 57 | 58 | run_hoard_version() { 59 | version="$1" 60 | reset_files 61 | if [ "${version}" != "cargo" ]; then 62 | download_hoard "${version}" 63 | fi 64 | 65 | if [ "${version}" != "v0.4.0" ]; then 66 | if ! call_hoard "${version}" upgrade; then 67 | die "first upgrade command failed" 68 | fi 69 | 70 | # Run again to make sure upgrading from the newest version also works 71 | if ! call_hoard "${version}" upgrade; then 72 | die "second upgrade command failed" 73 | fi 74 | fi 75 | 76 | if ! call_hoard "${version}" backup; then 77 | die "backup command failed" 78 | fi 79 | } 80 | 81 | rm -rf "${TEST_ROOT}" 82 | 83 | mkdir -p "${HOARD_CONFIG_DIR}" 84 | mkdir -p "${HOARD_DATA_DIR}" 85 | mkdir -p "${HOARD_FILES}" 86 | 87 | tee "${CONFIG_FILE}" << EOF 88 | [envs.always] 89 | env = [{ var = "HOARD_FILES" }] 90 | 91 | [hoards.anon_file] 92 | "always" = "${HOARD_FILES}/anon_file" 93 | 94 | [hoards.anon_dir] 95 | "always" = "${HOARD_FILES}/anon_dir" 96 | 97 | [hoards.named] 98 | [hoards.named.file] 99 | "always" = "${HOARD_FILES}/named_file" 100 | [hoards.named.dir] 101 | "always" = "${HOARD_FILES}/named_dir" 102 | EOF 103 | 104 | run_hoard_version "v0.4.0" 105 | read -p "Paused..." unused 106 | run_hoard_version "cargo" 107 | 108 | rm -rf "${TEST_ROOT}" 109 | 110 | echo "Success!" 111 | -------------------------------------------------------------------------------- /book/src/file-locations.md: -------------------------------------------------------------------------------- 1 | # File Locations 2 | 3 | This page explains what files are generated by `hoard` and where they can be found. 4 | 5 | In general, `hoard` uses the [`directories`](https://docs.rs/directories) library, 6 | using the `config_dir` and the `data_dir` of the `ProjectDirs` struct. 7 | 8 | ## Config Directory 9 | 10 | The configuration directory holds the configuration file (`config.toml`, `config.yaml`, or `config.yml`) as well as 11 | other local-only configuration data. 12 | 13 | - Linux/BSD: `$XDG_CONFIG_HOME/hoard/` or `$HOME/.config/hoard/` 14 | - macos: `$HOME/Library/Application Support/com.shadow53.hoard/` 15 | - Windows: `%AppData%\shadow53\hoard\config\` 16 | 17 | ### Config File 18 | 19 | The [configuration file](./config/index.md) (`config.toml`) contains the environment and hoard definitions, along 20 | with all related configuration. Follow the link in the previous sentence for more about the 21 | configuration file format. 22 | 23 | ### UUID File 24 | 25 | The UUID file (`uuid`) contains a unique identifier for the current system. This is used when performing 26 | pre-operation checks. Files relating to this UUID are synchronized between machines using whatever 27 | synchronization mechanism you use to synchronize hoards between machines, but **nowhere else**. This UUID 28 | is *not* used to identify your machines to any service, only the `hoard` program. 29 | 30 | ## Hoard Data Directory 31 | 32 | The hoard data directory contains all backed up hoard files, along with other files that should be 33 | synchronized with the hoard files. 34 | 35 | - Linux/BSD: `$XDG_DATA_HOME/hoard/` or `/home/$USER/.local/share/hoard/` 36 | - macos: `$HOME/Library/Application Support/com.shadow53.hoard/` 37 | - Windows: `%AppData%\shadow53\hoard\data\` 38 | 39 | ### Hoard Files 40 | 41 | All files backed up by `hoard` are stored in the data directory, in a subdirectory called `hoards`. 42 | 43 | The files are organized according to the names of the hoard and pile they are configured under. 44 | 45 | As an example, consider the following real configuration: 46 | 47 | ```toml 48 | [hoards.custom_fonts] 49 | "unix" = "/home/shadow53/.local/share/fonts" 50 | 51 | [hoards.fish] 52 | [hoards.fish.confdir] 53 | "unix" = "/home/shadow53/.config/fish/conf.d" 54 | [hoards.fish.functions] 55 | "unix" = "/home/shadow53/.config/fish/functions" 56 | 57 | [hoards.newsboat] 58 | "unix" = "/home/shadow53/.newsboat/config" 59 | 60 | [hoards.qemu] 61 | [hoards.qemu.script] 62 | "unix" = "/home/shadow53/.bin/vm" 63 | [hoards.qemu.configs] 64 | "unix" = "/home/shadow53/.config/qemu" 65 | ``` 66 | 67 | These hoards/piles are stored in the following locations: 68 | 69 | ```ignore 70 | $data_dir 71 | ├─ custom_fonts/ 72 | ├─ fish 73 | │ ├─ confdir/ 74 | │ └─ functions/ 75 | ├─ newsboat/ 76 | └─ qemu/ 77 | ├─ script 78 | └─ configs/ 79 | ``` 80 | 81 | ### History Files 82 | 83 | There are currently two types of history-related files stored by `hoard`, both of which are used 84 | in pre-operation consistency checks. All history-related files are stored in a subdirectory `history/{uuid}` 85 | in the data directory, where `uuid` is the generated uuid of the current system. 86 | 87 | - [Last Paths](./cli/checks.md#last-paths): a single file `last_paths.json`. 88 | - [Operations](./cli/checks.md#remote-operations): date-stamped JSON files with details of which files were modified 89 | during a given operation and what the checksum was for each file. 90 | -------------------------------------------------------------------------------- /src/config/builder/var_defaults.rs: -------------------------------------------------------------------------------- 1 | //! See [`EnvVarDefaults`]. 2 | 3 | use crate::env_vars::StringWithEnv; 4 | use serde::{Deserialize, Serialize}; 5 | use std::collections::BTreeMap; 6 | use std::fmt::Formatter; 7 | use std::{env, fmt}; 8 | 9 | /// Failed to apply one or more environment variables in [`EnvVarDefaults::apply`]. 10 | /// 11 | /// Most common reasons for this occurring is trying to use an unset variable in a default value, 12 | /// or having two unset variables' values dependent on each other. 13 | #[derive(Debug, thiserror::Error)] 14 | pub struct EnvVarDefaultsError(BTreeMap); 15 | 16 | impl fmt::Display for EnvVarDefaultsError { 17 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 18 | assert!(!self.0.is_empty()); 19 | write!(f, "Could not apply environment variable defaults. One or more default values requires an unset variable.")?; 20 | for (var, value) in &self.0 { 21 | write!(f, "\n{var}: {value:?}")?; 22 | } 23 | Ok(()) 24 | } 25 | } 26 | 27 | /// Define variables and their default values, should that variable not otherwise be defined. 28 | /// 29 | /// Variable default values can interpolate the values of other environment variables. 30 | /// See [`StringWithEnv`] for the required syntax. 31 | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] 32 | #[serde(transparent)] 33 | #[repr(transparent)] 34 | #[allow(clippy::module_name_repetitions)] 35 | pub struct EnvVarDefaults(BTreeMap); 36 | 37 | impl EnvVarDefaults { 38 | /// Insert a new environment variable/value pairing. 39 | pub fn insert(&mut self, var: String, value: StringWithEnv) -> Option { 40 | self.0.insert(var, value) 41 | } 42 | 43 | /// Merge `other` into `self`. Values in `other` take precedence. 44 | pub fn merge_with(&mut self, other: Self) { 45 | for (var, value) in other.0 { 46 | self.0.insert(var, value); 47 | } 48 | } 49 | 50 | /// Attempt to apply every default value to any unset environment variables, expanding their 51 | /// values first. 52 | /// 53 | /// This will repeatedly attempt to apply variables as long as at least one successfully applies. 54 | /// 55 | /// # Errors 56 | /// 57 | /// See [`EnvVarDefaultsError`]. 58 | pub fn apply(self) -> Result<(), EnvVarDefaultsError> { 59 | // Add one just so the `while` condition is true once. 60 | let mut remaining_last_loop = self.0.len() + 1; 61 | let mut this_loop = self.0; 62 | 63 | while remaining_last_loop != this_loop.len() { 64 | let last_loop = std::mem::take(&mut this_loop); 65 | remaining_last_loop = last_loop.len(); 66 | for (var, value) in last_loop { 67 | if env::var_os(&var).is_none() { 68 | match value.clone().process() { 69 | Err(_) => _ = this_loop.insert(var, value), 70 | Ok(value) => { 71 | // This function can panic under certain circumstances. Either check for 72 | // them or catch the panic. 73 | env::set_var(var, value); 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | if this_loop.is_empty() { 81 | Ok(()) 82 | } else { 83 | Err(EnvVarDefaultsError(this_loop)) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/newtypes/environment_name.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, ops::Deref, str::FromStr}; 2 | 3 | use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; 4 | 5 | use super::{validate_name, Error}; 6 | 7 | /// Newtype wrapper for `String` representing an [environment](https://hoard.rs/config/envs.html). 8 | /// 9 | /// See the [module documentation](super) for what makes an acceptable name. 10 | #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] 11 | #[repr(transparent)] 12 | #[serde(transparent)] 13 | pub struct EnvironmentName(String); 14 | 15 | impl FromStr for EnvironmentName { 16 | type Err = Error; 17 | #[tracing::instrument(level = "trace", name = "parse_environment_name")] 18 | fn from_str(s: &str) -> Result { 19 | validate_name(s.to_string()).map(Self) 20 | } 21 | } 22 | 23 | impl Deref for EnvironmentName { 24 | type Target = str; 25 | 26 | fn deref(&self) -> &Self::Target { 27 | self.0.as_str() 28 | } 29 | } 30 | 31 | impl fmt::Display for EnvironmentName { 32 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 33 | self.deref().fmt(f) 34 | } 35 | } 36 | 37 | impl<'de> Deserialize<'de> for EnvironmentName { 38 | fn deserialize(deserializer: D) -> Result 39 | where 40 | D: Deserializer<'de>, 41 | { 42 | let inner = String::deserialize(deserializer)?; 43 | inner.parse().map_err(D::Error::custom) 44 | } 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use serde_test::{assert_tokens, Token}; 50 | 51 | use super::*; 52 | 53 | #[test] 54 | fn test_from_str() { 55 | let inputs = [ 56 | ("", Err(Error::DisallowedName(String::new()))), 57 | ( 58 | "invalid name", 59 | Err(Error::DisallowedCharacters(String::from("invalid name"))), 60 | ), 61 | ("valid", Ok(EnvironmentName(String::from("valid")))), 62 | ]; 63 | 64 | for (s, expected) in inputs { 65 | let result = s.parse::(); 66 | match (result, expected) { 67 | (Ok(result), Err(expected)) => { 68 | panic!("expected error {expected:?} but got success {result:?}") 69 | } 70 | (Err(err), Ok(expected)) => { 71 | panic!("expected success {expected:?} but got error {err:?}") 72 | } 73 | (Ok(result), Ok(expected)) => { 74 | assert_eq!(result, expected, "expected {expected:?} but got {result:?}"); 75 | } 76 | (Err(err), Err(expected)) => { 77 | assert_eq!(err, expected, "expected error {expected:?} but got {err:?}"); 78 | } 79 | } 80 | } 81 | } 82 | 83 | #[test] 84 | #[allow(clippy::explicit_deref_methods)] 85 | fn test_deref() { 86 | let s = "testing"; 87 | let name: EnvironmentName = s.parse().unwrap(); 88 | assert_eq!(s, name.deref()); 89 | } 90 | 91 | #[test] 92 | fn test_to_string() { 93 | let s = "test_name"; 94 | let name: EnvironmentName = s.parse().unwrap(); 95 | assert_eq!(s, name.to_string()); 96 | } 97 | 98 | #[test] 99 | fn test_serde() { 100 | let name: EnvironmentName = "testing".parse().unwrap(); 101 | assert_tokens(&name, &[Token::Str("testing")]); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /book/src/cli/flags-subcommands.md: -------------------------------------------------------------------------------- 1 | # Flags 2 | 3 | Flags can be used with any subcommand and must be specified *before* any subcommand. 4 | 5 | - `--help`: View the program's help message. 6 | - `-V/--version`: Print the installed version of `hoard`. 7 | - `-c/--config-file`: Path to (non-default) configuration file. 8 | - `--data-dir`: Path to (non-default) hoards data directory. 9 | - `--config-dir`: Path to (non-default) hoards config directory. 10 | 11 | # Subcommands 12 | 13 | ## `hoard backup` 14 | 15 | ``` 16 | hoard [flags...] backup [name] [name] [...] 17 | ``` 18 | 19 | Back up the specified hoard(s). If no `name` is specified, all hoards are backed up. 20 | 21 | ## `hoard cleanup` 22 | 23 | ``` 24 | hoard [flags...] cleanup 25 | ``` 26 | 27 | Deletes all extra [operation log files](../file-locations.md#history-files) 28 | that are unnecessary for the related [check](./checks.md#remote-operations). 29 | 30 | ## `hoard diff` 31 | 32 | ``` 33 | hoard [flags...] diff [-v|--verbose] 34 | ``` 35 | 36 | Shows a list of all files that differ between the system and the hoard given by ``. This 37 | can detect files that were created, modified, or deleted, locally or remotely. 38 | 39 | If `-v` or `--verbose` is passed, the output will show unified diffs of text files. 40 | 41 | ## `hoard edit` 42 | 43 | ``` 44 | hoard [flags...] edit 45 | ``` 46 | 47 | Opens the Hoard configuration file in the default editor. This uses `$EDITOR` when set and 48 | the system default handler otherwise. 49 | 50 | - On Linux and BSD, this delegates to `xdg-open`, which must be installed if `$EDITOR` is not set. 51 | 52 | ## `hoard init` 53 | 54 | ``` 55 | hoard [flags...] init 56 | ``` 57 | 58 | Ensures the Hoard configuration and data directories exist. If there is no configuration file, 59 | one is created. 60 | 61 | ## `hoard list` 62 | 63 | ``` 64 | hoard [flags...] list 65 | ``` 66 | 67 | List all configured hoards by name (sorted). 68 | 69 | ## `hoard restore` 70 | 71 | ``` 72 | hoard [flags...] restore [name] [name] [...] 73 | ``` 74 | 75 | Restore the specified hoard(s). If no `name` is specified, all hoards are restored. 76 | 77 | ## `hoard status` 78 | 79 | ``` 80 | hoard [flags...] status 81 | ``` 82 | 83 | Displays the current status of every configured hoard: 84 | 85 | - `modified locally`: all changes are local, and this hoard can be safely backed up with 86 | `hoard backup`. 87 | - `modified remotely`: all changes are remote, and this hoard can be safely applied locally 88 | with `hoard restore`. 89 | - `mixed changes`: changes are a combination of local and remote, and manual intervention is 90 | recommended. Using [`hoard diff`](#hoard-diff) may be useful in reconciling changes. 91 | - `unexpected changes`: at least one hoard file appears to have been directly modified instead 92 | of using `hoard backup`. [`hoard diff`](#hoard-diff) may be useful in handling the unexpected 93 | change. 94 | 95 | ## `hoard upgrade` 96 | 97 | ``` 98 | hoard [flags...] upgrade 99 | ``` 100 | 101 | Automatically upgrades hoard-related files to newer formats. Old formats may be removed in later 102 | versions to help keep the codebase clean. 103 | 104 | This currently affects: 105 | 106 | - [Operation log files](checks.md#remote-operations) 107 | 108 | ## `hoard validate` 109 | 110 | ``` 111 | hoard [flags...] validate 112 | ``` 113 | Attempt to parse the default configuration file (or the one provided via `--config-file`). 114 | Exits with code `0` if the config is valid. 115 | -------------------------------------------------------------------------------- /src/newtypes/hoard_name.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, ops::Deref, str::FromStr}; 2 | 3 | use serde::{de::Error as _, Deserialize, Deserializer, Serialize}; 4 | 5 | use super::{validate_name, Error}; 6 | 7 | /// Newtype wrapper for `String` representing a hoard name. 8 | /// 9 | /// See the [module documentation](super) for what makes an acceptable name. 10 | #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] 11 | #[repr(transparent)] 12 | #[serde(transparent)] 13 | pub struct HoardName(String); 14 | 15 | impl FromStr for HoardName { 16 | type Err = Error; 17 | #[tracing::instrument(level = "trace", name = "parse_hoard_name")] 18 | fn from_str(s: &str) -> Result { 19 | validate_name(s.to_string()).map(Self) 20 | } 21 | } 22 | 23 | impl AsRef for HoardName { 24 | fn as_ref(&self) -> &str { 25 | &self.0 26 | } 27 | } 28 | 29 | impl Deref for HoardName { 30 | type Target = str; 31 | 32 | fn deref(&self) -> &Self::Target { 33 | self.0.as_str() 34 | } 35 | } 36 | 37 | impl fmt::Display for HoardName { 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 39 | self.deref().fmt(f) 40 | } 41 | } 42 | 43 | impl<'de> Deserialize<'de> for HoardName { 44 | fn deserialize(deserializer: D) -> Result 45 | where 46 | D: Deserializer<'de>, 47 | { 48 | let inner = String::deserialize(deserializer)?; 49 | inner.parse().map_err(D::Error::custom) 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use serde_test::{assert_tokens, Token}; 56 | 57 | use super::*; 58 | 59 | #[test] 60 | fn test_from_str() { 61 | let inputs = [ 62 | (String::new(), Err(Error::DisallowedName(String::new()))), 63 | ( 64 | String::from("config"), 65 | Err(Error::DisallowedName(String::from("config"))), 66 | ), 67 | ( 68 | String::from("bad name"), 69 | Err(Error::DisallowedCharacters(String::from("bad name"))), 70 | ), 71 | (String::from("valid"), Ok(HoardName(String::from("valid")))), 72 | ]; 73 | 74 | for (s, expected) in inputs { 75 | let result = s.parse::(); 76 | match (&result, &expected) { 77 | (Ok(name), Err(err)) => panic!("expected error {err:?}, got success {name:?}"), 78 | (Err(err), Ok(name)) => panic!("expected success {name:?}, got error {err:?}"), 79 | (Ok(name), Ok(expected)) => { 80 | assert_eq!(name, expected, "expected {expected} but got {name}"); 81 | } 82 | (Err(err), Err(expected)) => { 83 | assert_eq!(err, expected, "expected {expected:?} but got {err:?}"); 84 | } 85 | } 86 | } 87 | } 88 | 89 | #[test] 90 | #[allow(clippy::explicit_deref_methods)] 91 | fn test_as_ref_and_deref() { 92 | let s = "testing"; 93 | let name: HoardName = s.parse().unwrap(); 94 | assert_eq!(s, name.as_ref()); 95 | assert_eq!(s, name.deref()); 96 | } 97 | 98 | #[test] 99 | fn test_to_string() { 100 | let s = "testing"; 101 | let name: HoardName = s.parse().unwrap(); 102 | assert_eq!(s, name.to_string()); 103 | } 104 | 105 | #[test] 106 | fn test_serde() { 107 | let name: HoardName = "testing".parse().unwrap(); 108 | assert_tokens(&name, &[Token::Str("testing")]); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /book/src/getting-started/create-config/README.md: -------------------------------------------------------------------------------- 1 | # Creating the Configuration File 2 | 3 | ## 0. Open configuration file 4 | 5 | Run [`hoard edit`](../../cli/flags-subcommands.md#hoard-edit) to open the configuration file in your default editor. 6 | 7 | Alternatively, check the [File Locations](../../file-locations.md#config-directory) page for the location that 8 | the configuration file should be placed. If you are creating the file using your systems File Explorer, you may 9 | need to enable hidden files/folders. 10 | 11 | ## 1. Choose files to back up 12 | 13 | The next step is determining what you are going to back up with Hoard. Common examples are configuration files for 14 | various programs and save files for PC games. Just like with Hoard's configuration file, these files are often found 15 | in hidden folders, so you may have to do some digging to find them. 16 | 17 | For the sake of this guide, we will consider three different programs: 18 | 19 | > **NOTE:** The examples use TOML as the config file format. Users looking to use YAML should be able to translate 20 | > the configuration from TOML. See also [this other note](../../config/). 21 | 22 | 1. [Hoard itself](./hoard.md) 23 | 2. [Vim and Neovim](./vim.md) 24 | 3. [*Mindustry* and *Death and Taxes*](./games.md) 25 | 26 | ## 2. Add configuration for those files 27 | 28 | When adding configuration for a specific file or set of files, consider: 29 | 30 | - What to name the hoard and, optionally, the pile or piles within it. See the examples linked above for ideas of how 31 | to structure hoards. 32 | - What conditions must be true for a path to be used. These determine the environments, or 33 | [`envs`](../../config/environments.md) that you will define. 34 | - If there are multiple, mutually exclusive conditions that can be true at the same time (see 35 | [Vim and Neovim](vim-neovim.md) for an example). This determines if you need to add anything under 36 | [`exclusivity`](../../config/environments.md#exclusivity). 37 | - Whether the programs use environment variables to determine where to place files, or if it is hardcoded. This will 38 | inform whether you use environment variables in the pile path or not. 39 | - Whether there are files in a directory that you want to [ignore](../../config/hoards-piles.md#ignore-patterns) when 40 | backing up. 41 | 42 | ## 2.1: Validate the configuration 43 | 44 | When you think you have completed the configuration, double check by running `hoard validate`. If there are any errors 45 | with the configuration file, this command will tell you. 46 | 47 | ## 3. Do an initial backup 48 | 49 | Once you have validated the configuration, run `hoard backup `, where `` is the name of the 50 | hoard you just created. Alternatively, you can run `hoard backup` to back up all configured hoards. 51 | 52 | ## 4. Optional: Set up sync 53 | 54 | If you want to use Hoard to synchronize files between systems, you'll want to set up some sort of synchronization. 55 | Hoard aims to be agnostic to which method is used and only requires that the data files can be found in the 56 | [expected location](../../file-locations.md#hoard-data-directory). This can be done by synchronizing that directory 57 | directly or by creating a symbolic link to another directory. 58 | 59 | Possible sync solutions: 60 | 61 | - [Syncthing](https://syncthing.net) 62 | - A git repository on any hosting service 63 | - File synchronization services like Nextcloud/ownCloud, Dropbox, Microsoft Onedrive, etc. 64 | 65 | Whatever solution you choose, be aware of the possibility of synchronization conflicts. Hoard has no special logic to 66 | prevent synchronization-level conflicts, instead leaving that to the synchronization software itself. 67 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | //! Items to be used only for testing Hoard-related code. 2 | #![cfg(test)] 3 | 4 | use std::{ 5 | fs, io, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use tempfile::TempDir; 10 | use thiserror::Error; 11 | use uuid::Uuid; 12 | 13 | mod macros { 14 | macro_rules! path_string { 15 | ($path: expr) => {{ 16 | #[cfg(unix)] 17 | { 18 | String::from($path) 19 | } 20 | #[cfg(windows)] 21 | { 22 | format!("C:{}", $path).replace("/", "\\") 23 | } 24 | }}; 25 | } 26 | 27 | macro_rules! system_path { 28 | ($path: expr) => {{ 29 | let path = crate::test::path_string!($path); 30 | crate::paths::SystemPath::try_from(std::path::PathBuf::from(path)).unwrap() 31 | }}; 32 | } 33 | 34 | macro_rules! relative_path { 35 | ($path: expr) => { 36 | crate::paths::RelativePath::try_from(std::path::PathBuf::from($path)).unwrap() 37 | }; 38 | } 39 | 40 | pub(crate) use path_string; 41 | pub(crate) use relative_path; 42 | pub(crate) use system_path; 43 | } 44 | 45 | pub(crate) use macros::{path_string, relative_path, system_path}; 46 | 47 | #[derive(Debug, Error)] 48 | #[allow(variant_size_differences)] 49 | pub enum Error { 50 | #[error("I/O error: {0}")] 51 | IO(#[from] io::Error), 52 | #[error("failed to parse UUID: {0}")] 53 | Uuid(#[from] uuid::Error), 54 | } 55 | 56 | #[derive(Debug)] 57 | pub struct Tester { 58 | config_dir: TempDir, 59 | data_dir: TempDir, 60 | } 61 | 62 | impl Tester { 63 | /// Create a new `Tester`. 64 | /// 65 | /// This creates temporary config and data directories and uses the `HOARD_*_DIR` environment 66 | /// variables to override the directories. 67 | /// 68 | /// The temporary directories will be cleaned up when this `Tester` is dropped. 69 | /// 70 | /// # Errors 71 | /// 72 | /// Any I/O errors while creating the temporary directories. 73 | pub fn new() -> io::Result { 74 | let config_dir = TempDir::new()?; 75 | let data_dir = TempDir::new()?; 76 | 77 | std::env::set_var("HOARD_DATA_DIR", data_dir.path()); 78 | std::env::set_var("HOARD_CONFIG_DIR", config_dir.path()); 79 | 80 | Ok(Self { 81 | config_dir, 82 | data_dir, 83 | }) 84 | } 85 | 86 | /// Returns the overridden config directory. 87 | #[must_use] 88 | pub fn config_dir(&self) -> &Path { 89 | self.config_dir.path() 90 | } 91 | 92 | /// Returns the overridden data directory. 93 | #[must_use] 94 | pub fn data_dir(&self) -> &Path { 95 | self.data_dir.path() 96 | } 97 | 98 | /// Returns the path to the UUID file 99 | #[must_use] 100 | pub fn uuid_path(&self) -> PathBuf { 101 | self.config_dir().join("uuid") 102 | } 103 | 104 | /// Return the current system UUID. 105 | /// 106 | /// # Errors 107 | /// 108 | /// - I/O errors while reading the UUID file. 109 | /// - Errors while parsing the file contents as a UUID string. 110 | pub fn uuid(&self) -> Result { 111 | let data = fs::read_to_string(self.uuid_path())?; 112 | data.parse::().map_err(Error::from) 113 | } 114 | 115 | /// Writes the given UUID as a string to the UUID file. 116 | /// 117 | /// # Errors 118 | /// 119 | /// Any I/O errors that may occur while writing. 120 | pub fn set_uuid(&self, id: &Uuid) -> io::Result<()> { 121 | fs::write(self.uuid_path(), id.as_hyphenated().to_string()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/common/file.rs: -------------------------------------------------------------------------------- 1 | use std::fs::Metadata; 2 | use std::fs::Permissions; 3 | #[cfg(unix)] 4 | use std::os::unix::fs::PermissionsExt; 5 | use std::path::Path; 6 | use tokio::fs::File; 7 | 8 | use hoard::checksum::Checksum; 9 | use hoard::hoard_item::HoardItem; 10 | use tempfile::{NamedTempFile, TempDir}; 11 | use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; 12 | 13 | pub fn get_temp_file() -> NamedTempFile { 14 | NamedTempFile::new().expect("failed to create temp file") 15 | } 16 | 17 | pub fn get_temp_dir() -> TempDir { 18 | TempDir::new().expect("failed to create temp dir") 19 | } 20 | 21 | async fn get_metadata(left: &File, right: &File) -> (Metadata, Metadata) { 22 | let left_meta = left 23 | .metadata() 24 | .await 25 | .expect("failed to get metadata for left file"); 26 | let right_meta = right 27 | .metadata() 28 | .await 29 | .expect("failed to get metadata for right file"); 30 | 31 | (left_meta, right_meta) 32 | } 33 | 34 | pub async fn assert_eq_files(left: &mut File, right: &mut File) { 35 | assert_eq_file_types(left, right).await; 36 | assert_eq_file_permissions(left, right).await; 37 | assert_eq_file_contents(left, right).await; 38 | } 39 | 40 | pub async fn assert_eq_file_types(left: &File, right: &File) { 41 | let (left_meta, right_meta) = get_metadata(left, right).await; 42 | assert_eq!( 43 | left_meta.file_type(), 44 | right_meta.file_type(), 45 | "files are not the samee type (dir, file, symlink)" 46 | ); 47 | } 48 | 49 | pub async fn assert_eq_file_contents(left: &mut File, right: &mut File) { 50 | let (left_meta, right_meta) = get_metadata(left, right).await; 51 | assert_eq!( 52 | left_meta.len(), 53 | right_meta.len(), 54 | "files are not the same length" 55 | ); 56 | 57 | // Ensure seek to beginning of file 58 | left.seek(SeekFrom::Start(0)) 59 | .await 60 | .expect("failed to seek to beginning of left file (beginning)"); 61 | right 62 | .seek(SeekFrom::Start(0)) 63 | .await 64 | .expect("failed to seek to beginning of right file (beginning)"); 65 | 66 | // Create iterator over bytes 67 | let (mut left_bytes, mut right_bytes) = (Vec::new(), Vec::new()); 68 | left.read_to_end(&mut left_bytes).await; 69 | right.read_to_end(&mut right_bytes).await; 70 | assert_eq!(left_bytes, right_bytes, "file contents differ"); 71 | 72 | // Return to beginning of file before returning 73 | left.seek(SeekFrom::Start(0)) 74 | .await 75 | .expect("failed to seek to beginning of left file (end)"); 76 | right 77 | .seek(SeekFrom::Start(0)) 78 | .await 79 | .expect("failed to seek to beginning of right file (end)"); 80 | } 81 | 82 | #[cfg(not(unix))] 83 | fn assert_mode(left_perm: &Permissions, right_perm: &Permissions) {} 84 | 85 | #[cfg(unix)] 86 | fn assert_mode(left_perm: &Permissions, right_perm: &Permissions) { 87 | assert_eq!( 88 | left_perm.mode(), 89 | right_perm.mode(), 90 | "Unix file modes differ" 91 | ); 92 | } 93 | 94 | pub async fn assert_eq_file_permissions(left: &File, right: &File) { 95 | let (left_meta, right_meta) = get_metadata(left, right).await; 96 | 97 | let left_perm = left_meta.permissions(); 98 | let right_perm = right_meta.permissions(); 99 | 100 | // The only permission currently available on all systems 101 | assert_eq!( 102 | left_perm.readonly(), 103 | right_perm.readonly(), 104 | "exactly one of the files is readonly" 105 | ); 106 | 107 | assert_mode(&left_perm, &right_perm); 108 | } 109 | -------------------------------------------------------------------------------- /src/checksum/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::module_name_repetitions)] 2 | //! Module for handling checksums. 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | mod digest; 6 | 7 | pub use self::digest::{MD5, SHA256}; 8 | 9 | /// The types of checksums supported by Hoard. 10 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Copy, Clone, Serialize, Deserialize)] 11 | #[serde(rename_all = "lowercase")] 12 | pub enum ChecksumType { 13 | /// MD5 checksum -- provided for backwards compatibility with older versions of Hoard. 14 | MD5, 15 | /// SHA256 checksum -- currently the default. 16 | SHA256, 17 | } 18 | 19 | impl Default for ChecksumType { 20 | fn default() -> Self { 21 | Self::SHA256 22 | } 23 | } 24 | 25 | /// A file's checksum as a human-readable string. 26 | /// 27 | /// If you have a choice of which variant to construct, 28 | /// prefer using [`HoardItem::system_checksum`](crate::hoard_item::HoardItem::system_checksum) 29 | /// or [`HoardItem::hoard_checksum`](crate::hoard_item::HoardItem::system_checksum) with the 30 | /// return value of [`ChecksumType::default()`] 31 | /// 32 | /// # TODO 33 | /// 34 | /// - Ensure that the contained values can never be invalid for the associated checksum type. 35 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] 36 | #[serde(rename_all = "lowercase")] 37 | pub enum Checksum { 38 | /// An MD5 checksum -- provided for backwards compatibility with older versions of Hoard. 39 | MD5(MD5), 40 | /// A SHA256 checksum -- currently the default. 41 | SHA256(SHA256), 42 | } 43 | 44 | impl Checksum { 45 | /// Returns the [`ChecksumType`] for this `Checksum`. 46 | /// 47 | /// ``` 48 | /// # use hoard::checksum::{Checksum, ChecksumType}; 49 | /// let checksum = Checksum::SHA256("50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c".parse().unwrap()); 50 | /// assert_eq!(checksum.typ(), ChecksumType::SHA256); 51 | /// let checksum = Checksum::MD5("ae2b1fca515949e5d54fb22b8ed95575".parse().unwrap()); 52 | /// assert_eq!(checksum.typ(), ChecksumType::MD5); 53 | /// ``` 54 | #[must_use] 55 | pub fn typ(&self) -> ChecksumType { 56 | match self { 57 | Self::MD5(_) => ChecksumType::MD5, 58 | Self::SHA256(_) => ChecksumType::SHA256, 59 | } 60 | } 61 | } 62 | 63 | impl fmt::Display for Checksum { 64 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 65 | match self { 66 | Self::MD5(md5) => write!(f, "md5({md5})"), 67 | Self::SHA256(sha256) => write!(f, "sha256({sha256})"), 68 | } 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod tests { 74 | use super::*; 75 | 76 | #[test] 77 | fn test_checksum_display() { 78 | let shasum = "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c"; 79 | let checksum = Checksum::SHA256(shasum.parse().unwrap()); 80 | assert_eq!(format!("sha256({shasum})"), checksum.to_string()); 81 | let md5sum = "ae2b1fca515949e5d54fb22b8ed95575"; 82 | let checksum = Checksum::MD5(md5sum.parse().unwrap()); 83 | assert_eq!(format!("md5({md5sum})"), checksum.to_string()); 84 | } 85 | 86 | #[test] 87 | fn test_checksum_type() { 88 | assert_eq!( 89 | ChecksumType::MD5, 90 | Checksum::MD5("ae2b1fca515949e5d54fb22b8ed95575".parse().unwrap()).typ() 91 | ); 92 | assert_eq!( 93 | ChecksumType::SHA256, 94 | Checksum::SHA256( 95 | "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c" 96 | .parse() 97 | .unwrap() 98 | ) 99 | .typ() 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/diff.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for checking whether files differ. 2 | //! 3 | //! Unified diffs are optionally available for text files. Following Git's example, 4 | //! non-text binary files can only be detected as differing or the same. 5 | use std::path::Path; 6 | use tokio::{fs, io, io::AsyncReadExt}; 7 | 8 | use crate::paths::{HoardPath, SystemPath}; 9 | use similar::{ChangeTag, TextDiff}; 10 | 11 | const CONTEXT_RADIUS: usize = 5; 12 | 13 | /// Represents the existing file content for a given file. 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 15 | pub enum FileContent { 16 | Text(String), 17 | Binary(Vec), 18 | Missing, 19 | } 20 | 21 | impl FileContent { 22 | #[tracing::instrument(level = "debug")] 23 | pub async fn read(mut file: fs::File) -> io::Result { 24 | let mut bytes = Vec::new(); 25 | file.read_to_end(&mut bytes).await?; 26 | match String::from_utf8(bytes) { 27 | Ok(s) => Ok(Self::Text(s)), 28 | Err(err) => Ok(Self::Binary(err.into_bytes())), 29 | } 30 | } 31 | 32 | #[tracing::instrument(level = "debug")] 33 | pub async fn read_path(path: &Path) -> io::Result { 34 | match fs::File::open(path).await { 35 | Ok(file) => FileContent::read(file).await, 36 | Err(err) => match err.kind() { 37 | io::ErrorKind::NotFound => Ok(FileContent::Missing), 38 | _ => Err(err), 39 | }, 40 | } 41 | } 42 | 43 | #[tracing::instrument(level = "debug")] 44 | pub fn as_bytes(&self) -> Option<&[u8]> { 45 | match self { 46 | Self::Text(s) => Some(s.as_bytes()), 47 | Self::Binary(v) => Some(v.as_slice()), 48 | Self::Missing => None, 49 | } 50 | } 51 | } 52 | 53 | #[allow(variant_size_differences)] 54 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 55 | pub enum Diff { 56 | /// Text content differs. Contains the generated unified diff. 57 | Text(String), 58 | /// Binary content differs. Also occurs if a file changes between text and binary formats. 59 | Binary, 60 | /// The left path to `diff_files` did not exist, but the right path did. 61 | HoardNotExists, 62 | /// The left path to `diff_paths` existed, but the right path did not. 63 | SystemNotExists, 64 | } 65 | 66 | pub(crate) fn str_diff( 67 | (hoard_path, hoard_text): (&HoardPath, &str), 68 | (system_path, system_text): (&SystemPath, &str), 69 | ) -> Option { 70 | let text_diff = TextDiff::from_lines(hoard_text, system_text); 71 | 72 | let has_diff = text_diff 73 | .iter_all_changes() 74 | .any(|op| op.tag() != ChangeTag::Equal); 75 | 76 | if has_diff { 77 | let udiff = text_diff 78 | .unified_diff() 79 | .context_radius(CONTEXT_RADIUS) 80 | .header( 81 | &hoard_path.to_string_lossy(), 82 | &system_path.to_string_lossy(), 83 | ) 84 | .to_string(); 85 | Some(Diff::Text(udiff)) 86 | } else { 87 | None 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod test { 93 | use super::*; 94 | 95 | mod file_content { 96 | use super::*; 97 | 98 | #[test] 99 | fn test_text_into_bytes() { 100 | let string_content = String::from("text content"); 101 | let s = FileContent::Text(string_content.clone()); 102 | assert_eq!(s.as_bytes(), Some(string_content.as_bytes())); 103 | } 104 | 105 | #[test] 106 | fn test_binary_into_bytes() { 107 | let bytes = vec![23u8, 244u8, 0u8, 12u8, 17u8]; 108 | let b = FileContent::Binary(bytes.clone()); 109 | assert_eq!(b.as_bytes(), Some(bytes.as_slice())); 110 | } 111 | 112 | #[test] 113 | fn test_missing_into_bytes() { 114 | assert_eq!(FileContent::Missing.as_bytes(), None); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /book/src/getting-started/create-config/vim.md: -------------------------------------------------------------------------------- 1 | # Example: Vim and Neovim 2 | 3 | This example explores the following concepts: 4 | 5 | - Multiple named piles in a single hoard 6 | - Mutually exclusive environments 7 | - Ignoring files by glob pattern 8 | 9 | For simplicity, this example assumes only one operating system (Linux) will ever be used. For an example that defines 10 | paths based on operating system, see the [Hoard Config](./hoard.md) example. 11 | 12 | ## 1. Choose files to back up 13 | 14 | While a Vim configuration can live inside a single file, we will consider a situation where there is a directory 15 | called `config` whose contents are included into the main file with the following code: 16 | 17 | ```vimscript 18 | if has('nvim') 19 | runtime! config/*.vim 20 | else 21 | runtime! ~/.vim/config 22 | endif 23 | ``` 24 | 25 | In this situation, the configuration files are found in the following locations: 26 | 27 | - Vim: 28 | - Config entrypoint: `${HOME}/.vimrc` 29 | - `config` directory: `${HOME}/.vim/config` 30 | - Neovim: 31 | - Config entrypoint: `${XDG_CONFIG_HOME}/nvim/init.vim` 32 | - `config` directory: `${XDG_CONFIG_HOME}/nvim/config` 33 | 34 | ## 1.1. Choose files to ignore 35 | 36 | For sake of example, let's suppose the `config` directory contains a number of old, unused files whose names 37 | end with `.backup`. You're going to get around to deleting them eventually, but they might have code you want 38 | to keep, just not backed up. 39 | 40 | ## 2. Add configuration for those files 41 | 42 | As stated above, for simplicity we are assuming that Linux is the only operating system being used -- if it were 43 | not, we would need to figure out the paths for other operating systems and include configuration conditional on 44 | that. Since we are not worried about that, though, the only environments we care about are whether Vim and/or 45 | Neovim are installed: 46 | 47 | ```toml 48 | [envs] 49 | # Checks for CLI Vim *or* GUI (Gtk+) Vim 50 | vim = { exe_exists = ["vim", "gvim"] } 51 | # Checks for CLI Neovim *or* GUI (Qt) Neovim 52 | neovim = { exe_exists = ["nvim", "nvim-qt"] } 53 | ``` 54 | 55 | Since it is possible for both Vim and Neovim to be installed on the same system, we need to tell Hoard which 56 | one to prioritize. In this case, we will prioritize Neovim: 57 | 58 | ```toml 59 | exclusivity = [ 60 | ["neovim", "vim"] 61 | ] 62 | 63 | [envs] 64 | # Checks for CLI Vim *or* GUI (Gtk+) Vim 65 | vim = { exe_exists = ["vim", "gvim"] } 66 | # Checks for CLI Neovim *or* GUI (Qt) Neovim 67 | neovim = { exe_exists = ["nvim", "nvim-qt"] } 68 | ``` 69 | 70 | Finally, define the actual hoard. We'll call it `vim`: 71 | 72 | ```toml 73 | [hoards] 74 | [hoards.vim] 75 | [hoards.vim.config] 76 | # This is the configuration for the vim hoard and is include for 77 | # demonstration only. For this example, you should use the config 78 | # *inside* the config_dir pile instead. 79 | ignore = ["**/*.backup"] 80 | [hoards.vim.init] 81 | "vim" = "${HOME}/.vimrc" 82 | "neovim" = "${XDG_CONFIG_DIR}/nvim/init.vim" 83 | [hoards.vim.config_dir] 84 | # This is configuration just for the vim.config_dir pile 85 | config = { ignore = ["**/*.backup"] } 86 | "vim" = "${HOME}/.vim/config" 87 | "neovim" = "${XDG_CONFIG_DIR}/nvim/config" 88 | ``` 89 | 90 | **NOTE:** The name `config` is reserved for [hoard/pile configuration](../../config/hoards-piles.md#pile-configuration) 91 | and cannot be used as the name of a hoard or pile. This is why the name `config_dir` is used above: using `config` would 92 | conflict with the hoard-level configuration block. 93 | 94 | We use the glob pattern `**/*.backup` above to indicate that any file in any subdirectory of `config/` with suffix 95 | `.backup` should be ignored. Use `*.backup` for top-level files only. 96 | 97 | ## 3. Do an initial backup 98 | 99 | You can now run `hoard backup vim` to back up your Vim/Neovim configuration, and `hoard restore vim` to restore the 100 | latest backup. 101 | -------------------------------------------------------------------------------- /src/command/edit.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{stderr, stdin, stdout, IsTerminal}, 3 | path::{Path, PathBuf}, 4 | process::ExitStatus, 5 | }; 6 | 7 | use tap::TapFallible; 8 | use thiserror::Error; 9 | use tokio::{fs, io}; 10 | 11 | use super::DEFAULT_CONFIG; 12 | 13 | /// Errors that may occur while running the edit command. 14 | #[derive(Debug, Error)] 15 | #[allow(variant_size_differences)] 16 | pub enum Error { 17 | /// An error occurred while trying to start the editor. 18 | #[error("failed to start editor: {0}")] 19 | Start(#[from] open_cmd::Error), 20 | /// The editor exited with an error status. 21 | #[error("editor exited with failure status: {0}")] 22 | Exit(ExitStatus), 23 | /// An I/O error occurred while working with the temporary file. 24 | #[error("an I/O error occurred while setting up the temporary file: {0}")] 25 | IO(#[from] io::Error), 26 | /// A directory was provided as the configuration file path. 27 | #[error("expected a configuration file, found a directory: {0}")] 28 | IsDirectory(PathBuf), 29 | } 30 | 31 | /// Edit the configuration file at `path`. 32 | /// 33 | /// This function: 34 | /// 35 | /// 1. Creates a temporary file by either copying the existing file at `path` or, if 36 | /// the file does not exist, populating it with the example configuration. 37 | /// 2. Opens the file... 38 | /// 1. In `$EDITOR` if the variable exists and `hoard` is running in a terminal. 39 | /// 2. Or in the system default graphical editor for the file 40 | /// 3. If the editor process exits without failure... 41 | /// 1. The temporary file is copied to the given `path`. 42 | /// 4. The temporary file is deleted. 43 | /// 44 | /// # Errors 45 | /// 46 | /// See [`Error`]. 47 | #[tracing::instrument] 48 | pub(crate) async fn run_edit(path: &Path) -> Result<(), super::Error> { 49 | let tmp_dir = tempfile::tempdir().map_err(Error::IO).tap_err(|error| { 50 | tracing::error!(%error, "failed to create temporary file for editing"); 51 | })?; 52 | let tmp_file = tmp_dir.path().join( 53 | path.file_name() 54 | .ok_or_else(|| Error::IsDirectory(path.to_path_buf()))?, 55 | ); 56 | 57 | if path.exists() { 58 | fs::copy(path, &tmp_file).await.map_err(|error| { 59 | tracing::error!(%error, "failed to copy config file ({}) to temporary file ({})", path.display(), tmp_file.display()); 60 | Error::IO(error) 61 | })?; 62 | } else { 63 | fs::write(&tmp_file, DEFAULT_CONFIG.as_bytes()) 64 | .await 65 | .map_err(|error| { 66 | tracing::error!(%error, "failed to write default sample config to temporary file ({})", tmp_file.display()); 67 | Error::IO(error) 68 | })?; 69 | } 70 | 71 | let mut cmd = if stdin().is_terminal() && stderr().is_terminal() && stdout().is_terminal() { 72 | open_cmd::open_editor(tmp_file.clone()).map_err(|error| { 73 | tracing::error!(%error, "failed to generate CLI editor command"); 74 | Error::Start(error) 75 | })? 76 | } else { 77 | open_cmd::open(tmp_file.clone()).map_err(|error| { 78 | tracing::error!(%error, "failed to generate editor command"); 79 | Error::Start(error) 80 | })? 81 | }; 82 | 83 | let status = cmd.status().map_err(|error| { 84 | tracing::error!(%error, "failed to run editor command"); 85 | super::Error::Edit(Error::Start(open_cmd::Error::from(error))) 86 | })?; 87 | 88 | if status.success() { 89 | tracing::debug!("editing exited without error, copying temporary file back to original"); 90 | fs::copy(&tmp_file, path).await.map_err(|error| { 91 | tracing::error!(%error, "failed to copy temporary file ({}) to config file location ({})", tmp_file.display(), path.display()); 92 | Error::IO(error) 93 | })?; 94 | } else { 95 | tracing::error!("edit command exited with status {}", status); 96 | return Err(super::Error::Edit(Error::Exit(status))); 97 | } 98 | 99 | Ok(()) 100 | } 101 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A CLI program for managing files across multiple devices. 2 | //! 3 | //! You can think of `hoard` as a dotfiles management tool, though its intended use extends 4 | //! beyond that. `hoard` can be used for backing up and restoring any kind of file from/to any 5 | //! location on the filesystem. In fact, the original purpose behind writing it was to synchronize 6 | //! save files for games that don't support cloud saves. 7 | //! 8 | //! # Terminology 9 | //! 10 | //! The following terms have special meanings when talking about `hoard`. 11 | //! 12 | //! - [`Hoard`](crate::config::builder::hoard::Hoard): A collection at least one 13 | //! [`Pile`](crate::config::builder::hoard::Pile). 14 | //! - [`Pile`](crate::config::builder::hoard::Pile): A single file or directory in a 15 | //! [`Hoard`](crate::config::builder::hoard::Hoard). 16 | //! - [`Environment`](crate::config::builder::environment::Environment): A combination of conditions 17 | //! that can be used to determine where to find files in a [`Pile`](crate::config::builder::hoard::Pile). 18 | 19 | #![deny(clippy::all)] 20 | #![deny(clippy::correctness)] 21 | #![deny(clippy::style)] 22 | #![deny(clippy::complexity)] 23 | #![deny(clippy::perf)] 24 | #![deny(clippy::pedantic)] 25 | // See https://github.com/rust-lang/rust/issues/87858 26 | //#![deny(rustdoc::missing_doc_code_examples)] 27 | #![deny( 28 | absolute_paths_not_starting_with_crate, 29 | anonymous_parameters, 30 | bad_style, 31 | dead_code, 32 | keyword_idents, 33 | improper_ctypes, 34 | macro_use_extern_crate, 35 | meta_variable_misuse, // May have false positives 36 | missing_abi, 37 | missing_debug_implementations, // can affect compile time/code size 38 | missing_docs, 39 | no_mangle_generic_items, 40 | non_shorthand_field_patterns, 41 | noop_method_call, 42 | overflowing_literals, 43 | path_statements, 44 | patterns_in_fns_without_body, 45 | semicolon_in_expressions_from_macros, 46 | single_use_lifetimes, 47 | trivial_casts, 48 | trivial_numeric_casts, 49 | unconditional_recursion, 50 | unreachable_pub, 51 | unsafe_code, 52 | unused, 53 | unused_allocation, 54 | unused_comparisons, 55 | unused_extern_crates, 56 | unused_import_braces, 57 | unused_lifetimes, 58 | unused_parens, 59 | unused_qualifications, 60 | variant_size_differences, 61 | while_true 62 | )] 63 | 64 | pub use config::Config; 65 | 66 | pub mod checkers; 67 | pub mod checksum; 68 | pub mod combinator; 69 | pub mod command; 70 | pub mod config; 71 | pub(crate) mod diff; 72 | pub mod dirs; 73 | pub mod env_vars; 74 | pub mod filters; 75 | pub mod hoard; 76 | pub mod hoard_item; 77 | pub mod logging; 78 | pub mod newtypes; 79 | pub mod paths; 80 | pub mod test; 81 | 82 | /// The default file stem of the configuration file (i.e. without file extension). 83 | pub const CONFIG_FILE_STEM: &str = "config"; 84 | 85 | /// The name of the directory containing the backed up hoards. 86 | pub const HOARDS_DIR_SLUG: &str = "hoards"; 87 | 88 | #[inline] 89 | pub(crate) fn tap_log_error(error: &E) { 90 | tracing::error!(%error); 91 | } 92 | 93 | #[inline] 94 | pub(crate) fn tap_log_error_msg(msg: &'_ str) -> impl Fn(&E) + '_ { 95 | move |error| { 96 | tracing::error!(%error, "{}", msg); 97 | } 98 | } 99 | 100 | #[inline] 101 | pub(crate) fn create_log_error(error: E) -> Result { 102 | tap_log_error(&error); 103 | Err(error) 104 | } 105 | 106 | #[inline] 107 | pub(crate) fn create_log_error_msg(msg: &str, error: E) -> Result { 108 | tap_log_error_msg(msg)(&error); 109 | Err(error) 110 | } 111 | 112 | pub(crate) fn map_log_error( 113 | map: impl Fn(E1) -> E2, 114 | ) -> impl Fn(E1) -> E2 { 115 | move |error| { 116 | let error = map(error); 117 | tap_log_error(&error); 118 | error 119 | } 120 | } 121 | 122 | pub(crate) fn map_log_error_msg<'m, E1: std::error::Error, E2: std::error::Error>( 123 | msg: &'m str, 124 | map: impl Fn(E1) -> E2 + 'm, 125 | ) -> impl Fn(E1) -> E2 + 'm { 126 | move |error| { 127 | let error = map(error); 128 | tap_log_error_msg(msg)(&error); 129 | error 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/config/builder/environment/envvar.rs: -------------------------------------------------------------------------------- 1 | //! See [`EnvVariable`]. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::convert::{Infallible, TryInto}; 5 | use std::fmt; 6 | use std::fmt::Formatter; 7 | 8 | /// A conditional structure that checks if the given environment variable exists and optionally if 9 | /// it is set to a specific value. 10 | #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Hash)] 11 | pub struct EnvVariable { 12 | /// The variable to check. 13 | pub var: String, 14 | /// The expected value to check against. If `None`, this matches any value. 15 | pub expected: Option, 16 | } 17 | 18 | impl TryInto for EnvVariable { 19 | type Error = Infallible; 20 | 21 | fn try_into(self) -> Result { 22 | let EnvVariable { var, expected } = self; 23 | tracing::trace!(%var, "checking if environment variable exists"); 24 | let result = match std::env::var_os(&var) { 25 | None => false, 26 | Some(val) => match expected { 27 | None => true, 28 | Some(expected) => { 29 | tracing::trace!(%var, %expected, "checking if variable matches expected value"); 30 | val == expected.as_str() 31 | } 32 | }, 33 | }; 34 | Ok(result) 35 | } 36 | } 37 | 38 | // For use in displaying in boolean strings 39 | impl fmt::Display for EnvVariable { 40 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 41 | match &self.expected { 42 | None => write!(f, "ENV ${{{}}} IS SET", self.var), 43 | Some(expected) => write!(f, "ENV ${{{}}} == \"{}\"", self.var, expected), 44 | } 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | 52 | #[test] 53 | fn test_display_env_no_value() { 54 | let env = EnvVariable { 55 | var: "TESTING_VAR".to_string(), 56 | expected: None, 57 | }; 58 | assert_eq!("ENV ${TESTING_VAR} IS SET", env.to_string()); 59 | } 60 | 61 | #[test] 62 | fn test_display_env_with_value() { 63 | let env = EnvVariable { 64 | var: "TESTING_VAR".to_string(), 65 | expected: Some("testing value".to_string()), 66 | }; 67 | assert_eq!("ENV ${TESTING_VAR} == \"testing value\"", env.to_string()); 68 | } 69 | 70 | #[test] 71 | fn test_env_variable_is_set() { 72 | let var = String::from("HOARD_ENV_IS_SET"); 73 | std::env::set_var(&var, "true"); 74 | let is_set: bool = EnvVariable { 75 | var, 76 | expected: None, 77 | } 78 | .try_into() 79 | .expect("failed to check environment variable"); 80 | assert!(is_set); 81 | } 82 | 83 | #[test] 84 | fn test_env_variable_is_set_to_value() { 85 | let var = String::from("HOARD_ENV_IS_SET_TO"); 86 | let value = String::from("set to this"); 87 | std::env::set_var(&var, &value); 88 | let is_set: bool = EnvVariable { 89 | var, 90 | expected: Some(value), 91 | } 92 | .try_into() 93 | .expect("failed to check environment variable"); 94 | assert!(is_set); 95 | } 96 | 97 | #[test] 98 | fn test_env_variable_is_not_set() { 99 | let var = String::from("HOARD_ENV_NOT_SET"); 100 | assert!( 101 | std::env::var_os(&var).is_none(), 102 | "env var {var} should not be set" 103 | ); 104 | let is_set: bool = EnvVariable { 105 | var, 106 | expected: None, 107 | } 108 | .try_into() 109 | .expect("failed to check environment variable"); 110 | assert!(!is_set); 111 | } 112 | 113 | #[test] 114 | fn test_env_variable_is_not_set_to_value() { 115 | let var = String::from("HOARD_ENV_WRONG_VALUE"); 116 | std::env::set_var(&var, "unexpected value"); 117 | let is_set: bool = EnvVariable { 118 | var, 119 | expected: Some(String::from("wrong value")), 120 | } 121 | .try_into() 122 | .expect("failed to check environment variable"); 123 | assert!(!is_set); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/dirs/win.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | use std::os::windows::ffi::{OsStrExt, OsStringExt}; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use windows::core::{Result as WinResult, GUID, PCWSTR, PWSTR}; 6 | use windows::Win32::Foundation::HANDLE; 7 | use windows::Win32::UI::Shell::{FOLDERID_Profile, FOLDERID_RoamingAppData}; 8 | use windows::Win32::UI::Shell::{SHGetKnownFolderPath, SHSetKnownFolderPath, KF_FLAG_CREATE}; 9 | 10 | use super::{path_from_env, COMPANY, PROJECT}; 11 | 12 | #[allow(unsafe_code)] 13 | fn pwstr_len(pwstr: PWSTR) -> usize { 14 | unsafe { 15 | // Not entirely sure if this is correct, but it should be: 16 | // - The string is always returned from another Windows API 17 | // - `as_wide` converts into a slice of u16 without '\0' 18 | // - AFAICT, there are no multi-u16 characters 19 | pwstr.as_wide().len() 20 | } 21 | } 22 | 23 | /// Get a Windows "Known Folder" by id. 24 | /// 25 | /// All ids can be found under [`windows::Win32::UI::Shell`] as `FOLDERID_{Name}`. 26 | /// 27 | /// This crate uses and re-exports [`FOLDERID_Profile`] and [`FOLDERID_RoamingAppData`]. 28 | /// 29 | /// # Errors 30 | /// 31 | /// This function will error if [`SHGetKnownFolderPath`] does. See the 32 | /// [official Microsoft docs](https://docs.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shgetknownfolderpath#return-value) 33 | /// for more. 34 | #[allow(unsafe_code)] 35 | pub fn get_known_folder(folder_id: GUID) -> WinResult { 36 | unsafe { 37 | SHGetKnownFolderPath(&folder_id, KF_FLAG_CREATE, HANDLE(std::ptr::null_mut())).map( 38 | |pwstr| { 39 | let slice = std::slice::from_raw_parts(pwstr.0, pwstr_len(pwstr)); 40 | PathBuf::from(OsString::from_wide(slice)) 41 | }, 42 | ) 43 | } 44 | } 45 | 46 | /// Set a Windows "Known Folder" by id. 47 | /// 48 | /// All ids can be found under [`windows::Win32::UI::Shell`] as `FOLDERID_{Name}`. 49 | /// 50 | /// This crate uses and re-exports [`FOLDERID_Profile`] and [`FOLDERID_RoamingAppData`]. 51 | /// 52 | /// # Errors 53 | /// 54 | /// This function will error if [`SHSetKnownFolderPath`] does. See the 55 | /// [official Microsoft docs](https://docs.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shsetknownfolderpath#return-value) 56 | /// for more. 57 | #[allow(unsafe_code)] 58 | pub fn set_known_folder(folder_id: GUID, new_path: &Path) -> WinResult<()> { 59 | unsafe { 60 | let new_path: Vec = new_path.as_os_str().encode_wide().chain([0]).collect(); 61 | let new_path = PCWSTR(new_path.as_ptr()); 62 | SHSetKnownFolderPath(&folder_id, 0, HANDLE(std::ptr::null_mut()), new_path) 63 | } 64 | } 65 | 66 | macro_rules! get_and_log_known_folder { 67 | ($id: ident) => {{ 68 | tracing::trace!("attempting to get known folder {}", std::stringify!($id)); 69 | get_known_folder($id) 70 | }}; 71 | } 72 | 73 | #[must_use] 74 | #[tracing::instrument(level = "trace")] 75 | pub(super) fn home_dir() -> PathBuf { 76 | get_and_log_known_folder!(FOLDERID_Profile) 77 | .ok() 78 | .or_else(|| path_from_env("USERPROFILE")) 79 | .expect("could not determine user home directory") 80 | } 81 | 82 | #[inline] 83 | #[tracing::instrument(level = "trace")] 84 | fn appdata() -> PathBuf { 85 | get_and_log_known_folder!(FOLDERID_RoamingAppData) 86 | .ok() 87 | .or_else(|| path_from_env("APPDATA")) 88 | .unwrap_or_else(|| home_dir().join("AppData").join("Roaming")) 89 | .join(COMPANY) 90 | .join(PROJECT) 91 | } 92 | 93 | #[must_use] 94 | #[tracing::instrument(level = "trace")] 95 | pub(super) fn config_dir() -> PathBuf { 96 | appdata().join("config") 97 | } 98 | 99 | #[must_use] 100 | #[tracing::instrument(level = "trace")] 101 | pub(super) fn data_dir() -> PathBuf { 102 | appdata().join("data") 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use super::*; 108 | 109 | #[test] 110 | fn get_known_folder_works_correctly() { 111 | let known_home = get_known_folder(FOLDERID_Profile).unwrap(); 112 | let env_home = std::env::var_os("USERPROFILE").map(PathBuf::from).unwrap(); 113 | assert_eq!(known_home, env_home); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [tasks.clean-all] 2 | script = """ 3 | cargo clean 4 | rm -rf profraw 5 | """ 6 | 7 | [tasks.create-profraw-dir] 8 | command = "mkdir" 9 | args = ["-p", "./profraw"] 10 | 11 | [tasks.install-stable] 12 | ignore_errors = true 13 | command = "rustup" 14 | args = ["toolchain", "install", "stable", "--component", "llvm-tools-preview"] 15 | 16 | [tasks.build-stable] 17 | install_crate = false 18 | dependencies = ["install-stable"] 19 | command = "cargo" 20 | args = ["+stable", "build", "--target", "x86_64-unknown-linux-musl"] 21 | [tasks.build-stable.env] 22 | RUSTFLAGS="-Cinstrument-coverage" 23 | LLVM_PROFILE_FILE="profraw/hoard-python-test-%p-%m.profraw" 24 | 25 | [tasks.deny] 26 | command = "cargo" 27 | args = ["deny", "check"] 28 | 29 | [tasks.test-nextest] 30 | dependencies = ["clean-all", "install-stable", "create-profraw-dir"] 31 | command = "cargo" 32 | args = ["nextest", "run"] 33 | [tasks.test-nextest.env] 34 | RUSTFLAGS="-Cinstrument-coverage -Copt-level=0 -Ccodegen-units=1" 35 | LLVM_PROFILE_FILE="profraw/hoard-cargo-test-%p-%m.profraw" 36 | 37 | [tasks.test-single-thread] 38 | install_crate = false 39 | dependencies = ["clean-all", "install-stable", "create-profraw-dir"] 40 | command = "cargo" 41 | args = ["+stable", "test", "--", "--test-threads=1"] 42 | [tasks.test-single-thread.env] 43 | RUSTFLAGS="-Cinstrument-coverage -Copt-level=0 -Ccodegen-units=1" 44 | LLVM_PROFILE_FILE="profraw/hoard-cargo-test-%p-%m.profraw" 45 | 46 | [tasks.docker-tests] 47 | dependencies = ["clean-all", "create-profraw-dir"] 48 | script = """ 49 | sudo docker image build . -t hoard-tests --no-cache 50 | echo "Running tests" 51 | sudo docker container run --rm -v $(pwd)/profraw:/hoard-tests/profraw:Z hoard-tests 52 | echo "Ran tests" 53 | """ 54 | 55 | [tasks.test-all] 56 | # Do docker tests first so ./target is not sent to Docker 57 | dependencies = ["docker-tests", "test-nextest"] 58 | 59 | [tasks.grcov] 60 | install_crate = { crate_name = "grcov" } 61 | dependencies = ["clean-all", "test-all"] 62 | # Using `script` is necessary to get the glob expansion 63 | script = """ 64 | grcov profraw/*.profraw --binary-path ./target/debug \ 65 | -s . -t html --branch --ignore-not-existing -o ./target/debug/coverage --ignore src/main.rs \ 66 | --excl-br-line "($EXCLUDE_DERIVE|$EXCLUDE_PANICS|$EXCLUDE_TRACING|$EXCLUDE_PROPAGATE_ERROR|$EXCLUDE_MANUAL|$EXCLUDE_LONE_CLOSING_BRACE)" \ 67 | --excl-line "($EXCLUDE_DERIVE|$EXCLUDE_PANICS|$EXCLUDE_TRACING|$EXCLUDE_PROPAGATE_ERROR|$EXCLUDE_MANUAL|$EXCLUDE_LONE_CLOSING_BRACE)" \ 68 | --excl-br-start "(grcov: ignore-start|mod tests)" --excl-start "(grcov: ignore-start|mod tests)" \ 69 | --excl-br-stop "grcov: ignore-end" --excl-stop "grcov: ignore-end" 70 | """ 71 | [tasks.grcov.env] 72 | RUSTFLAGS="-Cinstrument-coverage" 73 | RUSTUP_TOOLCHAIN="stable" 74 | HOARD_LOG="trace" 75 | EXCLUDE_DERIVE="#\\[derive\\(" 76 | EXCLUDE_PANICS="panic!|todo!|unimplemented!|unreachable!" 77 | EXCLUDE_TRACING="kjhgfdsadgjkl" #tracing::(error|warn|info|debug|trace)(_span)?!" 78 | EXCLUDE_PROPAGATE_ERROR="(return|(Err\\(err(or)?\\)|err(or)?) =>) (Some\\()?Err\\(err(or)?(\\.into\\(\\))?\\)" 79 | EXCLUDE_MANUAL="grcov: ignore" 80 | EXCLUDE_LONE_CLOSING_BRACE="^\\s*\\}\\s*$" 81 | 82 | [tasks.view-grcov] 83 | dependencies = ["clean-all", "grcov"] 84 | command = "xdg-open" 85 | args = ["./target/debug/coverage/index.html"] 86 | 87 | [tasks.book] 88 | command = "mdbook" 89 | args = ["serve", "./book"] 90 | 91 | [tasks.outdated] 92 | command = "cargo" 93 | args = ["outdated", "-R"] 94 | 95 | [tasks.deadlinks] 96 | command = "cargo" 97 | args = ["deadlinks"] 98 | 99 | [tasks.check-all] 100 | dependencies = ["clippy", "check-format", "docs", "test-nextest", "deadlinks", "deny", "msrv-verify"] 101 | 102 | [tasks.changelog] 103 | command = "git-cliff" 104 | args = ["-o", "CHANGELOG.md"] 105 | 106 | [tasks.msrv] 107 | command = "cargo" 108 | args = ["msrv"] 109 | 110 | [tasks.msrv-verify] 111 | command = "cargo" 112 | args = ["msrv", "verify"] 113 | -------------------------------------------------------------------------------- /book/src/getting-started/create-config/games.md: -------------------------------------------------------------------------------- 1 | # Example: *Mindustry* and *Death and Taxes* 2 | 3 | This example explores the following concepts: 4 | 5 | - Multiple named piles in a single hoard 6 | - Mutually exclusive environments 7 | - How to handle files for flatpak'd applications on Linux 8 | 9 | For this example, we will consider two games, installed on both Windows and Linux. Further, we will consider multiple 10 | methods of installing each game on each system. This is likely more work than one would do in practice, but this is for 11 | sake of example. 12 | 13 | # 1. Choose files to back up 14 | 15 | Generally speaking, save files are usually found in one of the following locations: 16 | 17 | - User documents 18 | - The game's installation directory 19 | - Some hidden directory (e.g. under `$XDG_CONFIG_HOME` or `$XDG_DATA_HOME` on Linux, `%APPDATA%` on Windows, etc.) 20 | 21 | Of the two games we will be using, *Mindustry* sometimes stores its saves in the installation directory, while 22 | *Death and Taxes* stores its saves in a game-specific subdirectory of a location common to all Unity games. 23 | 24 | - *Mindustry*: 25 | - Flatpak: `${HOME}/.var/app/com.github.Anuken.Mindustry/data/Mindustry/saves/saves` 26 | - Linux Itch: `${XDG_DATA_HOME}/Mindustry/saves/saves` 27 | - Linux Steam: `${XDG_DATA_HOME}/Steam/steamapps/common/Mindustry/saves/saves` 28 | - Linux Steam Flatpak: `${HOME}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Mindustry/saves/saves` 29 | - Windows Itch: `${APPDATA}/Mindustry/saves/saves` 30 | - Windows Steam: `${ProgramFiles(x86)}/Steam/steamapps/common/saves/saves` 31 | - *Death and Taxes*: 32 | - Linux: `${XDG_CONFIG_HOME}/unity3d/Placeholder Gameworks/Death and Taxes/Saves` 33 | - Windows: `${USERPROFILE}/AppData/LocalLow/Placeholder Gameworks/Death and Taxes/Saves` 34 | 35 | # 2. Add configuration for those files 36 | 37 | We'll need environments for the operating systems and for the game stores. For 38 | simplicity, we will assume that the XDG variables are always set. We'll also need to specify the order of precedence 39 | for the game stores, since all three of them could be installed at once time. 40 | 41 | > The double square brackets (`[[]]`) are used to indicate that *all* of the XDG environment variables are set. If 42 | > single square brackets (`[]`) were used, it would mean that *at least one* must be set. 43 | > 44 | > For more, see the documentation for [environments](../../config/environments.md). 45 | 46 | ```toml 47 | exclusivity = [ 48 | ["flatpak_steam", "flatpak_mindustry", "linux_steam", "linux_itch"], 49 | ["win_steam", "win_itch"], 50 | ] 51 | 52 | [envs] 53 | [envs.flatpak_mindustry] 54 | exe_exists = ["flatpak"] 55 | os = ["linux"] 56 | path_exists = [ 57 | "/var/lib/flatpak/app/com.github.Anuken.Mindustry", 58 | "${XDG_DATA_HOME}/flatpak/app/com.github.Anuken.Mindustry", 59 | ] 60 | [envs.flatpak_steam] 61 | exe_exists = ["flatpak"] 62 | os = ["linux"] 63 | path_exists = [ 64 | "/var/lib/flatpak/app/com.valvesoftware.Steam", 65 | "${XDG_DATA_HOME}/flatpak/app/com.valvesoftware.Steam", 66 | ] 67 | [envs.linux_itch] 68 | os = ["linux"] 69 | path_exists = ["${HOME}/.itch/itch"] 70 | [envs.linux_steam] 71 | os = ["linux"] 72 | exe_exists = ["steam"] 73 | [envs.win_itch] 74 | os = ["windows"] 75 | path_exists = ["${LOCALAPPDATA}/itch/itch-setup.exe"] 76 | [envs.win_steam] 77 | os = ["windows"] 78 | path_exists = ["${ProgramFiles(x86)}/Steam/steam.exe"] 79 | 80 | [hoards] 81 | [hoards.game_saves] 82 | [hoards.game_saves.death_and_taxes] 83 | "linux" = "${XDG_CONFIG_HOME}/unity3d/Placeholder Gameworks/Death and Taxes/Saves" 84 | "windows" = "${USERPROFILE}/AppData/LocalLow/Placeholder Gameworks/Death and Taxes/Saves" 85 | [hoards.game_saves.mindustry] 86 | "flatpak_mindustry" = "${HOME}/.var/app/com.github.Anuken.Mindustry/data/Mindustry/saves/saves" 87 | "flatpak_steam" = "${HOME}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Mindustry/saves/saves" 88 | "linux_itch" = "${XDG_DATA_HOME}/Mindustry/saves/saves" 89 | "linux_steam" = "${XDG_DATA_HOME}/Steam/steamapps/common/Mindustry/saves/saves" 90 | "win_itch" = "${APPDATA}/Mindustry/saves/saves" 91 | "win_steam" = "${ProgramFiles(x86)}/Steam/steamapps/common/saves/saves" 92 | ``` 93 | 94 | # 3. Do an initial backup 95 | 96 | You can now run `hoard backup game_saves` to back up the game saves, and `hoard restore game_saves` to restore them. 97 | -------------------------------------------------------------------------------- /tests/fake_editors/windows.rs: -------------------------------------------------------------------------------- 1 | use super::Editor; 2 | use registry::{key::Error as RegError, Data, Hive, Security}; 3 | use std::ffi::OsString; 4 | use std::path::{Path, PathBuf}; 5 | use tempfile::TempDir; 6 | use tokio::fs; 7 | use tokio::io::AsyncWriteExt; 8 | use tokio::runtime::Handle; 9 | 10 | const EDITOR_NAME: &str = "editor.ps1"; 11 | const EDITOR_ID: &str = "hoard.test.editor"; 12 | 13 | fn set_reg_key(key: &str, name: &str, val: &Data) { 14 | let reg = Hive::CurrentUser 15 | .open(key, Security::AllAccess) 16 | .or_else(|err| match err { 17 | RegError::NotFound(_, _) => Hive::CurrentUser.create(key, Security::AllAccess), 18 | _ => Err(err), 19 | }) 20 | .expect("opening/creating registry key should not fail"); 21 | reg.set_value(name, val) 22 | .expect("setting registry value should not fail"); 23 | } 24 | 25 | fn get_reg_key(key: &str, name: &str) -> Option { 26 | match Hive::CurrentUser.open(key, Security::Read) { 27 | Ok(reg) => reg 28 | .value(name) 29 | .map(Some) 30 | .expect("reading registry key should not fail"), 31 | Err(err) => match err { 32 | RegError::NotFound(_, _) => None, 33 | _ => panic!("failed to open registry item {}: {:?}", key, err), 34 | }, 35 | } 36 | } 37 | 38 | /// Returns previous default editor 39 | fn set_default_editor(file_type: &str, command: &str) { 40 | let key = format!("Software\\Classes\\.{}", file_type); 41 | set_reg_key(&key, "", &Data::String(EDITOR_ID.try_into().unwrap())); 42 | 43 | let key = format!("{}\\OpenWithProgIds", key); 44 | set_reg_key(&key, &EDITOR_ID, &Data::String("".try_into().unwrap())); 45 | 46 | let key = format!("Software\\Classes\\{}\\shell\\open\\command", EDITOR_ID); 47 | let data = Data::String(command.try_into().unwrap()); 48 | set_reg_key(&key, "", &data); 49 | } 50 | 51 | fn remove_default_editor() { 52 | let key = format!("Software\\Classes\\{}", EDITOR_ID); 53 | Hive::CurrentUser.delete(key, true).unwrap(); 54 | } 55 | 56 | pub struct EditorGuard { 57 | temp_dir: TempDir, 58 | script_file: PathBuf, 59 | old_path: OsString, 60 | modified_registry: bool, 61 | } 62 | 63 | impl EditorGuard { 64 | pub fn script_path(&self) -> &Path { 65 | &self.script_file 66 | } 67 | } 68 | 69 | impl Drop for EditorGuard { 70 | fn drop(&mut self) { 71 | if self.modified_registry { 72 | Handle::current().block_on(remove_default_editor()); 73 | } 74 | std::env::set_var("PATH", &self.old_path); 75 | } 76 | } 77 | 78 | async fn create_script_file(editor: Editor) -> EditorGuard { 79 | let temp_dir = tempfile::tempdir().expect("creating tempdir should succeed"); 80 | let script_file = temp_dir.path().join(EDITOR_NAME); 81 | let mut script = fs::File::create(&script_file) 82 | .await 83 | .expect("creating script file should not succeed"); 84 | script 85 | .write_all(editor.file_content().as_bytes()) 86 | .await 87 | .expect("writing to script file should succeed"); 88 | 89 | let old_path = std::env::var_os("PATH").expect("windows systems should always have PATH set"); 90 | 91 | EditorGuard { 92 | temp_dir, 93 | script_file, 94 | old_path, 95 | modified_registry: false, 96 | } 97 | } 98 | 99 | const SHELL_EDITOR_COMMAND: &str = "Unknown\\shell\\editor\\command"; 100 | const SHELL_OPEN_COMMAND: &str = "Unknown\\shell\\Open\\command"; 101 | const TXTFILE_OPEN_COMMAND: &str = "txtfile\\shell\\Open\\command"; 102 | 103 | pub async fn set_default_gui_editor(editor: Editor) -> EditorGuard { 104 | let mut guard = create_script_file(editor).await; 105 | let command = format!( 106 | "powershell.exe -Path {} \"%1\"", 107 | guard.script_path().display() 108 | ); 109 | 110 | for file_type in ["toml", "yaml", "yml"] { 111 | set_default_editor(file_type, &command); 112 | } 113 | 114 | guard.modified_registry = true; 115 | guard 116 | } 117 | 118 | pub async fn set_default_cli_editor(editor: Editor) -> EditorGuard { 119 | let mut guard = create_script_file(editor).await; 120 | std::env::set_var("EDITOR", EDITOR_NAME); 121 | let mut path: OsString = guard.temp_dir.path().into(); 122 | path.push(";"); 123 | path.push(&guard.old_path); 124 | std::env::set_var("PATH", path); 125 | guard 126 | } 127 | -------------------------------------------------------------------------------- /src/command/mod.rs: -------------------------------------------------------------------------------- 1 | //! See [`Command`]. 2 | 3 | mod backup_restore; 4 | mod cleanup; 5 | mod diff; 6 | mod edit; 7 | mod init; 8 | mod list; 9 | mod status; 10 | mod upgrade; 11 | 12 | use std::path::PathBuf; 13 | 14 | use clap::Parser; 15 | use thiserror::Error; 16 | 17 | pub(crate) use backup_restore::{run_backup, run_restore}; 18 | pub(crate) use cleanup::run_cleanup; 19 | pub(crate) use diff::run_diff; 20 | pub(crate) use edit::run_edit; 21 | pub(crate) use init::run_init; 22 | pub(crate) use list::run_list; 23 | pub(crate) use status::run_status; 24 | pub(crate) use upgrade::run_upgrade; 25 | 26 | use crate::newtypes::HoardName; 27 | pub use backup_restore::Error as BackupRestoreError; 28 | pub use edit::Error as EditError; 29 | 30 | const DEFAULT_CONFIG: &str = include_str!("../../config.toml.sample"); 31 | 32 | /// Errors that can occur while running commands. 33 | #[derive(Debug, Error)] 34 | pub enum Error { 35 | /// Error occurred while printing the help message. 36 | #[error("error while printing help message: {0}")] 37 | PrintHelp(#[from] clap::Error), 38 | /// Error occurred while backing up a hoard. 39 | #[error("failed to back up: {0}")] 40 | Backup(#[source] BackupRestoreError), 41 | /// Error occurred while running [`Checkers`](crate::checkers::Checkers). 42 | #[error("error while running or saving consistency checks: {0}")] 43 | Checkers(#[from] crate::checkers::Error), 44 | /// An error occurred while running the cleanup command. 45 | #[error("error after cleaning up {success_count} log files: {error}")] 46 | Cleanup { 47 | /// The number of files successfully cleaned. 48 | success_count: u32, 49 | /// The error that occurred. 50 | #[source] 51 | error: crate::checkers::history::operation::Error, 52 | }, 53 | /// Error occurred while running the diff command. 54 | #[error("error while running hoard diff: {0}")] 55 | Diff(#[source] crate::hoard::iter::Error), 56 | /// Error occurred while running the edit command. 57 | #[error("error while running hoard edit: {0}")] 58 | Edit(#[from] edit::Error), 59 | /// Error occurred while initializing Hoard. 60 | #[error("failed to create {path}: {error}")] 61 | Init { 62 | /// The path that could not be created. 63 | path: PathBuf, 64 | /// Why that path could not be created. 65 | #[source] 66 | error: std::io::Error, 67 | }, 68 | /// Error occurred while restoring a hoard. 69 | #[error("failed to restore: {0}")] 70 | Restore(#[source] backup_restore::Error), 71 | /// Error occurred while running the status command. 72 | #[error("error while running hoard status: {0}")] 73 | Status(#[source] crate::hoard::iter::Error), 74 | /// Error occurred while upgrading formats. 75 | #[error("error while running hoard upgrade: {0}")] 76 | Upgrade(#[from] upgrade::Error), 77 | } 78 | 79 | /// The possible subcommands for `hoard`. 80 | #[derive(Clone, PartialEq, Eq, Debug, Parser)] 81 | pub enum Command { 82 | /// Loads all configuration for validation. 83 | /// If the configuration loads and builds, this command succeeds. 84 | Validate, 85 | /// Cleans up the operation logs for all known systems. 86 | Cleanup, 87 | /// Back up the given hoard(s). 88 | Backup { 89 | /// The name(s) of the hoard(s) to back up. Will back up all hoards if empty. 90 | hoards: Vec, 91 | }, 92 | /// Restore the files from the given hoard to the filesystem. 93 | Restore { 94 | /// The name(s) of the hoard(s) to restore. Will restore all hoards if empty. 95 | hoards: Vec, 96 | }, 97 | /// List configured hoards. 98 | List, 99 | /// Open the configuration file in the system default editor. 100 | Edit, 101 | /// Initialize a new Hoard setup. 102 | Init, 103 | /// Show which files differ for a given hoard. Optionally show unified diffs for text files 104 | /// too. 105 | Diff { 106 | /// The name of the hoard to diff. 107 | hoard: HoardName, 108 | /// If true, prints unified diffs for text files. 109 | #[clap(long, short)] 110 | verbose: bool, 111 | }, 112 | /// Provides a summary of which hoards have changes and if the diffs can be resolved 113 | /// with a single command. 114 | Status, 115 | /// Upgrade internal file formats to the newest format. 116 | Upgrade, 117 | } 118 | 119 | impl Default for Command { 120 | fn default() -> Self { 121 | Self::Validate 122 | } 123 | } 124 | 125 | #[cfg(test)] 126 | mod tests { 127 | use super::*; 128 | 129 | #[test] 130 | fn default_command_is_validate() { 131 | // The default command is validate if one is not given 132 | assert_eq!(Command::Validate, Command::default()); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/newtypes/non_empty_pile_name.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, ops::Deref, str::FromStr}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::{validate_name, Error}; 6 | 7 | /// Like [`PileName`](super::PileName), but not allowed to be empty ("anonymous") 8 | #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] 9 | #[repr(transparent)] 10 | #[serde(try_from = "String")] 11 | #[serde(into = "String")] 12 | pub struct NonEmptyPileName(String); 13 | 14 | impl Deref for NonEmptyPileName { 15 | type Target = str; 16 | 17 | fn deref(&self) -> &Self::Target { 18 | &self.0 19 | } 20 | } 21 | 22 | impl AsRef for NonEmptyPileName { 23 | fn as_ref(&self) -> &str { 24 | &self.0 25 | } 26 | } 27 | 28 | impl fmt::Display for NonEmptyPileName { 29 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 | self.0.fmt(f) 31 | } 32 | } 33 | 34 | impl FromStr for NonEmptyPileName { 35 | type Err = Error; 36 | 37 | #[tracing::instrument(level = "trace", name = "parse_non_empty_pile_name")] 38 | fn from_str(value: &str) -> Result { 39 | validate_name(value.to_string()).map(Self) 40 | } 41 | } 42 | 43 | impl TryFrom for NonEmptyPileName { 44 | type Error = Error; 45 | 46 | #[tracing::instrument(level = "trace", name = "non_empty_pile_name_try_from_string")] 47 | fn try_from(value: String) -> Result { 48 | validate_name(value).map(Self) 49 | } 50 | } 51 | 52 | impl From for String { 53 | fn from(name: NonEmptyPileName) -> Self { 54 | name.0 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | #[test] 63 | #[allow(clippy::explicit_deref_methods)] 64 | fn test_deref() { 65 | let name = NonEmptyPileName(String::from("testing")); 66 | assert_eq!("testing", name.deref()); 67 | } 68 | 69 | #[test] 70 | fn test_as_ref() { 71 | let name = NonEmptyPileName(String::from("testing")); 72 | assert_eq!("testing", name.as_ref()); 73 | } 74 | 75 | #[test] 76 | fn test_to_string() { 77 | let s = String::from("testing"); 78 | let name = NonEmptyPileName(s.clone()); 79 | assert_eq!(s, name.to_string()); 80 | } 81 | 82 | #[test] 83 | fn test_from_str_and_try_from_string() { 84 | let inputs = vec![ 85 | (String::new(), Err(Error::DisallowedName(String::new()))), 86 | ( 87 | String::from("testing"), 88 | Ok(NonEmptyPileName(String::from("testing"))), 89 | ), 90 | ( 91 | String::from("config"), 92 | Err(Error::DisallowedName(String::from("config"))), 93 | ), 94 | ]; 95 | 96 | for (s, expected) in inputs { 97 | let from_str = s.parse::(); 98 | let try_from = NonEmptyPileName::try_from(s); 99 | match (&from_str, &try_from, &expected) { 100 | (Ok(_), Err(_), _) | (Err(_), Ok(_), _) => { 101 | panic!( 102 | "from_str ({from_str:?}) and try_from_string ({try_from:?}) returned different results" 103 | ) 104 | } 105 | (Ok(result), Ok(_), Err(err)) => { 106 | panic!("conversion succeeded ({result}) but expected to fail with {err:?}"); 107 | } 108 | (Err(err), Err(_), Ok(result)) => { 109 | panic!( 110 | "conversion failed with {err:?} but expected to succeed with {result:?}" 111 | ); 112 | } 113 | (Ok(from_str), Ok(try_from), Ok(expected)) => { 114 | assert_eq!( 115 | from_str, try_from, 116 | "from_str and try_from_string returned different results" 117 | ); 118 | assert_eq!(from_str, expected, "expected {expected} but got {from_str}"); 119 | } 120 | (Err(from_str), Err(try_from), Err(expected)) => { 121 | assert_eq!( 122 | from_str, try_from, 123 | "from_str and try_from_string returned different errors" 124 | ); 125 | assert_eq!( 126 | from_str, expected, 127 | "expected error {expected:?} but got {from_str:?}" 128 | ); 129 | } 130 | } 131 | } 132 | } 133 | 134 | #[test] 135 | fn test_into_string() { 136 | let s = String::from("testing"); 137 | let name = NonEmptyPileName(s.clone()); 138 | assert_eq!(s, name.to_string()); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/checkers/mod.rs: -------------------------------------------------------------------------------- 1 | //! Types that validate (check) Hoard configurations 2 | //! 3 | //! This module includes a single trait, [`Checker`], and all types that implement it. 4 | //! Currently, that is only the [`LastPaths`](history::last_paths::LastPaths) checker. 5 | 6 | use std::collections::HashMap; 7 | 8 | use thiserror::Error; 9 | 10 | use crate::checkers::history::last_paths::{Error as LastPathsError, LastPaths}; 11 | use crate::checkers::history::operation::{Error as OperationError, Operation}; 12 | use crate::hoard::{Direction, Hoard}; 13 | use crate::newtypes::HoardName; 14 | use crate::paths::HoardPath; 15 | 16 | pub mod history; 17 | 18 | /// Trait for validating [`Hoard`]s. 19 | /// 20 | /// A [`Checker`] takes a [`Hoard`] and its name (as [`&str`]) as parameters and uses that 21 | /// information plus any internal state to validate that it is safe to operate on that [`Hoard`]. 22 | #[async_trait::async_trait(? Send)] 23 | pub trait Checker: Sized + Unpin { 24 | /// The error type returned from the check. 25 | type Error: std::error::Error; 26 | /// Returns a new instance of the implementing Checker type. 27 | /// 28 | /// # Errors 29 | /// 30 | /// Any errors that may occur while creating an instance, such as I/O or consistency errors. 31 | async fn new( 32 | hoard_root: &HoardPath, 33 | hoard_name: &HoardName, 34 | hoard: &Hoard, 35 | direction: Direction, 36 | ) -> Result; 37 | /// Returns an error if it is not safe to operate on the given [`Hoard`]. 38 | /// 39 | /// # Errors 40 | /// 41 | /// Any error that prevents operations on the given [`Hoard`], or any errors that 42 | /// occur while performing the check. 43 | async fn check(&mut self) -> Result<(), Self::Error>; 44 | /// Saves any persistent data to disk. 45 | /// 46 | /// # Errors 47 | /// 48 | /// Generally, any I/O errors that occur while persisting data. 49 | async fn commit_to_disk(self) -> Result<(), Self::Error>; 50 | } 51 | 52 | /// Errors that may occur while using [`Checkers`]. 53 | #[derive(Debug, Error)] 54 | #[allow(variant_size_differences)] 55 | pub enum Error { 56 | /// An error occurred while comparing paths for this run to the previous one. 57 | #[error("error while comparing previous run to current run: {0}")] 58 | LastPaths(#[from] LastPathsError), 59 | /// An error occurred while checking against remote operations. 60 | #[error("error while checking against recent remote operations: {0}")] 61 | Operation(#[from] OperationError), 62 | } 63 | 64 | /// A wrapper type for running all implemented [`Checker`] types at once. 65 | #[derive(Debug, Clone, PartialEq)] 66 | pub struct Checkers { 67 | last_paths: HashMap, 68 | operations: HashMap, 69 | } 70 | 71 | impl Checkers { 72 | #[allow(single_use_lifetimes)] 73 | #[tracing::instrument(level = "debug", name = "checkers_new", skip(hoards))] 74 | pub(crate) async fn new<'a>( 75 | hoards_root: &HoardPath, 76 | hoards: impl IntoIterator, 77 | direction: Direction, 78 | ) -> Result { 79 | let mut last_paths = HashMap::new(); 80 | let mut operations = HashMap::new(); 81 | 82 | for (name, hoard) in hoards { 83 | tracing::debug!(%name, ?hoard, "processing hoard"); 84 | let lp = LastPaths::new(hoards_root, name, hoard, direction).await?; 85 | let op = Operation::new(hoards_root, name, hoard, direction).await?; 86 | last_paths.insert(name.clone(), lp); 87 | operations.insert(name.clone(), op); 88 | } 89 | 90 | Ok(Self { 91 | last_paths, 92 | operations, 93 | }) 94 | } 95 | 96 | #[tracing::instrument(level = "debug", name = "checkers_check", skip_all)] 97 | pub(crate) async fn check(&mut self) -> Result<(), Error> { 98 | for last_path in &mut self.last_paths.values_mut() { 99 | last_path.check().await?; 100 | } 101 | for operation in self.operations.values_mut() { 102 | operation.check().await?; 103 | } 104 | Ok(()) 105 | } 106 | 107 | #[tracing::instrument(level = "debug", name = "checkers_commit", skip_all)] 108 | pub(crate) async fn commit_to_disk(self) -> Result<(), Error> { 109 | let Self { 110 | last_paths, 111 | operations, 112 | .. 113 | } = self; 114 | for (_, last_path) in last_paths { 115 | last_path.commit_to_disk().await?; 116 | } 117 | for (_, operation) in operations { 118 | operation.commit_to_disk().await?; 119 | } 120 | Ok(()) 121 | } 122 | 123 | pub(crate) fn get_operation_for(&self, hoard_name: &HoardName) -> Option<&Operation> { 124 | self.operations.get(hoard_name) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This document is a work-in-progress. 2 | 3 | # Table of Contents 4 | 5 | - [Bug Reports](#bug-reports) 6 | - [Feature Requests](#feature-requests) 7 | - [Code](#code) 8 | - [Style](#style) 9 | - [Logging](#logging) 10 | 11 | # Bug Reports 12 | 13 | When creating a bug report, start by checking for similar bugs, *even if they are closed.* Closed 14 | issues may contain the solution to your problem. 15 | 16 | If the same issue was reported and closed due to lack of information, add your information to the existing issue and it 17 | will get reopened for further review. 18 | 19 | If no similar issues exist, create a new one with the following information: 20 | 21 | - `hoard` version (commit if built from git). 22 | - Steps to reproduce the issue. 23 | - A minimal configuration file that causes the issue to be reproduceable. 24 | - If the issue is not a crash or obvious error, a short description of what the expected behavior is. 25 | 26 | This information will make it much easier for me to be able to help resolve your issue. 27 | 28 | # Feature Requests 29 | 30 | When submitting a feature request, first check the issue tracker for similar requests. 31 | 32 | - If one exists and is open, add a thumbs up, heart, or other reaction to the first post to show your support. 33 | - If one exists and is closed, check the reason for closing: 34 | - If marked `wontfix`, the feature request has been rejected. Please do not create a new request. If you feel there 35 | is a strong reason to reconsider *and* the thread is not locked, add your thoughts to the existing issue. 36 | - If not marked `wontfix`, the feature may have been implemented already! If that is the case, there will be a pull 37 | request linked somewhere in the issue's conversation. 38 | 39 | If no similar request exists, you can submit a new one, with the following: 40 | 41 | - The feature being requested. 42 | - The benefit this feature will bring to users. 43 | - Why this feature should be implemented in `hoard` and not externally. 44 | 45 | If these are not included in the initial request, they will probably be asked for before the request will be considered. 46 | 47 | # Code 48 | 49 | This section describes guidelines for how to write code that will be accepted. 50 | 51 | ## Style 52 | 53 | Any code that is submitted via Pull Request needs to pass `clippy` checks and be formatted using `cargo fmt`. There are 54 | CI checks in place that will fail if this is not the case. 55 | 56 | ## Logging 57 | 58 | Any new code should be logged appropriately: 59 | 60 | - `ERROR`: Fatal errors must be logged where they are created. If the error is built recursively or in a loop, this only 61 | applies to the final recursion/iteration. 62 | - `WARN`: Non-fatal errors and potentially unexpected behavior (e.g. an operation being skipped) must be logged the same 63 | as fatal ones, but with this log level. 64 | - `INFO`: Messages to inform the user of the high-level progress of the application get logged here. "High-level" 65 | currently means no more frequent than once per `Hoard`. 66 | - `DEBUG`: More detailed messages to inform the user of some of the lower-level workings of the program. This generally 67 | means anything more specific than `INFO` but less specific than `TRACE`. A good rule of thumb is that multiple 68 | instances of a single `DEBUG` message should not take up more than one full-screen console window. 69 | - `TRACE`: Very detailed messages that announce every non-trivial operation in the program. 70 | 71 | These are some general rules used for writing log statements. 72 | 73 | 1. Logged messages must start with lowercase letters and remain lowercase unless there is a specific reason to have 74 | capital letters. 75 | 2. If a `hoard` library function may call another `hoard` library function and/or contains a loop, create a new span 76 | before that point with the necesssary context. 77 | 3. A span's context should only contain anything used in the immediate context. Items passed through to other library 78 | functions should be part of that more specific context, instead. 79 | 4. Let `hoard` library functions log themselves being called; create spans before calling for context, if necessary. 80 | That is, if there is a function call to `do_a_thing()`, the log message `"Doing a thing"` should come from *inside* 81 | `do_a_thing()`. 82 | 5. If there must be an event logged with the creation of a span, log the event first so that the context logged with it 83 | remains minimal. 84 | 85 | ## Ignoring Lines for Code Coverage 86 | 87 | Ignored lines should be limited to the following: 88 | 89 | - Logging statements 90 | - "This should never happen" panics/asserts *in testing code*. 91 | - Code containing `unimplemented!()` (i.e. should never be called) 92 | - Lines containing only opening/closing braces that are marked as missing coverage 93 | - Manual error propagation 94 | - That is, something like inspecting a lower error to return a specific higher one, or otherwise returning the 95 | original error. 96 | 97 | Lines can be manually ignored by adding a comment with `grcov: ignore` on that line. 98 | -------------------------------------------------------------------------------- /src/dirs/mod.rs: -------------------------------------------------------------------------------- 1 | //! Functions to determine special folders for Hoard to work with on different platforms. 2 | use std::path::{Path, PathBuf}; 3 | 4 | use once_cell::sync::Lazy; 5 | #[cfg(windows)] 6 | pub use windows::Win32::UI::Shell::{FOLDERID_Profile, FOLDERID_RoamingAppData}; 7 | 8 | #[cfg(unix)] 9 | use unix as sys; 10 | #[cfg(windows)] 11 | use win as sys; 12 | #[cfg(windows)] 13 | pub use win::{get_known_folder, set_known_folder}; 14 | 15 | #[cfg(unix)] 16 | mod unix; 17 | #[cfg(windows)] 18 | mod win; 19 | 20 | /// The TLD portion of the application identifier. 21 | pub const TLD: &str = "com"; 22 | /// The Company portion of the application identifier. 23 | pub const COMPANY: &str = "shadow53"; 24 | /// The Project Name portion of the application identifier. 25 | pub const PROJECT: &str = "hoard"; 26 | /// The environment variable that takes precendence over data dir detection. 27 | pub const DATA_DIR_ENV: &str = "HOARD_DATA_DIR"; 28 | /// The environment variable that takes precendence over config dir detection. 29 | pub const CONFIG_DIR_ENV: &str = "HOARD_CONFIG_DIR"; 30 | 31 | static EMPTY_SPAN: Lazy = Lazy::new(|| tracing::trace_span!("get_dir_path")); 32 | 33 | #[inline] 34 | #[tracing::instrument(level = "trace")] 35 | fn path_from_env(var: &str) -> Option { 36 | match std::env::var_os(var).map(PathBuf::from) { 37 | None => { 38 | tracing::trace!("could not find path in env var {}", var); 39 | None 40 | } 41 | Some(path) => { 42 | tracing::trace!("found {} = {}", var, path.display()); 43 | Some(path) 44 | } 45 | } 46 | } 47 | 48 | /// Returns the current user's home directory. 49 | /// 50 | /// - Windows: The "known folder" `FOLDERID_Profile`, fallback to `%USERPROFILE%`. 51 | /// - macOS/Linux/BSD: The value of `$HOME`. 52 | #[must_use] 53 | #[inline] 54 | pub fn home_dir() -> PathBuf { 55 | let _span = tracing::trace_span!(parent: &*EMPTY_SPAN, "home_dir").entered(); 56 | sys::home_dir() 57 | } 58 | 59 | /// Returns Hoard's configuration directory for the current user. 60 | /// 61 | /// Returns the contents of `HOARD_CONFIG_DIR`, if set, otherwise: 62 | /// 63 | /// - Windows: `{appdata}/shadow53/hoard/config` where `{appdata}` is the "known folder" 64 | /// `FOLDERID_RoamingAppData` or the value of `%APPDATA%`. 65 | /// - macOS: `${XDG_CONFIG_HOME}/hoard`, if `XDG_CONFIG_HOME` is set, otherwise 66 | /// `$HOME/Library/Application Support/com.shadow53.hoard`. 67 | /// - Linux/BSD: `${XFG_CONFIG_HOME}/hoard`, if `XDG_CONFIG_HOME` is set, otherwise `$HOME/.config/hoard`. 68 | #[must_use] 69 | #[inline] 70 | pub fn config_dir() -> PathBuf { 71 | let _span = tracing::trace_span!(parent: &*EMPTY_SPAN, "config_dir").entered(); 72 | path_from_env(CONFIG_DIR_ENV).unwrap_or_else(sys::config_dir) 73 | } 74 | 75 | /// Returns Hoard's data directory for the current user. 76 | /// 77 | /// - Windows: `{appdata}/shadow53/hoard/data` where `{appdata}` is the "known folder" 78 | /// `FOLDERID_RoamingAppData` or the value of `%APPDATA%`. 79 | /// - macOS: `${XDG_DATA_HOME}/hoard`, if `XDG_DATA_HOME` is set, otherwise 80 | /// `$HOME/Library/Application Support/com.shadow53.hoard`. 81 | /// - Linux/BSD: `${XFG_DATA_HOME}/hoard`, if `XDG_DATA_HOME` is set, otherwise `$HOME/.local/share/hoard`. 82 | #[must_use] 83 | #[inline] 84 | pub fn data_dir() -> PathBuf { 85 | let _span = tracing::trace_span!(parent: &*EMPTY_SPAN, "data_dir").entered(); 86 | path_from_env(DATA_DIR_ENV).unwrap_or_else(sys::data_dir) 87 | } 88 | 89 | /// Set the environment variable that overrides Hoard's config directory. 90 | /// 91 | /// See [`CONFIG_DIR_ENV`]. 92 | #[tracing::instrument(level = "trace")] 93 | pub fn set_config_dir(path: &Path) { 94 | std::env::set_var(CONFIG_DIR_ENV, path); 95 | } 96 | 97 | /// Set the environment variable that overrides Hoard's data directory. 98 | /// 99 | /// See [`DATA_DIR_ENV`]. 100 | #[tracing::instrument(level = "trace")] 101 | pub fn set_data_dir(path: &Path) { 102 | std::env::set_var(DATA_DIR_ENV, path); 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use std::env; 108 | 109 | use super::*; 110 | 111 | #[test] 112 | fn test_env_config_dir() { 113 | env::remove_var(CONFIG_DIR_ENV); 114 | let original = config_dir(); 115 | let new_path = PathBuf::from("/env/config/dir"); 116 | assert_ne!(original, new_path); 117 | set_config_dir(&new_path); 118 | assert_eq!(new_path.as_os_str(), env::var_os(CONFIG_DIR_ENV).unwrap()); 119 | assert_eq!(new_path, config_dir()); 120 | } 121 | 122 | #[test] 123 | fn test_env_data_dir() { 124 | env::remove_var(DATA_DIR_ENV); 125 | let original = data_dir(); 126 | let new_path = PathBuf::from("/env/data/dir"); 127 | assert_ne!(original, new_path); 128 | set_data_dir(&new_path); 129 | assert_eq!(new_path.as_os_str(), env::var_os(DATA_DIR_ENV).unwrap()); 130 | assert_eq!(new_path, data_dir()); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /config.toml.sample: -------------------------------------------------------------------------------- 1 | # Lists of environment names. 2 | # 3 | # Environment names in lists together cannot appear in the same environment condition string. 4 | # For example, you cannot specify a path that matches both Vim and Neovim. 5 | exclusivity = [ 6 | # Neovim takes priority over Vim. 7 | ["neovim", "vim"], 8 | # Flatpak steam installation takes priority over "native" install. 9 | ["steam_flatpak", "steam"] 10 | ] 11 | 12 | [defaults] 13 | XDG_CONFIG_HOME = "${HOME}/.config" 14 | XDG_DATA_HOME = "${HOME}/.local/share" 15 | 16 | [envs] 17 | 18 | # Match if `fish` is somewhere in $PATH. 19 | [envs.fish] 20 | exe_exists = ["fish"] 21 | 22 | [envs.flatpak] 23 | exe_exists = ["flatpak"] 24 | 25 | [envs.neovim] 26 | # Detect "neovim" if AT LEAST one of `nvim` or `nvim-qt` exists in $PATH. 27 | # This is parsed as `nvim OR nvim-qt`. 28 | exe_exists = ["nvim", "nvim-qt"] 29 | [envs.vim] 30 | # Detect "vim" if AT LEAST one of `vim` or `gvim` exists in $PATH. 31 | exe_exists = ["vim", "gvim"] 32 | 33 | # GAMES 34 | [envs.itch] 35 | # Match itch if BOTH of these paths exist. 36 | # These paths are parsed as (first) AND (second). 37 | path_exists = [ 38 | [ 39 | "${LOCALAPPDATA}/itch" 40 | ], 41 | [ 42 | "${HOME}/.itch", 43 | "${XDG_DATA_HOME}/applications/io.itch.itch.desktop" 44 | ] 45 | ] 46 | [envs.steam] 47 | exe_exists = ["steam"] 48 | [envs.steam_flatpak] 49 | exe_exists = ["flatpak"] 50 | path_exists = [ 51 | "/home/shadow53/.var/app/com.valvesoftware.Steam" 52 | ] 53 | 54 | # OPERATING SYSTEMS 55 | # See https://doc.rust-lang.org/stable/std/env/consts/constant.OS.html for some possible 56 | # values for `os`. 57 | [envs.windows] 58 | os = ["windows"] 59 | [envs.linux] 60 | os = ["linux"] 61 | [envs.macos] 62 | os = ["macos"] 63 | [envs.freebsd] 64 | os = ["freebsd"] 65 | 66 | # Is "unix" if one of the OSes match AND both of the environment variables exist. 67 | # You can also require a specific value for the variable with 68 | # 69 | # { var = "SOME_VAR", expected = "the var value" } 70 | # 71 | # Note: macOS is technically unixy as well, but does not generally use the XDG_* 72 | # directories and is thus excluded here. 73 | [envs.unix] 74 | os = ["linux", "freebsd"] 75 | env = [ 76 | { var = "HOME" }, 77 | { var = "XDG_CONFIG_HOME" } 78 | ] 79 | 80 | [hoards] 81 | 82 | # Both `fish|linux` and `linux|fish` match the same way and are considered 83 | # the same condition internally. 84 | [hoards.fish] 85 | [hoards.fish.confdir] 86 | "unix|fish" = "${XDG_CONFIG_HOME}/fish/conf.d" 87 | [hoards.fish.functions] 88 | "fish|unix" = "${XDG_CONFIG_HOME}/fish/functions" 89 | 90 | [hoards.fonts] 91 | # This is a standard place for FreeDesktop (Linux/BSD) systems to store user fonts. 92 | "unix" = "${XDG_DATA_HOME}/fonts" 93 | 94 | # This hoard uses "linux" instead of "unix" because Steam/Itch/etc. are not on the BSDs. 95 | [hoards.game_saves] 96 | [hoards.game_saves.apotheon] 97 | "linux|steam" = "${XDG_DATA_HOME}/Apotheon/SavedGames" 98 | "linux|steam_flatpak" = "${HOME}/.var/app/com.valvesoftware.Steam/.local/share/Apotheon/SavedGames" 99 | [hoards.game_saves.death_and_taxes] 100 | "linux|itch" = "${XDG_CONFIG_HOME}/unity3d/Placeholder Gameworks/Death and Taxes/Saves" 101 | [hoards.game_saves.hat_in_time] 102 | "linux|steam" = "${XDG_DATA_HOME}/Steam/steamapps/common/HatInTime/HatInTimeGame/SaveData" 103 | "linux|steam_flatpak" = "${HOME}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/HatInTime/HatInTimeGame/SaveData" 104 | [hoards.game_saves.mindustry] 105 | "linux|steam" = "${XDG_DATA_HOME}/Steam/steamapps/common/Mindustry/saves/saves" 106 | "linux|steam_flatpak" = "${HOME}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/Mindustry/saves/saves" 107 | 108 | [hoards.git] 109 | "unix" = "${HOME}/.gitconfig" 110 | 111 | [hoards.hoard] 112 | "unix" = "${XDG_CONFIG_HOME}/hoard/config.toml" 113 | "macos" = "${HOME}/Library/Application Support/com.shadow53.hoard/config.toml" 114 | "windows" = "${APPDATA}\\shadow53\\hoard\\config.toml" 115 | 116 | [hoards.vim] 117 | [hoards.vim.init] 118 | # Match unix AND neovim environments 119 | "unix|neovim" = "${XDG_CONFIG_HOME}/nvim/init.vim" 120 | "unix|vim" = "${HOME}/.vimrc" 121 | "windows|neovim" = "${CSIDL_LOCAL_APPDATA}\\nvim\\init.vim" 122 | "windows|vim" = "${USERPROFILE}/.vim/_vimrc" 123 | [hoards.vim.configdir] 124 | "windows|neovim" = "${CSIDL_LOCAL_APPDATA}\\nvim\\config" 125 | "windows|vim" = "${USERPROFILE}/.vim/config" 126 | "unix|neovim" = "${XDG_CONFIG_HOME}/nvim/config" 127 | "unix|vim" = "${HOME}/.vim/config" 128 | [hoards.vim.templates] 129 | "windows|neovim" = "${CSIDL_LOCAL_APPDATA}\\nvim\\templates" 130 | "windows|vim" = "${USER_PROFILE}\\.vim\\templates" 131 | "unix|neovim" = "${XGD_CONFIG_HOME}/nvim/templates" 132 | "unix|vim" = "${HOME}/.vim/templates" 133 | -------------------------------------------------------------------------------- /src/checkers/history/mod.rs: -------------------------------------------------------------------------------- 1 | //! Keep records of previous operations (including on other system) to prevent inconsistencies 2 | //! and accidental overwrites or deletions. 3 | 4 | use std::path::PathBuf; 5 | 6 | use futures::TryStreamExt; 7 | use tap::TapFallible; 8 | use tokio::{fs, io}; 9 | use tokio_stream::wrappers::ReadDirStream; 10 | use uuid::Uuid; 11 | 12 | use crate::paths::{HoardPath, RelativePath}; 13 | 14 | pub mod last_paths; 15 | pub mod operation; 16 | 17 | const UUID_FILE_NAME: &str = "uuid"; 18 | const HISTORY_DIR_NAME: &str = "history"; 19 | 20 | #[tracing::instrument(level = "debug")] 21 | pub(crate) fn get_uuid_file() -> PathBuf { 22 | crate::dirs::config_dir().join(UUID_FILE_NAME) 23 | } 24 | 25 | #[tracing::instrument(level = "debug")] 26 | fn get_history_root_dir() -> HoardPath { 27 | HoardPath::try_from(crate::dirs::data_dir().join(HISTORY_DIR_NAME)) 28 | .expect("directory rooted in the data dir is always a valid hoard path") 29 | } 30 | 31 | #[tracing::instrument(level = "debug")] 32 | fn get_history_dir_for_id(id: Uuid) -> HoardPath { 33 | get_history_root_dir().join( 34 | &RelativePath::try_from(PathBuf::from(id.to_string())) 35 | .expect("uuid is always a valid relative path"), 36 | ) 37 | } 38 | 39 | #[tracing::instrument(level = "debug")] 40 | async fn get_history_dirs_not_for_id(id: &Uuid) -> Result, io::Error> { 41 | let root = get_history_root_dir(); 42 | if !root.exists() { 43 | tracing::trace!("history root dir does not exist"); 44 | return Ok(Vec::new()); 45 | } 46 | 47 | fs::read_dir(&root) 48 | .await 49 | .map(ReadDirStream::new) 50 | .tap_err(|error| { 51 | tracing::error!(%error, "failed to list items in history root directory {}", root.display()); 52 | })? 53 | .try_filter_map(|entry| async move { 54 | let path = entry.path(); 55 | path.file_name().and_then(|file_name| { 56 | file_name.to_str().and_then(|file_str| { 57 | // Only directories that have UUIDs for names and do not match "this" 58 | // id. 59 | Uuid::parse_str(file_str) 60 | .ok() 61 | .and_then(|other_id| (&other_id != id).then(|| Ok({ 62 | HoardPath::try_from(path.clone()) 63 | .expect("dir entries based in a HoardPath are always valid HoardPaths") 64 | }))) 65 | }) 66 | }).transpose() 67 | }) 68 | .try_collect() 69 | .await 70 | .tap_err(|error| { 71 | tracing::error!(%error, "failed to read metadata for system history directory"); 72 | }) 73 | } 74 | 75 | /// Get this machine's unique UUID, creating if necessary. 76 | /// 77 | /// The UUID can be found in a file called "uuid" in the `hoard` 78 | /// configuration directory. If the file cannot be found or its contents are invalid, 79 | /// a new file is created. 80 | /// 81 | /// # Errors 82 | /// 83 | /// Any I/O unexpected errors that may occur while reading and/or 84 | /// writing the UUID file. 85 | #[allow(clippy::missing_panics_doc)] 86 | pub async fn get_or_generate_uuid() -> Result { 87 | let uuid_file = get_uuid_file(); 88 | let _span = tracing::debug_span!("get_or_generate_uuid", file = ?uuid_file); 89 | 90 | tracing::trace!("attempting to read uuid from file"); 91 | let id: Option = match fs::read_to_string(&uuid_file).await { 92 | Ok(id) => match id.parse() { 93 | Ok(id) => { 94 | tracing::trace!(uuid = %id, "successfully read uuid from file"); 95 | Some(id) 96 | } 97 | Err(err) => { 98 | tracing::warn!(error = %err, bad_id = %id, "failed to parse uuid in file"); 99 | None 100 | } 101 | }, 102 | Err(error) => { 103 | if error.kind() == io::ErrorKind::NotFound { 104 | tracing::trace!("no uuid file found: creating one"); 105 | None 106 | } else { 107 | tracing::error!(%error, "error while reading uuid file {}", uuid_file.display()); 108 | return Err(error); 109 | } 110 | } 111 | }; 112 | 113 | // Return existing id or generate, save to file, and return. 114 | match id { 115 | None => { 116 | let new_id = Uuid::new_v4(); 117 | tracing::debug!(new_uuid = %new_id, "generated new uuid"); 118 | fs::create_dir_all( 119 | uuid_file 120 | .parent() 121 | .expect("uuid file should always have a parent directory"), 122 | ) 123 | .await 124 | .tap_err(|error| { 125 | tracing::error!(%error, "error while create parent dir"); 126 | })?; 127 | fs::write(&uuid_file, new_id.as_hyphenated().to_string()) 128 | .await.tap_err(|error| { 129 | tracing::error!(%error, "error while saving uuid to file {}", uuid_file.display()); 130 | })?; 131 | Ok(new_id) 132 | } 133 | Some(id) => Ok(id), 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/hoard/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module contains processed versions of builder 2 | //! [`Hoard`](crate::config::builder::hoard::Hoard)s. See documentation for builder `Hoard`s 3 | //! for more details. 4 | 5 | use std::collections::HashMap; 6 | use std::fmt; 7 | use std::path::PathBuf; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | use thiserror::Error; 11 | use tokio::io; 12 | 13 | pub use pile_config::Config as PileConfig; 14 | 15 | use crate::newtypes::{NonEmptyPileName, PileName}; 16 | use crate::paths::{HoardPath, RelativePath, SystemPath}; 17 | 18 | pub mod iter; 19 | pub mod pile_config; 20 | 21 | /// Errors that can happen while backing up or restoring a hoard. 22 | #[derive(Debug, Error)] 23 | pub enum Error { 24 | /// Error while copying a file. 25 | #[error("failed to copy {src} to {dest}: {error}")] 26 | CopyFile { 27 | /// The path of the source file. 28 | src: PathBuf, 29 | /// The path of the destination file. 30 | dest: PathBuf, 31 | /// The I/O error that occurred. 32 | #[source] 33 | error: io::Error, 34 | }, 35 | /// Error while creating a directory. 36 | #[error("failed to create {path}: {error}")] 37 | CreateDir { 38 | /// The path of the directory to create. 39 | path: PathBuf, 40 | /// The error that occurred while creating. 41 | #[source] 42 | error: io::Error, 43 | }, 44 | /// Error while reading a directory or an item in a directory. 45 | #[error("cannot read directory {path}: {error}")] 46 | ReadDir { 47 | /// The path of the file or directory to read. 48 | path: PathBuf, 49 | /// The error that occurred while reading. 50 | #[source] 51 | error: io::Error, 52 | }, 53 | /// Both the source and destination exist but are not both directories or both files. 54 | #[error("both source (\"{src}\") and destination (\"{dest}\") exist but are not both files or both directories")] 55 | TypeMismatch { 56 | /// Source path/ 57 | src: PathBuf, 58 | /// Destination path. 59 | dest: PathBuf, 60 | }, 61 | } 62 | 63 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] 64 | #[serde(rename_all = "lowercase")] 65 | /// Indicates which direction files are being copied in. Used to determine which files are required 66 | /// to exist. 67 | pub enum Direction { 68 | /// Backing up from system to hoards. 69 | Backup, 70 | /// Restoring from hoards to system. 71 | Restore, 72 | } 73 | 74 | impl fmt::Display for Direction { 75 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 76 | match self { 77 | Self::Backup => write!(f, "backup"), 78 | Self::Restore => write!(f, "restore"), 79 | } 80 | } 81 | } 82 | 83 | /// A single path to hoard, with configuration. 84 | #[derive(Clone, Debug, PartialEq, Eq)] 85 | pub struct Pile { 86 | /// Optional configuration for this path. 87 | pub config: PileConfig, 88 | /// The path to hoard. 89 | /// 90 | /// The path is optional because it will almost always be set by processing a configuration 91 | /// file and it is possible that none of the environment combinations match. 92 | pub path: Option, 93 | } 94 | 95 | /// A collection of multiple related [`Pile`]s. 96 | #[derive(Clone, Debug, PartialEq, Eq)] 97 | pub struct MultipleEntries { 98 | /// The named [`Pile`]s in the hoard. 99 | pub piles: HashMap, 100 | } 101 | 102 | /// A configured hoard. May contain one or more [`Pile`]s. 103 | #[derive(Clone, Debug, PartialEq, Eq)] 104 | #[allow(variant_size_differences)] 105 | pub enum Hoard { 106 | /// A single anonymous [`Pile`]. 107 | Anonymous(Pile), 108 | /// Multiple named [`Pile`]s. 109 | Named(MultipleEntries), 110 | } 111 | 112 | impl Hoard { 113 | /// Returns an iterator over all piles with associated paths. 114 | /// 115 | /// The [`HoardPath`] and [`SystemPath`] represent the relevant prefix/root path for the given pile. 116 | #[must_use] 117 | #[tracing::instrument(name = "get_hoard_paths")] 118 | pub fn get_paths( 119 | &self, 120 | hoards_root: HoardPath, 121 | ) -> Box> { 122 | match self { 123 | Hoard::Anonymous(pile) => match pile.path.clone() { 124 | None => Box::new(std::iter::empty()), 125 | Some(path) => Box::new(std::iter::once({ 126 | (PileName::anonymous(), hoards_root, path) 127 | })), 128 | }, 129 | Hoard::Named(named) => Box::new(named.piles.clone().into_iter().filter_map( 130 | move |(name, pile)| { 131 | pile.path.map(|path| { 132 | let pile_hoard_root = hoards_root.join(&RelativePath::from(&name)); 133 | (name.into(), pile_hoard_root, path) 134 | }) 135 | }, 136 | )), 137 | } 138 | } 139 | 140 | /// Returns the pile with the given [`PileName`], if exists. 141 | #[must_use] 142 | pub fn get_pile(&self, name: &PileName) -> Option<&Pile> { 143 | match (name.as_ref(), self) { 144 | (None, Self::Anonymous(pile)) => Some(pile), 145 | (Some(name), Self::Named(map)) => map.piles.get(name), 146 | _ => None, 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/fake_editors/unix.rs: -------------------------------------------------------------------------------- 1 | use super::Editor; 2 | use std::ffi::OsString; 3 | use std::os::unix::fs::PermissionsExt; 4 | use std::path::{Path, PathBuf}; 5 | use tempfile::TempDir; 6 | use tokio::fs; 7 | use tokio::io::AsyncWriteExt; 8 | use tokio::process::Command; 9 | use tokio::runtime::Handle; 10 | 11 | const EDITOR_NAME: &str = "com.shadow53.hoard.test-editor"; 12 | const EDITOR_DESKTOP: &str = "com.shadow53.hoard-test-editor.desktop"; 13 | 14 | pub struct EditorGuard { 15 | temp_dir: TempDir, 16 | script_file: PathBuf, 17 | #[cfg(not(target_os = "macos"))] 18 | desktop_file: Option, 19 | old_path: OsString, 20 | } 21 | 22 | impl EditorGuard { 23 | pub fn script_path(&self) -> &Path { 24 | &self.script_file 25 | } 26 | } 27 | 28 | impl Drop for EditorGuard { 29 | fn drop(&mut self) { 30 | #[cfg(not(target_os = "macos"))] 31 | if self.desktop_file.is_some() { 32 | Handle::current().block_on(tokio::spawn(xdg_desktop_menu("uninstall", EDITOR_DESKTOP))); 33 | } 34 | 35 | std::env::set_var("PATH", &self.old_path); 36 | } 37 | } 38 | 39 | #[cfg(not(target_os = "macos"))] 40 | async fn xdg_desktop_menu(command: &str, file_name: &str) { 41 | let status = Command::new("xdg-desktop-menu") 42 | .arg(command) 43 | .arg(file_name) 44 | .status() 45 | .await 46 | .expect("xdg-desktop-menu command should not error"); 47 | assert_eq!( 48 | status.code(), 49 | Some(0), 50 | "xdg-desktop-menu exited with non-zero status" 51 | ); 52 | } 53 | 54 | #[cfg(not(target_os = "macos"))] 55 | async fn set_desktop_file_default(mime_type: &str) { 56 | let status = Command::new("xdg-mime") 57 | .arg("default") 58 | .arg(EDITOR_DESKTOP) 59 | .arg(mime_type) 60 | .status() 61 | .await 62 | .expect("xdg-mime command should not error"); 63 | assert_eq!( 64 | status.code(), 65 | Some(0), 66 | "xdg-mime exited with non-zero status" 67 | ); 68 | let output = Command::new("xdg-mime") 69 | .arg("query") 70 | .arg("default") 71 | .arg(mime_type) 72 | .output() 73 | .await 74 | .expect("xdg-mime command should not error"); 75 | let as_bytes = EDITOR_DESKTOP.as_bytes(); 76 | assert!( 77 | output 78 | .stdout 79 | .windows(as_bytes.len()) 80 | .any(|window| window == as_bytes), 81 | "{} does not seem to be correctly set as GUI default", 82 | EDITOR_DESKTOP 83 | ); 84 | } 85 | 86 | async fn create_script_file(editor: Editor) -> EditorGuard { 87 | let temp_dir = tempfile::tempdir().expect("creating tempdir should succeed"); 88 | let script_file = temp_dir.path().join(EDITOR_NAME); 89 | let mut script = fs::File::create(&script_file) 90 | .await 91 | .expect("creating script file should not succeed"); 92 | script 93 | .write_all(editor.file_content().as_bytes()) 94 | .await 95 | .expect("writing to script file should succeed"); 96 | let mut permissions = script 97 | .metadata() 98 | .await 99 | .expect("reading script file metadata should succeed") 100 | .permissions(); 101 | // Mark script executable 102 | permissions.set_mode(permissions.mode() | 0o000111); 103 | script 104 | .set_permissions(permissions) 105 | .await 106 | .expect("making script executable should succeed"); 107 | 108 | let old_path = std::env::var_os("PATH").expect("unixy systems should always have PATH set"); 109 | 110 | EditorGuard { 111 | temp_dir, 112 | script_file, 113 | #[cfg(not(target_os = "macos"))] 114 | desktop_file: Option::::None, 115 | old_path, 116 | } 117 | } 118 | 119 | #[cfg(target_os = "macos")] 120 | pub fn set_default_gui_editor(editor: Editor) -> EditorGuard { 121 | unimplemented!("setting default GUI programs on MacOS is non-trivial"); 122 | } 123 | 124 | #[cfg(not(target_os = "macos"))] 125 | pub async fn set_default_gui_editor(editor: Editor) -> EditorGuard { 126 | let mut guard = create_script_file(editor).await; 127 | let desktop_path = guard.temp_dir.path().join(EDITOR_DESKTOP); 128 | let content = format!( 129 | r#"[Desktop Entry] 130 | Type=Application 131 | Name=Fake Editor 132 | GenericName=Editor 133 | Categories=System 134 | MimeType=text/plain;application/x-yaml 135 | Exec={} %f 136 | "#, 137 | guard.script_path().display() 138 | ); 139 | fs::write(&desktop_path, content) 140 | .await 141 | .expect("writing to desktop file should succeed"); 142 | xdg_desktop_menu("install", &desktop_path.to_string_lossy()); 143 | // The mime type reported by xdg-mime for TOML files 144 | set_desktop_file_default("text/plain"); 145 | // The mime type reported by xdg-mime for YAML files 146 | set_desktop_file_default("application/x-yaml"); 147 | guard.desktop_file = Some(desktop_path); 148 | 149 | // These env vars need to be set for the system to use xdg-open 150 | std::env::set_var("XDG_CURRENT_DESKTOP", "X-Generic"); 151 | std::env::set_var("DISPLAY", ":0"); 152 | 153 | guard 154 | } 155 | 156 | pub async fn set_default_cli_editor(editor: Editor) -> EditorGuard { 157 | let guard = create_script_file(editor).await; 158 | std::env::set_var("EDITOR", EDITOR_NAME); 159 | let mut path: OsString = guard.temp_dir.path().into(); 160 | path.push(":"); 161 | path.push(&guard.old_path); 162 | std::env::set_var("PATH", path); 163 | guard 164 | } 165 | -------------------------------------------------------------------------------- /src/config/builder/environment/path.rs: -------------------------------------------------------------------------------- 1 | //! See [`PathExists`]. 2 | 3 | use crate::env_vars::PathWithEnv; 4 | use crate::paths::SystemPath; 5 | use serde::{de, Deserialize, Deserializer, Serialize}; 6 | use std::convert::{Infallible, TryInto}; 7 | use std::fmt; 8 | use std::fmt::Formatter; 9 | 10 | struct PathExistsVisitor; 11 | 12 | impl<'de> de::Visitor<'de> for PathExistsVisitor { 13 | type Value = PathExists; 14 | 15 | fn expecting(&self, formatter: &mut Formatter) -> fmt::Result { 16 | formatter.write_str("a path that may or may not contain environment variables") 17 | } 18 | 19 | fn visit_some(self, deserializer: D) -> Result 20 | where 21 | D: Deserializer<'de>, 22 | { 23 | deserializer.deserialize_str(self) 24 | } 25 | 26 | fn visit_str(self, s: &str) -> Result 27 | where 28 | E: de::Error, 29 | { 30 | tracing::trace!("parsing path_exists item {}", s); 31 | let inner = PathWithEnv::from(s).process().ok(); 32 | Ok(PathExists(inner)) 33 | } 34 | } 35 | 36 | /// A conditional structure that tests whether or not the contained path exists. 37 | /// 38 | /// The path can be anything from a file, directory, symbolic link, or otherwise, so long as 39 | /// *something* with that name exists. 40 | #[derive(Clone, PartialEq, Eq, Debug, Hash, Serialize)] 41 | #[serde(transparent)] 42 | #[repr(transparent)] 43 | #[allow(clippy::module_name_repetitions)] 44 | pub struct PathExists(pub Option); 45 | 46 | impl<'de> Deserialize<'de> for PathExists { 47 | fn deserialize(deserializer: D) -> Result 48 | where 49 | D: Deserializer<'de>, 50 | { 51 | deserializer.deserialize_option(PathExistsVisitor) 52 | } 53 | } 54 | 55 | impl TryInto for PathExists { 56 | type Error = Infallible; 57 | 58 | fn try_into(self) -> Result { 59 | let PathExists(path) = self; 60 | match path { 61 | Some(path) => { 62 | tracing::trace!("checking if path \"{}\" exists", path.to_string_lossy()); 63 | Ok(path.exists()) 64 | } 65 | None => Ok(false), 66 | } 67 | } 68 | } 69 | 70 | impl fmt::Display for PathExists { 71 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 72 | let PathExists(path) = self; 73 | write!(f, "PATH EXISTS {path:?}") 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | use crate::test::system_path; 81 | use serde_test::{assert_de_tokens, assert_de_tokens_error, assert_tokens, Token}; 82 | use std::fs; 83 | use std::path::PathBuf; 84 | use tempfile::{tempdir, NamedTempFile}; 85 | 86 | #[test] 87 | fn test_none_is_false() { 88 | assert!(!TryInto::::try_into(PathExists(None)).expect("conversion should not fail")); 89 | } 90 | 91 | #[test] 92 | fn test_file_does_exist() { 93 | let temp = NamedTempFile::new().expect("failed to create temporary file"); 94 | let exists: bool = PathExists(Some( 95 | SystemPath::try_from(temp.path().to_path_buf()).unwrap(), 96 | )) 97 | .try_into() 98 | .expect("failed to check if path exists"); 99 | assert!(exists); 100 | } 101 | 102 | #[test] 103 | fn test_dir_does_exist() { 104 | let temp = tempdir().expect("failed to create temporary directory"); 105 | let exists: bool = PathExists(Some( 106 | SystemPath::try_from(temp.path().to_path_buf()).unwrap(), 107 | )) 108 | .try_into() 109 | .expect("failed to check if path exists"); 110 | assert!(exists); 111 | } 112 | 113 | #[test] 114 | fn test_file_does_not_exist() { 115 | let temp = NamedTempFile::new().expect("failed to create temporary file"); 116 | fs::remove_file(temp.path()).expect("failed to remove temporary file"); 117 | let exists: bool = PathExists(Some( 118 | SystemPath::try_from(temp.path().to_path_buf()).unwrap(), 119 | )) 120 | .try_into() 121 | .expect("failed to check if path exists"); 122 | assert!(!exists); 123 | } 124 | 125 | #[test] 126 | fn test_dir_does_not_exist() { 127 | let temp = tempdir().expect("failed to create temporary directory"); 128 | fs::remove_dir(temp.path()).expect("failed to remove temporary directory"); 129 | let exists: bool = PathExists(Some( 130 | SystemPath::try_from(temp.path().to_path_buf()).unwrap(), 131 | )) 132 | .try_into() 133 | .expect("failed to check if path exists"); 134 | assert!(!exists); 135 | } 136 | 137 | #[test] 138 | fn test_custom_deserialize() { 139 | #[cfg(unix)] 140 | let path_str = "/test/path/example"; 141 | #[cfg(windows)] 142 | let path_str = "C:\\test\\path\\example"; 143 | let path = PathExists(Some(SystemPath::try_from(PathBuf::from(path_str)).unwrap())); 144 | assert_tokens(&path, &[Token::Some, Token::Str(path_str)]); 145 | 146 | assert_de_tokens_error::( 147 | &[Token::U8(5)], "invalid type: integer `5`, expected a path that may or may not contain environment variables" 148 | ); 149 | } 150 | 151 | #[test] 152 | fn test_env_is_expanded_in_path() { 153 | std::env::set_var("HOARD_TEST_ENV", "hoard-test"); 154 | #[cfg(unix)] 155 | let path_with_env = "/test/path/${HOARD_TEST_ENV}/leaf"; 156 | #[cfg(windows)] 157 | let path_with_env = "C:/test/path/${HOARD_TEST_ENV}/leaf"; 158 | let path = PathExists(Some(system_path!("/test/path/hoard-test/leaf"))); 159 | assert_de_tokens(&path, &[Token::Str(path_with_env)]); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tests/last_paths.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | use common::base::{HOARD_ANON_DIR, HOARD_ANON_FILE, HOARD_NAMED}; 4 | use common::tester::Tester; 5 | 6 | use common::base::DefaultConfigTester; 7 | use hoard::checkers::history::last_paths::{ 8 | Error as LastPathsError, HoardPaths, LastPaths, PilePaths, 9 | }; 10 | use hoard::checkers::Error as CheckerError; 11 | use hoard::command::{BackupRestoreError, Command, Error as CommandError}; 12 | use hoard::config::Error as ConfigError; 13 | use hoard::paths::SystemPath; 14 | 15 | async fn assert_expected_paths(tester: &Tester, expected: &LastPaths) { 16 | let current = LastPaths::from_default_file() 17 | .await 18 | .expect("reading last_paths.json should not fail"); 19 | let anon_file = HOARD_ANON_FILE.parse().unwrap(); 20 | let anon_dir = HOARD_ANON_DIR.parse().unwrap(); 21 | let named = HOARD_NAMED.parse().unwrap(); 22 | assert_eq!( 23 | current.hoard(&anon_file).expect("hoard should exist").piles, 24 | expected 25 | .hoard(&anon_file) 26 | .expect("hoard should exist") 27 | .piles, 28 | "Expected: {:#?}\nReceived{:#?}\n{}", 29 | expected, 30 | current, 31 | tester.extra_logging_output().await 32 | ); 33 | assert_eq!( 34 | current.hoard(&anon_dir).expect("hoard should exist").piles, 35 | expected.hoard(&anon_dir).expect("hoard should exist").piles, 36 | "Expected: {:#?}\nReceived{:#?}\n{}", 37 | expected, 38 | current, 39 | tester.extra_logging_output().await 40 | ); 41 | assert_eq!( 42 | current.hoard(&named).expect("hoard should exist").piles, 43 | expected.hoard(&named).expect("hoard should exist").piles, 44 | "Expected: {:#?}\nReceived{:#?}\n{}", 45 | expected, 46 | current, 47 | tester.extra_logging_output().await 48 | ); 49 | } 50 | 51 | #[tokio::test] 52 | async fn test_last_paths() { 53 | let mut tester = DefaultConfigTester::with_log_level(tracing::Level::TRACE).await; 54 | 55 | let timestamp = time::OffsetDateTime::now_utc(); 56 | let first_env_paths = LastPaths::from(maplit::hashmap! { 57 | HOARD_ANON_FILE.parse().unwrap() => HoardPaths { 58 | timestamp, 59 | piles: PilePaths::Anonymous( 60 | Some(SystemPath::try_from(tester.home_dir().join("first_anon_file")).unwrap()) 61 | ) 62 | }, 63 | HOARD_ANON_DIR.parse().unwrap() => HoardPaths { 64 | timestamp, 65 | piles: PilePaths::Anonymous( 66 | Some(SystemPath::try_from(tester.home_dir().join("first_anon_dir")).unwrap()) 67 | ) 68 | }, 69 | HOARD_NAMED.parse().unwrap() => HoardPaths { 70 | timestamp, 71 | piles: PilePaths::Named( 72 | maplit::hashmap! { 73 | "file".parse().unwrap() => 74 | SystemPath::try_from(tester.home_dir().join("first_named_file")).unwrap(), 75 | "dir1".parse().unwrap() => 76 | SystemPath::try_from(tester.home_dir().join("first_named_dir1")).unwrap(), 77 | "dir2".parse().unwrap() => 78 | SystemPath::try_from(tester.home_dir().join("first_named_dir2")).unwrap() 79 | } 80 | ) 81 | } 82 | }); 83 | 84 | let second_env_paths = LastPaths::from(maplit::hashmap! { 85 | HOARD_ANON_FILE.parse().unwrap() => HoardPaths { 86 | timestamp, 87 | piles: PilePaths::Anonymous( 88 | Some(SystemPath::try_from(tester.home_dir().join("second_anon_file")).unwrap()) 89 | ) 90 | }, 91 | HOARD_ANON_DIR.parse().unwrap() => HoardPaths { 92 | timestamp, 93 | piles: PilePaths::Anonymous( 94 | Some(SystemPath::try_from(tester.home_dir().join("second_anon_dir")).unwrap()) 95 | ) 96 | }, 97 | HOARD_NAMED.parse().unwrap() => HoardPaths { 98 | timestamp, 99 | piles: PilePaths::Named( 100 | maplit::hashmap! { 101 | "file".parse().unwrap() => 102 | SystemPath::try_from(tester.home_dir().join("second_named_file")).unwrap(), 103 | "dir1".parse().unwrap() => 104 | SystemPath::try_from(tester.home_dir().join("second_named_dir1")).unwrap(), 105 | "dir2".parse().unwrap() => 106 | SystemPath::try_from(tester.home_dir().join("second_named_dir2")).unwrap() 107 | } 108 | ) 109 | } 110 | }); 111 | 112 | let backup = Command::Backup { hoards: Vec::new() }; 113 | tester.setup_files().await; 114 | 115 | tester.use_first_env(); 116 | 117 | // Running twice should succeed 118 | tester.expect_command(backup.clone()).await; 119 | tester.expect_command(backup.clone()).await; 120 | assert_expected_paths(&tester, &first_env_paths).await; 121 | 122 | // Switching environments (thus paths) should fail 123 | tester.use_second_env(); 124 | 125 | let error = tester 126 | .run_command(backup.clone()) 127 | .await 128 | .expect_err("changing environment should have caused last_paths to fail"); 129 | assert!(matches!( 130 | error, 131 | ConfigError::Command(CommandError::Backup(BackupRestoreError::Consistency( 132 | CheckerError::LastPaths(LastPathsError::HoardPathsMismatch) 133 | ))) 134 | )); 135 | assert_expected_paths(&tester, &first_env_paths).await; 136 | 137 | // Mismatched paths should not be saved, so first env should succeed still 138 | tester.use_first_env(); 139 | 140 | tester.expect_command(backup.clone()).await; 141 | assert_expected_paths(&tester, &first_env_paths).await; 142 | 143 | tester.use_second_env(); 144 | 145 | tester.expect_forced_command(backup).await; 146 | assert_expected_paths(&tester, &second_env_paths).await; 147 | } 148 | --------------------------------------------------------------------------------