├── rust-toolchain.toml ├── src ├── lib.rs ├── util.rs ├── cache │ ├── sql │ │ ├── none_to_v0.sql │ │ ├── none_to_v1.sql │ │ └── v0_to_v1.sql │ ├── filetree.rs │ └── tests.rs ├── args.rs ├── reporter.rs ├── restic.rs ├── main.rs ├── cache.rs └── ui.rs ├── .gitignore ├── screenshot_marks.png ├── screenshot_start.png ├── screenshot_details.png ├── rustfmt.toml ├── LICENSE ├── Cargo.toml ├── .github └── workflows │ ├── build.yaml │ └── release.yaml ├── benches └── cache.rs ├── README.md └── Cargo.lock /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod reporter; 3 | pub mod restic; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .envrc 3 | .idea 4 | /scripts/target 5 | /target 6 | -------------------------------------------------------------------------------- /screenshot_marks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/HEAD/screenshot_marks.png -------------------------------------------------------------------------------- /screenshot_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/HEAD/screenshot_start.png -------------------------------------------------------------------------------- /screenshot_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drdo/redu/HEAD/screenshot_details.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | use_small_heuristics = "Max" 3 | use_field_init_shorthand = true 4 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | pub fn snapshot_short_id(id: &str) -> String { 2 | id.chars().take(7).collect::() 3 | } 4 | -------------------------------------------------------------------------------- /src/cache/sql/none_to_v0.sql: -------------------------------------------------------------------------------- 1 | -- snapshots 2 | CREATE TABLE IF NOT EXISTS snapshots ( 3 | id TEXT PRIMARY KEY, 4 | "group" INTEGER NOT NULL 5 | ); 6 | 7 | -- files 8 | CREATE TABLE IF NOT EXISTS files ( 9 | snapshot_group INTEGER, 10 | path TEXT, 11 | size INTEGER, 12 | parent TEXT GENERATED ALWAYS AS (path_parent(path)), 13 | PRIMARY KEY (snapshot_group, path) 14 | ); 15 | 16 | CREATE INDEX IF NOT EXISTS files_path_parent 17 | ON files (parent); 18 | 19 | -- directories 20 | CREATE TABLE IF NOT EXISTS directories ( 21 | snapshot_group INTEGER, 22 | path TEXT, 23 | size INTEGER, 24 | parent TEXT GENERATED ALWAYS AS (path_parent(path)), 25 | PRIMARY KEY (snapshot_group, path) 26 | ); 27 | 28 | CREATE INDEX IF NOT EXISTS directories_path_parent 29 | ON directories (parent); 30 | 31 | -- marks 32 | CREATE TABLE IF NOT EXISTS marks ( 33 | path TEXT PRIMARY KEY 34 | ); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Oliveira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cache/sql/none_to_v1.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE metadata_integer ( 2 | key TEXT PRIMARY KEY, 3 | value INTEGER NOT NULL 4 | ) WITHOUT ROWID; 5 | INSERT INTO metadata_integer (key, value) VALUES ('version', 1); 6 | 7 | CREATE TABLE paths ( 8 | id INTEGER PRIMARY KEY, 9 | parent_id INTEGER NOT NULL, 10 | component TEXT NOT NULL 11 | ); 12 | CREATE UNIQUE INDEX paths_parent_component ON paths (parent_id, component); 13 | 14 | CREATE TABLE snapshots ( 15 | hash TEXT PRIMARY KEY, 16 | time INTEGER, 17 | parent TEXT, 18 | tree TEXT NOT NULL, 19 | hostname TEXT, 20 | username TEXT, 21 | uid INTEGER, 22 | gid INTEGER, 23 | original_id TEXT, 24 | program_version TEXT 25 | ) WITHOUT ROWID; 26 | CREATE TABLE snapshot_paths ( 27 | hash TEXT, 28 | path TEXT, 29 | PRIMARY KEY (hash, path) 30 | ) WITHOUT ROWID; 31 | CREATE TABLE snapshot_excludes ( 32 | hash TEXT, 33 | path TEXT, 34 | PRIMARY KEY (hash, path) 35 | ) WITHOUT ROWID; 36 | CREATE TABLE snapshot_tags ( 37 | hash TEXT, 38 | tag TEXT, 39 | PRIMARY KEY (hash, tag) 40 | ) WITHOUT ROWID; 41 | 42 | -- The entries tables are sharded per snapshot and created dynamically 43 | 44 | CREATE TABLE marks (path TEXT PRIMARY KEY) WITHOUT ROWID; 45 | -------------------------------------------------------------------------------- /src/cache/sql/v0_to_v1.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX files_path_parent; 2 | DROP INDEX directories_path_parent; 3 | DROP TABLE snapshots; 4 | DROP TABLE files; 5 | DROP TABLE directories; 6 | 7 | CREATE TABLE metadata_integer ( 8 | key TEXT PRIMARY KEY, 9 | value INTEGER NOT NULL 10 | ) WITHOUT ROWID; 11 | INSERT INTO metadata_integer (key, value) VALUES ('version', 1); 12 | 13 | CREATE TABLE paths ( 14 | id INTEGER PRIMARY KEY, 15 | parent_id INTEGER NOT NULL, 16 | component TEXT NOT NULL 17 | ); 18 | CREATE UNIQUE INDEX paths_parent_component ON paths (parent_id, component); 19 | 20 | CREATE TABLE snapshots ( 21 | hash TEXT PRIMARY KEY, 22 | time INTEGER, 23 | parent TEXT, 24 | tree TEXT NOT NULL, 25 | hostname TEXT, 26 | username TEXT, 27 | uid INTEGER, 28 | gid INTEGER, 29 | original_id TEXT, 30 | program_version TEXT 31 | ) WITHOUT ROWID; 32 | CREATE TABLE snapshot_paths ( 33 | hash TEXT, 34 | path TEXT, 35 | PRIMARY KEY (hash, path) 36 | ) WITHOUT ROWID; 37 | CREATE TABLE snapshot_excludes ( 38 | hash TEXT, 39 | path TEXT, 40 | PRIMARY KEY (hash, path) 41 | ) WITHOUT ROWID; 42 | CREATE TABLE snapshot_tags ( 43 | hash TEXT, 44 | tag TEXT, 45 | PRIMARY KEY (hash, tag) 46 | ) WITHOUT ROWID; 47 | 48 | -- The entries tables are sharded per snapshot and created dynamically 49 | 50 | CREATE TABLE new_marks (path TEXT PRIMARY KEY) WITHOUT ROWID; 51 | INSERT INTO new_marks (path) SELECT path FROM marks; 52 | DROP TABLE marks; 53 | ALTER TABLE new_marks RENAME TO marks; 54 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "redu" 3 | version = "0.2.14" 4 | authors = ["Daniel Rebelo de Oliveira "] 5 | license = "MIT" 6 | homepage = "https://github.com/drdo/redu" 7 | repository = "https://github.com/drdo/redu" 8 | keywords = ["restic", "ncdu", "disk", "usage", "analyzer"] 9 | categories = ["command-line-utilities"] 10 | edition = "2021" 11 | description = "This is like ncdu for a restic repository." 12 | 13 | [dependencies] 14 | anyhow = "1" 15 | camino = "1" 16 | chrono = { version = "0.4", features = ["serde"] } 17 | clap = { version = "4", features = ["derive", "env"] } 18 | crossterm = "0.29" 19 | directories = "6" 20 | simplelog = "0.12" 21 | humansize = "2" 22 | indicatif = "0.18" 23 | log = "0.4" 24 | rand = "0.9" 25 | ratatui = { version = "0.29", features = [ 26 | "unstable-rendered-line-info", 27 | "unstable-widget-ref", 28 | ] } 29 | rpassword = "7.3.1" 30 | rusqlite = { version = "0.37", features = ["bundled", "functions", "trace"] } 31 | scopeguard = "1" 32 | serde = { version = "1", features = ["derive"] } 33 | serde_json = "1" 34 | thiserror = "2" 35 | unicode-segmentation = "1" 36 | uuid = { version = "1", features = ["v4"], optional = true } 37 | 38 | [target.'cfg(unix)'.dependencies] 39 | nix = { version = "0.30", features = ["process"] } 40 | 41 | [lib] 42 | path = "src/lib.rs" 43 | 44 | [[bin]] 45 | name = "redu" 46 | path = "src/main.rs" 47 | 48 | [features] 49 | bench = ["uuid"] 50 | 51 | [profile.release] 52 | codegen-units = 1 53 | lto = "fat" 54 | 55 | [dev-dependencies] 56 | criterion = { version = "0.7", features = ["html_reports"] } 57 | uuid = { version = "1", features = ["v4"] } 58 | 59 | [[bench]] 60 | name = "cache" 61 | harness = false 62 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | rustfmt-check: 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install rustfmt 18 | run: rustup component add --toolchain stable-x86_64-unknown-linux-gnu rustfmt 19 | - name: Rustfmt Check 20 | run: cargo fmt --check 21 | build-test-linux-x86_64: 22 | needs: rustfmt-check 23 | runs-on: ubuntu-24.04 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Add x86_64-unknown-linux-musl target 27 | run: | 28 | rustup target add x86_64-unknown-linux-musl 29 | sudo apt-get -y update 30 | sudo apt-get -y install musl-dev musl-tools 31 | - name: Build 32 | run: cargo build --target=x86_64-unknown-linux-musl --verbose 33 | - name: Build benches 34 | run: cargo bench --target=x86_64-unknown-linux-musl --features bench --no-run 35 | - name: Run tests 36 | run: cargo test --target=x86_64-unknown-linux-musl --verbose 37 | build-linux-arm64: 38 | needs: rustfmt-check 39 | runs-on: ubuntu-24.04 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Install cross 43 | run: cargo install cross 44 | - name: Build 45 | run: cross build --target aarch64-unknown-linux-musl --verbose 46 | build-darwin-x86_64: 47 | needs: rustfmt-check 48 | runs-on: macos-13 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Build 52 | run: cargo build --verbose 53 | build-darwin-arm64: 54 | needs: rustfmt-check 55 | runs-on: macos-14 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Build 59 | run: cargo build --verbose 60 | build-windows-x86_64: 61 | needs: rustfmt-check 62 | runs-on: windows-2022 63 | steps: 64 | - uses: actions/checkout@v4 65 | - name: Build 66 | run: cargo build --target=x86_64-pc-windows-msvc --verbose 67 | - name: Run tests 68 | run: cargo test --verbose 69 | build-windows-arm64: 70 | needs: rustfmt-check 71 | runs-on: windows-2022 72 | steps: 73 | - uses: actions/checkout@v4 74 | - name: Add aarch64-pc-windows-msvc target 75 | run: rustup target add aarch64-pc-windows-msvc 76 | - name: Build 77 | run: cargo build --target=aarch64-pc-windows-msvc --verbose 78 | -------------------------------------------------------------------------------- /benches/cache.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | 3 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 4 | use redu::{ 5 | cache::{tests::*, Migrator}, 6 | restic::Snapshot, 7 | }; 8 | 9 | pub fn criterion_benchmark(c: &mut Criterion) { 10 | c.bench_function("merge sizetree", |b| { 11 | let sizetree0 = 12 | Cell::new(generate_sizetree(black_box(6), black_box(12))); 13 | let sizetree1 = 14 | Cell::new(generate_sizetree(black_box(5), black_box(14))); 15 | b.iter(move || sizetree0.take().merge(black_box(sizetree1.take()))); 16 | }); 17 | 18 | c.bench_function("save snapshot", |b| { 19 | let foo = Snapshot { 20 | id: "foo".to_string(), 21 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 22 | parent: Some("bar".to_string()), 23 | tree: "sometree".to_string(), 24 | paths: vec![ 25 | "/home/user".to_string(), 26 | "/etc".to_string(), 27 | "/var".to_string(), 28 | ] 29 | .into_iter() 30 | .collect(), 31 | hostname: Some("foo.com".to_string()), 32 | username: Some("user".to_string()), 33 | uid: Some(123), 34 | gid: Some(456), 35 | excludes: vec![ 36 | ".cache".to_string(), 37 | "Cache".to_string(), 38 | "/home/user/Downloads".to_string(), 39 | ] 40 | .into_iter() 41 | .collect(), 42 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 43 | .into_iter() 44 | .collect(), 45 | original_id: Some("fefwfwew".to_string()), 46 | program_version: Some("restic 0.16.0".to_string()), 47 | }; 48 | b.iter_with_setup( 49 | || { 50 | let tempfile = Tempfile::new(); 51 | let cache = 52 | Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 53 | (tempfile, cache, generate_sizetree(6, 12)) 54 | }, 55 | |(_tempfile, mut cache, tree)| { 56 | cache.save_snapshot(&foo, tree).unwrap() 57 | }, 58 | ); 59 | }); 60 | 61 | c.bench_function("save lots of small snapshots", |b| { 62 | fn mk_snapshot(id: String) -> Snapshot { 63 | Snapshot { 64 | id, 65 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 66 | parent: Some("bar".to_string()), 67 | tree: "sometree".to_string(), 68 | paths: vec![ 69 | "/home/user".to_string(), 70 | "/etc".to_string(), 71 | "/var".to_string(), 72 | ] 73 | .into_iter() 74 | .collect(), 75 | hostname: Some("foo.com".to_string()), 76 | username: Some("user".to_string()), 77 | uid: Some(123), 78 | gid: Some(456), 79 | excludes: vec![ 80 | ".cache".to_string(), 81 | "Cache".to_string(), 82 | "/home/user/Downloads".to_string(), 83 | ] 84 | .into_iter() 85 | .collect(), 86 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 87 | .into_iter() 88 | .collect(), 89 | original_id: Some("fefwfwew".to_string()), 90 | program_version: Some("restic 0.16.0".to_string()), 91 | } 92 | } 93 | 94 | b.iter_with_setup( 95 | || { 96 | let tempfile = Tempfile::new(); 97 | let cache = 98 | Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 99 | (tempfile, cache, generate_sizetree(1, 0)) 100 | }, 101 | |(_tempfile, mut cache, tree)| { 102 | for i in 0..10_000 { 103 | cache 104 | .save_snapshot( 105 | &mk_snapshot(i.to_string()), 106 | tree.clone(), 107 | ) 108 | .unwrap(); 109 | } 110 | }, 111 | ); 112 | }); 113 | } 114 | 115 | criterion_group! { 116 | name = benches; 117 | config = Criterion::default().sample_size(10); 118 | targets = criterion_benchmark 119 | } 120 | criterion_main!(benches); 121 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{ArgGroup, Parser}; 2 | use log::LevelFilter; 3 | use redu::restic::Repository; 4 | use rpassword::read_password; 5 | 6 | use crate::restic::Password; 7 | 8 | #[derive(Debug)] 9 | pub struct Args { 10 | pub repository: Repository, 11 | pub password: Password, 12 | pub parallelism: usize, 13 | pub log_level: LevelFilter, 14 | pub no_cache: bool, 15 | pub non_interactive: bool, 16 | } 17 | 18 | impl Args { 19 | /// Parse arguments from env::args_os(), exit on error. 20 | pub fn parse() -> Self { 21 | let cli = Cli::parse(); 22 | 23 | Args { 24 | repository: if let Some(repo) = cli.repo { 25 | Repository::Repo(repo) 26 | } else if let Some(file) = cli.repository_file { 27 | Repository::File(file) 28 | } else { 29 | unreachable!("Error in Config: neither repo nor repository_file found. Please open an issue if you see this.") 30 | }, 31 | password: if let Some(command) = cli.password_command { 32 | Password::Command(command) 33 | } else if let Some(file) = cli.password_file { 34 | Password::File(file) 35 | } else if let Some(str) = cli.restic_password { 36 | Password::Plain(str) 37 | } else { 38 | Password::Plain(Self::read_password_from_stdin()) 39 | }, 40 | parallelism: cli.parallelism, 41 | log_level: match cli.verbose { 42 | 0 => LevelFilter::Info, 43 | 1 => LevelFilter::Debug, 44 | _ => LevelFilter::Trace, 45 | }, 46 | no_cache: cli.no_cache, 47 | non_interactive: cli.non_interactive, 48 | } 49 | } 50 | 51 | fn read_password_from_stdin() -> String { 52 | eprint!("enter password for repository: "); 53 | read_password().unwrap() 54 | } 55 | } 56 | 57 | /// This is like ncdu for a restic respository. 58 | /// 59 | /// It computes the size for each directory/file by 60 | /// taking the largest over all snapshots in the repository. 61 | /// 62 | /// You can browse your repository and mark directories/files. 63 | /// These marks are persisted across runs of redu. 64 | /// 65 | /// When you're happy with the marks you can generate 66 | /// a list to stdout with everything that you marked. 67 | /// This list can be used directly as an exclude-file for restic. 68 | /// 69 | /// Redu keeps all messages and UI in stderr, 70 | /// only the marks list is generated to stdout. 71 | /// This means that you can pipe redu directly to a file 72 | /// to get the exclude-file. 73 | /// 74 | /// NOTE: redu will never do any kind of modification to your repo. 75 | /// It's strictly read-only. 76 | /// 77 | /// Keybinds: 78 | /// Arrows or hjkl: Movement 79 | /// PgUp/PgDown or C-b/C-f: Page up / Page down 80 | /// Enter: Details 81 | /// Escape: Close dialog 82 | /// m: Mark 83 | /// u: Unmark 84 | /// c: Clear all marks 85 | /// g: Generate 86 | /// q: Quit 87 | #[derive(Parser)] 88 | #[command(version, long_about, verbatim_doc_comment)] 89 | #[command(group( 90 | ArgGroup::new("repository") 91 | .required(true) 92 | .args(["repo", "repository_file"]), 93 | ))] 94 | struct Cli { 95 | #[arg(short = 'r', long, env = "RESTIC_REPOSITORY")] 96 | repo: Option, 97 | 98 | #[arg(long, env = "RESTIC_REPOSITORY_FILE")] 99 | repository_file: Option, 100 | 101 | #[arg(long, value_name = "COMMAND", env = "RESTIC_PASSWORD_COMMAND")] 102 | password_command: Option, 103 | 104 | #[arg(long, value_name = "FILE", env = "RESTIC_PASSWORD_FILE")] 105 | password_file: Option, 106 | 107 | #[arg(value_name = "RESTIC_PASSWORD", env = "RESTIC_PASSWORD")] 108 | restic_password: Option, 109 | 110 | /// How many restic subprocesses to spawn concurrently. 111 | /// 112 | /// If you get ssh-related errors or too much memory use try lowering this. 113 | #[arg(short = 'j', value_name = "NUMBER", default_value_t = 4)] 114 | parallelism: usize, 115 | 116 | /// Log verbosity level. You can pass it multiple times (maxes out at two). 117 | #[arg( 118 | short = 'v', 119 | action = clap::ArgAction::Count, 120 | )] 121 | verbose: u8, 122 | 123 | /// Pass the --no-cache option to restic subprocesses. 124 | #[arg(long)] 125 | no_cache: bool, 126 | 127 | /// Run redu only to update the cache, without any UI and without requiring a terminal. Exits when done. 128 | #[arg(long)] 129 | non_interactive: bool, 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | redu in a nutshell: it's ncdu for your restic repo. 4 | 5 | If you ever wanted to know what's taking so much space in your restic 6 | repo so that you can find all the caches and useless things you might be backing 7 | up and delete them from the snapshots, then this is exactly for you. 8 | 9 | redu aggregates data from **all** snapshots into one view so you can easily find 10 | the culprits! 11 | 12 | # Installing 13 | ## Cargo 14 | ``` 15 | cargo install redu --locked 16 | ``` 17 | 18 | ## Nix 19 | ``` 20 | nix-env -i redu 21 | ``` 22 | 23 | ## Prebuilt binaries 24 | You can grab a pre-built binary from Github, currently available for: 25 | - Darwin (MacOS) arm64 26 | - Darwin (MacOS) x86-64 27 | - Linux arm64 28 | - Linux x86-64 29 | - Windows arm64 30 | - Windows x86-64 31 | 32 | Note: On MacOS if you download via browser you might need to remove quarantine with: 33 | `xattr -d com.apple.quarantine ` 34 | 35 | # Running 36 | 37 | You can specify the repository and the password command in exactly the same ways 38 | that restic supports. 39 | 40 | For example using environment variables: 41 | ``` 42 | $ export RESTIC_REPOSITORY='sftp://my-backup-server.my-domain.net' 43 | $ export RESTIC_PASSWORD_COMMAND='security find-generic-password -s restic -a personal -w' 44 | $ redu 45 | ``` 46 | 47 | Or via command line arguments: 48 | ``` 49 | redu -r 'sftp://my-backup-server.my-domain.net' --password-command 'security find-generic-password -s restic -a personal -w' 50 | ``` 51 | 52 | Note: `--repository-file` (env: `RESTIC_REPOSITORY_FILE`) and `--password-file` (env: `RESTIC_PASSWORD_FILE`), 53 | as well as plain text passwords set via the `RESTIC_PASSWORD` environment variable, 54 | are supported as well and work just like in restic. 55 | 56 | Similar to restic, redu will prompt you to enter the password, if it isn't 57 | given any other way. 58 | 59 | ### Other options 60 | - `--non-interactive`: Run redu only to update the cache, without any UI and without requiring a terminal. Logs to stderr and exits when done. 61 | - `-v`: Log verbosity level. You can pass it multiple times (maxes out at two). 62 | - `-j`: How many restic subprocesses to spawn concurrently. Default: 4. 63 | 64 | # Usage 65 | Redu keeps a cache with your file/directory sizes (per repo). 66 | On each run it will sync the cache with the snapshots in your repo, 67 | deleting old snapshots and integrating new ones into the cache. 68 | 69 | If you have a lot of large snapshots the first sync might take some minutes 70 | depending on your connection speed and computer. 71 | It will be much faster the next time as it no longer needs to fetch the entire repo. 72 | 73 | After some time you will see something like this: 74 | 75 | ![Screenshot of redu showing the contents of a repo](screenshot_start.png) 76 | 77 | You can navigate using the **arrow keys** or **hjkl**. 78 | Going right enters a directory and going left leaves back to the parent. 79 | 80 | **PgUp**/**PgDown** or **C-b**/**C-b** scroll up or down a full page. 81 | 82 | The size that redu shows for each item is the maximum size of the item 83 | across all snapshots. That is, it's the size of that item for the snapshot 84 | where it is the biggest. 85 | 86 | The bars indicate the relative size of the item compared to everything else 87 | in the current location. 88 | 89 | By pressing **Enter** you can make a small window visible that shows some details 90 | about the currently highlighted item: 91 | - The latest snapshot where it has maximum size 92 | - The earliest date and snapshot where this item appears 93 | - The latest date and snapshot where this item appears 94 | 95 | ![Screenshot of redu showing the contents of a repo with details open](screenshot_details.png) 96 | 97 | You can keep navigating with the details window open and it will update as you 98 | browse around. 99 | 100 | Hint: you can press **Escape** to close the details window (as well as other dialogs). 101 | 102 | ### Marking files 103 | You can mark files and directories to build up your list of things to exclude. 104 | Keybinds 105 | - **m**: mark selected file/directory 106 | - **u**: unmark selected file/directory 107 | - **c**: clear all marks (this will prompt you for confirmation) 108 | 109 | The marks are persistent across runs of redu (they are saved in the cache file), 110 | so feel free to mark a few files and just quit and come back later. 111 | 112 | The marks are shown with an asterik at the beginning of the line 113 | and you can see how many total marks you have on the bar at the bottom. 114 | 115 | ![Screenshot of redu showing the contents of a repo with some marks](screenshot_marks.png) 116 | 117 | ### Generating the excludes 118 | Press **g** to exit redu and generate a list with all of your marks in alphabetic order to stdout. 119 | 120 | Everything else that redu prints (including the UI itself) goes to stderr, 121 | so this allows you to redirect redu's output to a file to get an exclude-file 122 | that you can directly use with restic. 123 | 124 | For example: 125 | ``` 126 | $ redu > exclude.txt 127 | $ restic rewrite --exclude-file=exclude.txt --forget 128 | ``` 129 | 130 | Note: redu is strictly **read-only** and will never modify your repository itself. 131 | 132 | ### Quit 133 | You can also just quit without generating the list by pressing **q**. 134 | 135 | # Contributing 136 | Bug reports, feature requests and PRs are all welcome! 137 | Just go ahead! 138 | 139 | You can also shoot me an email or talk to me on the rust Discord or Freenode 140 | if you want to contribute and want to discuss some point. 141 | 142 | ### Tests and Benchmarks 143 | You can run the tests with 144 | ``` 145 | cargo test 146 | ``` 147 | 148 | There are also a couple of benchmarks based on criterion that can be run with 149 | ``` 150 | cargo bench --features bench 151 | ``` 152 | -------------------------------------------------------------------------------- /src/reporter.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 4 | 5 | pub trait Reporter { 6 | fn print(&self, msg: &str); 7 | 8 | fn add_loader(&self, level: usize, msg: &str) -> Box; 9 | 10 | fn add_counter( 11 | &self, 12 | level: usize, 13 | prefix: &str, 14 | suffix: &str, 15 | ) -> Box; 16 | 17 | fn add_bar( 18 | &self, 19 | level: usize, 20 | prefix: &str, 21 | total: u64, 22 | ) -> Box; 23 | } 24 | 25 | impl Reporter for &T { 26 | fn print(&self, msg: &str) { 27 | (*self).print(msg); 28 | } 29 | 30 | fn add_loader(&self, level: usize, msg: &str) -> Box { 31 | (*self).add_loader(level, msg) 32 | } 33 | 34 | fn add_counter( 35 | &self, 36 | level: usize, 37 | prefix: &str, 38 | suffix: &str, 39 | ) -> Box { 40 | (*self).add_counter(level, prefix, suffix) 41 | } 42 | 43 | fn add_bar( 44 | &self, 45 | level: usize, 46 | prefix: &str, 47 | total: u64, 48 | ) -> Box { 49 | (*self).add_bar(level, prefix, total) 50 | } 51 | } 52 | 53 | pub trait Item { 54 | fn end(self: Box); 55 | } 56 | 57 | pub trait Counter { 58 | fn end(self: Box); 59 | fn inc(&mut self, delta: u64); 60 | } 61 | 62 | ////////// Term //////////////////////////////////////////////////////////////// 63 | #[derive(Clone)] 64 | pub struct TermReporter(MultiProgress); 65 | 66 | impl TermReporter { 67 | pub fn new() -> Self { 68 | Self(MultiProgress::new()) 69 | } 70 | 71 | fn add>(&self, template: T, total: Option) -> TermItem { 72 | let pb = new_pb(template.as_ref()); 73 | if let Some(total) = total { 74 | pb.set_length(total); 75 | } 76 | let pb = self.0.add(pb); 77 | pb.enable_steady_tick(PB_TICK_INTERVAL); 78 | TermItem { parent: self.clone(), pb } 79 | } 80 | } 81 | 82 | impl Default for TermReporter { 83 | fn default() -> Self { 84 | Self::new() 85 | } 86 | } 87 | 88 | impl Reporter for TermReporter { 89 | fn print(&self, msg: &str) { 90 | eprintln!("{msg}"); 91 | } 92 | 93 | fn add_loader(&self, level: usize, msg: &str) -> Box { 94 | Box::new( 95 | self.add(format!("{}{{spinner}} {}", " ".repeat(level), msg), None), 96 | ) 97 | } 98 | 99 | fn add_counter( 100 | &self, 101 | level: usize, 102 | prefix: &str, 103 | suffix: &str, 104 | ) -> Box { 105 | Box::new(self.add( 106 | format!( 107 | "{}{{spinner}} {}{{pos}}{}", 108 | " ".repeat(level), 109 | prefix, 110 | suffix, 111 | ), 112 | None, 113 | )) 114 | } 115 | 116 | fn add_bar( 117 | &self, 118 | level: usize, 119 | prefix: &str, 120 | total: u64, 121 | ) -> Box { 122 | Box::new(self.add( 123 | format!( 124 | "{}{{spinner}} {}{{wide_bar}} [{{pos}}/{{len}}]", 125 | " ".repeat(level), 126 | prefix, 127 | ), 128 | Some(total), 129 | )) 130 | } 131 | } 132 | 133 | struct TermItem { 134 | parent: TermReporter, 135 | pb: ProgressBar, 136 | } 137 | 138 | impl Drop for TermItem { 139 | fn drop(&mut self) { 140 | self.pb.abandon(); 141 | self.parent.0.remove(&self.pb); 142 | } 143 | } 144 | 145 | impl Item for TermItem { 146 | fn end(self: Box) {} 147 | } 148 | 149 | impl Counter for TermItem { 150 | fn end(self: Box) { 151 | ::end(self) 152 | } 153 | fn inc(&mut self, delta: u64) { 154 | self.pb.inc(delta); 155 | } 156 | } 157 | 158 | fn new_pb(template: &str) -> ProgressBar { 159 | let frames = &[ 160 | "(● )", 161 | "( ● )", 162 | "( ● )", 163 | "( ● )", 164 | "( ● )", 165 | "( ● )", 166 | "( ● )", 167 | "(● )", 168 | "(● )", 169 | ]; 170 | let style = 171 | ProgressStyle::with_template(template).unwrap().tick_strings(frames); 172 | ProgressBar::new_spinner().with_style(style) 173 | } 174 | 175 | const PB_TICK_INTERVAL: Duration = Duration::from_millis(100); 176 | 177 | ////////// NullReporter //////////////////////////////////////////////////////// 178 | pub struct NullReporter; 179 | 180 | impl NullReporter { 181 | pub fn new() -> Self { 182 | Self 183 | } 184 | } 185 | 186 | impl Default for NullReporter { 187 | fn default() -> Self { 188 | Self::new() 189 | } 190 | } 191 | 192 | impl Reporter for NullReporter { 193 | fn print(&self, _msg: &str) {} 194 | 195 | fn add_loader(&self, _level: usize, _msg: &str) -> Box { 196 | Box::new(NullItem) 197 | } 198 | 199 | fn add_counter( 200 | &self, 201 | _level: usize, 202 | _prefix: &str, 203 | _suffix: &str, 204 | ) -> Box { 205 | Box::new(NullItem) 206 | } 207 | 208 | fn add_bar( 209 | &self, 210 | _level: usize, 211 | _prefix: &str, 212 | _total: u64, 213 | ) -> Box { 214 | Box::new(NullItem) 215 | } 216 | } 217 | 218 | struct NullItem; 219 | 220 | impl Item for NullItem { 221 | fn end(self: Box) {} 222 | } 223 | 224 | impl Counter for NullItem { 225 | fn end(self: Box) { 226 | ::end(self) 227 | } 228 | 229 | fn inc(&mut self, _delta: u64) {} 230 | } 231 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | set-env: 12 | runs-on: ubuntu-24.04 13 | outputs: 14 | name: ${{steps.vars.outputs.name}} 15 | version: ${{steps.vars.outputs.version}} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - id: vars 19 | run: | 20 | set -e -o pipefail 21 | echo "NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].name')" >> "$GITHUB_OUTPUT" 22 | echo "VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[].version')" >> "$GITHUB_OUTPUT" 23 | build-linux-x86_64: 24 | needs: set-env 25 | runs-on: ubuntu-24.04 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Rustfmt Check 29 | run: cargo fmt --check 30 | - name: Add x86_64-unknown-linux-musl target 31 | run: | 32 | rustup target add x86_64-unknown-linux-musl 33 | sudo apt-get -y update 34 | sudo apt-get -y install musl-dev musl-tools 35 | - name: Build 36 | run: cargo build --target=x86_64-unknown-linux-musl --release --verbose 37 | - name: Run tests 38 | run: cargo test --verbose 39 | - name: Compress 40 | run: > 41 | cat "target/x86_64-unknown-linux-musl/release/${{needs.set-env.outputs.name}}" 42 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-x86_64.bz2 43 | - name: Upload 44 | uses: diamondburned/action-upload-release@v0.0.1 45 | with: 46 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-x86_64.bz2 47 | build-linux-arm64: 48 | needs: set-env 49 | runs-on: ubuntu-24.04 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Install cross 53 | run: cargo install cross 54 | - name: Build 55 | run: cross build --target aarch64-unknown-linux-musl --release --verbose 56 | - name: Compress 57 | run: > 58 | cat "target/aarch64-unknown-linux-musl/release/${{needs.set-env.outputs.name}}" 59 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-arm64.bz2 60 | - name: Upload 61 | uses: diamondburned/action-upload-release@v0.0.1 62 | with: 63 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-linux-arm64.bz2 64 | build-darwin-x86_64: 65 | needs: set-env 66 | runs-on: macos-13 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Build 70 | run: cargo build --release --verbose 71 | - name: Compress 72 | run: > 73 | cat "target/release/${{needs.set-env.outputs.name}}" 74 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-x86_64.bz2 75 | - name: Upload 76 | uses: diamondburned/action-upload-release@v0.0.1 77 | with: 78 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-x86_64.bz2 79 | build-darwin-arm64: 80 | needs: set-env 81 | runs-on: macos-14 82 | steps: 83 | - uses: actions/checkout@v4 84 | - name: Build 85 | run: cargo build --release --verbose 86 | - name: Compress 87 | run: > 88 | cat "target/release/${{needs.set-env.outputs.name}}" 89 | | bzip2 -9 -c > ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-arm64.bz2 90 | - name: Upload 91 | uses: diamondburned/action-upload-release@v0.0.1 92 | with: 93 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-darwin-arm64.bz2 94 | build-windows-x86_64: 95 | needs: set-env 96 | runs-on: windows-2022 97 | steps: 98 | - uses: actions/checkout@v4 99 | - name: Build 100 | run: cargo build --release --verbose 101 | - name: Run tests 102 | run: cargo test --verbose 103 | - name: Compress 104 | run: > 105 | Compress-Archive 106 | target/release/${{needs.set-env.outputs.name}}.exe 107 | ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 108 | - name: Upload artifact 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: windows-x86_64-release 112 | path: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 113 | upload-windows-x86_64: 114 | needs: [set-env, build-windows-x86_64] 115 | runs-on: ubuntu-24.04 116 | steps: 117 | - name: Download artifact 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: windows-x86_64-release 121 | - name: Upload 122 | uses: diamondburned/action-upload-release@v0.0.1 123 | with: 124 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-x86_64.zip 125 | build-windows-arm64: 126 | needs: set-env 127 | runs-on: windows-2022 128 | steps: 129 | - uses: actions/checkout@v4 130 | - name: Add aarch64-pc-windows-msvc target 131 | run: | 132 | rustup target add aarch64-pc-windows-msvc 133 | - name: Build 134 | run: cargo build --release --target=aarch64-pc-windows-msvc --verbose 135 | - name: Compress 136 | run: > 137 | Compress-Archive 138 | target/aarch64-pc-windows-msvc/release/${{needs.set-env.outputs.name}}.exe 139 | ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip 140 | - name: Upload artifact 141 | uses: actions/upload-artifact@v4 142 | with: 143 | name: windows-arm64-release 144 | path: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip 145 | upload-windows-arm64: 146 | needs: [set-env, build-windows-arm64] 147 | runs-on: ubuntu-24.04 148 | steps: 149 | - name: Download artifact 150 | uses: actions/download-artifact@v4 151 | with: 152 | name: windows-arm64-release 153 | - name: Upload 154 | uses: diamondburned/action-upload-release@v0.0.1 155 | with: 156 | files: ${{needs.set-env.outputs.name}}-${{needs.set-env.outputs.version}}-windows-arm64.zip -------------------------------------------------------------------------------- /src/cache/filetree.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::max, 3 | collections::{hash_map, HashMap}, 4 | iter::Peekable, 5 | }; 6 | 7 | use thiserror::Error; 8 | 9 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 10 | pub struct SizeTree(pub FileTree); 11 | 12 | #[derive(Debug, Eq, Error, PartialEq)] 13 | pub enum InsertError { 14 | #[error("Tried to insert into empty path")] 15 | EmptyPath, 16 | #[error("Tried to insert into existing path")] 17 | EntryExists, 18 | } 19 | 20 | impl SizeTree { 21 | pub fn new() -> Self { 22 | SizeTree(FileTree::new()) 23 | } 24 | 25 | pub fn merge(self, other: SizeTree) -> Self { 26 | SizeTree(self.0.merge(other.0, max)) 27 | } 28 | 29 | pub fn iter( 30 | &self, 31 | ) -> impl Iterator + '_ { 32 | self.0 33 | .iter() 34 | .map(|(level, cs, size, is_dir)| (level, cs, *size, is_dir)) 35 | } 36 | 37 | // `update` is used to update the sizes for all ancestors 38 | pub fn insert( 39 | &mut self, 40 | path: P, 41 | size: usize, 42 | ) -> Result<(), InsertError> 43 | where 44 | C: AsRef, 45 | P: IntoIterator, 46 | { 47 | let (mut breadcrumbs, mut remaining) = { 48 | let (breadcrumbs, remaining) = self.0.find(path); 49 | (breadcrumbs, remaining.peekable()) 50 | }; 51 | if remaining.peek().is_none() { 52 | return Err(InsertError::EntryExists); 53 | } 54 | 55 | // Update existing ancestors 56 | for node in breadcrumbs.iter_mut() { 57 | unsafe { (**node).data += size }; 58 | } 59 | 60 | // Create the rest 61 | let mut current_node: &mut Node = { 62 | if let Some(last) = breadcrumbs.pop() { 63 | unsafe { &mut *last } 64 | } else if let Some(component) = remaining.next() { 65 | self.0 66 | .children 67 | .entry(Box::from(component.as_ref())) 68 | .or_insert(Node::new(size)) 69 | } else { 70 | return Err(InsertError::EmptyPath); 71 | } 72 | }; 73 | for component in remaining { 74 | current_node = current_node 75 | .children 76 | .entry(Box::from(component.as_ref())) 77 | .or_insert(Node::new(0)); 78 | current_node.data = size; 79 | } 80 | 81 | Ok(()) 82 | } 83 | } 84 | 85 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 86 | pub struct FileTree { 87 | children: HashMap, Node>, 88 | } 89 | 90 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 91 | struct Node { 92 | data: T, 93 | children: HashMap, Node>, 94 | } 95 | 96 | impl FileTree { 97 | pub fn new() -> Self { 98 | FileTree { children: HashMap::new() } 99 | } 100 | 101 | pub fn merge(self, other: Self, mut combine: F) -> Self 102 | where 103 | F: FnMut(T, T) -> T, 104 | { 105 | fn merge_children T>( 106 | a: HashMap, Node>, 107 | b: HashMap, Node>, 108 | f: &mut F, 109 | ) -> HashMap, Node> { 110 | let mut sorted_a = sorted_hashmap(a).into_iter(); 111 | let mut sorted_b = sorted_hashmap(b).into_iter(); 112 | let mut children = HashMap::new(); 113 | loop { 114 | match (sorted_a.next(), sorted_b.next()) { 115 | (Some((name0, tree0)), Some((name1, tree1))) => { 116 | if name0 == name1 { 117 | children.insert(name0, merge_node(tree0, tree1, f)); 118 | } else { 119 | children.insert(name0, tree0); 120 | children.insert(name1, tree1); 121 | } 122 | } 123 | (None, Some((name, tree))) => { 124 | children.insert(name, tree); 125 | } 126 | (Some((name, tree)), None) => { 127 | children.insert(name, tree); 128 | } 129 | (None, None) => { 130 | break; 131 | } 132 | } 133 | } 134 | children 135 | } 136 | 137 | // This exists to be able to reuse `combine` multiple times in the loop 138 | // without being consumed by the recursive calls 139 | fn merge_node T>( 140 | a: Node, 141 | b: Node, 142 | f: &mut F, 143 | ) -> Node { 144 | Node { 145 | data: f(a.data, b.data), 146 | children: merge_children(a.children, b.children, f), 147 | } 148 | } 149 | 150 | FileTree { 151 | children: merge_children( 152 | self.children, 153 | other.children, 154 | &mut combine, 155 | ), 156 | } 157 | } 158 | 159 | /// Depth first, parent before children 160 | pub fn iter(&self) -> Iter<'_, T> { 161 | let breadcrumb = 162 | Breadcrumb { level: 1, children: self.children.iter() }; 163 | Iter { stack: vec![breadcrumb] } 164 | } 165 | 166 | /// Traverse the tree while keeping a context. 167 | /// The context is morally `[f(node_0), f(node_1), ..., f(node_2)]` for 168 | /// all ancestors nodes `node_i` of the visited node. 169 | /// 170 | /// Depth first, parent before children 171 | pub fn traverse_with_context<'a, C, E, F>( 172 | &'a self, 173 | mut f: F, 174 | ) -> Result<(), E> 175 | where 176 | F: for<'b> FnMut(&'b [C], &'a str, &'a T, bool) -> Result, 177 | { 178 | let mut iter = self.iter(); 179 | // First iteration just to initialized id_stack and previous_level 180 | let (mut context, mut previous_level): (Vec, usize) = { 181 | if let Some((level, component, data, is_dir)) = iter.next() { 182 | let context_component = f(&[], component, data, is_dir)?; 183 | (vec![context_component], level) 184 | } else { 185 | return Ok(()); 186 | } 187 | }; 188 | 189 | for (level, component, size, is_dir) in iter { 190 | if level <= previous_level { 191 | // We went up the tree or moved to a sibling 192 | for _ in 0..previous_level - level + 1 { 193 | context.pop(); 194 | } 195 | } 196 | context.push(f(&context, component, size, is_dir)?); 197 | previous_level = level; 198 | } 199 | Ok(()) 200 | } 201 | 202 | /// Returns the breadcrumbs of the largest prefix of the path. 203 | /// If the file is in the tree the last breadcrumb will be the file itself. 204 | /// Does not modify self at all. 205 | /// The cdr is the remaining path that did not match, if any. 206 | fn find( 207 | &mut self, 208 | path: P, 209 | ) -> (Vec<*mut Node>, impl Iterator) 210 | where 211 | C: AsRef, 212 | P: IntoIterator, 213 | { 214 | let mut iter = path.into_iter().peekable(); 215 | if let Some(component) = iter.peek() { 216 | let component = component.as_ref(); 217 | if let Some(node) = self.children.get_mut(component) { 218 | iter.next(); 219 | return node.find(iter); 220 | } 221 | } 222 | (vec![], iter) 223 | } 224 | } 225 | 226 | impl Node { 227 | fn new(data: T) -> Self { 228 | Node { data, children: HashMap::new() } 229 | } 230 | 231 | fn find( 232 | &mut self, 233 | mut path: Peekable

, 234 | ) -> (Vec<*mut Node>, Peekable

) 235 | where 236 | C: AsRef, 237 | P: Iterator, 238 | { 239 | let mut breadcrumbs: Vec<*mut Node> = vec![self]; 240 | while let Some(c) = path.peek() { 241 | let c = c.as_ref(); 242 | let current = unsafe { &mut **breadcrumbs.last().unwrap() }; 243 | match current.children.get_mut(c) { 244 | Some(next) => { 245 | breadcrumbs.push(next); 246 | path.next(); 247 | } 248 | None => break, 249 | } 250 | } 251 | (breadcrumbs, path) 252 | } 253 | } 254 | 255 | pub struct Iter<'a, T> { 256 | stack: Vec>, 257 | } 258 | 259 | struct Breadcrumb<'a, T> { 260 | level: usize, 261 | children: hash_map::Iter<'a, Box, Node>, 262 | } 263 | 264 | impl<'a, T> Iterator for Iter<'a, T> { 265 | /// (level, component, data, is_directory) 266 | type Item = (usize, &'a str, &'a T, bool); 267 | 268 | /// Depth first, parent before children 269 | fn next(&mut self) -> Option { 270 | loop { 271 | if let Some(mut breadcrumb) = self.stack.pop() { 272 | if let Some((component, child)) = breadcrumb.children.next() { 273 | let level = breadcrumb.level + 1; 274 | let item = ( 275 | level, 276 | component as &str, 277 | &child.data, 278 | !child.children.is_empty(), 279 | ); 280 | self.stack.push(breadcrumb); 281 | self.stack.push(Breadcrumb { 282 | level, 283 | children: child.children.iter(), 284 | }); 285 | break Some(item); 286 | } 287 | } else { 288 | break None; 289 | } 290 | } 291 | } 292 | } 293 | 294 | fn sorted_hashmap(m: HashMap) -> Vec<(K, V)> { 295 | let mut vec = m.into_iter().collect::>(); 296 | vec.sort_unstable_by(|(k0, _), (k1, _)| k0.cmp(k1)); 297 | vec 298 | } 299 | -------------------------------------------------------------------------------- /src/restic.rs: -------------------------------------------------------------------------------- 1 | use core::str; 2 | #[cfg(not(target_os = "windows"))] 3 | use std::os::unix::process::CommandExt; 4 | use std::{ 5 | borrow::Cow, 6 | collections::HashSet, 7 | ffi::OsStr, 8 | fmt::{self, Display, Formatter}, 9 | io::{self, BufRead, BufReader, Lines, Read, Write}, 10 | marker::PhantomData, 11 | mem, 12 | process::{Child, ChildStdout, Command, Stdio}, 13 | str::Utf8Error, 14 | }; 15 | 16 | use camino::Utf8PathBuf; 17 | use chrono::{DateTime, Utc}; 18 | use log::info; 19 | use scopeguard::defer; 20 | use serde::{de::DeserializeOwned, Deserialize}; 21 | use serde_json::Value; 22 | use thiserror::Error; 23 | 24 | #[derive(Debug, Error)] 25 | #[error("error launching restic process")] 26 | pub struct LaunchError(#[source] pub io::Error); 27 | 28 | #[derive(Debug, Error)] 29 | pub enum RunError { 30 | #[error("error doing IO")] 31 | Io(#[from] io::Error), 32 | #[error("error reading input as UTF-8")] 33 | Utf8(#[from] Utf8Error), 34 | #[error("error parsing JSON")] 35 | Parse(#[from] serde_json::Error), 36 | #[error("the restic process exited with error code {}", if let Some(code) = .0 { code.to_string() } else { "None".to_string() } )] 37 | Exit(Option), 38 | } 39 | 40 | #[derive(Debug, Error)] 41 | pub enum ErrorKind { 42 | #[error("error launching restic process")] 43 | Launch(#[from] LaunchError), 44 | #[error("error while running restic process")] 45 | Run(#[from] RunError), 46 | } 47 | 48 | impl From for ErrorKind { 49 | fn from(value: io::Error) -> Self { 50 | ErrorKind::Run(RunError::Io(value)) 51 | } 52 | } 53 | 54 | impl From for ErrorKind { 55 | fn from(value: Utf8Error) -> Self { 56 | ErrorKind::Run(RunError::Utf8(value)) 57 | } 58 | } 59 | 60 | impl From for ErrorKind { 61 | fn from(value: serde_json::Error) -> Self { 62 | ErrorKind::Run(RunError::Parse(value)) 63 | } 64 | } 65 | 66 | #[derive(Debug, Error)] 67 | pub struct Error { 68 | #[source] 69 | pub kind: ErrorKind, 70 | pub stderr: Option, 71 | } 72 | 73 | impl Display for Error { 74 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 75 | match &self.stderr { 76 | Some(s) => write!(f, "restic error, stderr dump:\n{}", s), 77 | None => write!(f, "restic error"), 78 | } 79 | } 80 | } 81 | 82 | impl From for Error { 83 | fn from(value: LaunchError) -> Self { 84 | Error { kind: ErrorKind::Launch(value), stderr: None } 85 | } 86 | } 87 | 88 | #[derive(Debug, Deserialize)] 89 | pub struct Config { 90 | pub id: String, 91 | } 92 | 93 | pub struct Restic { 94 | repository: Repository, 95 | password: Password, 96 | no_cache: bool, 97 | } 98 | 99 | #[derive(Debug)] 100 | pub enum Repository { 101 | /// A repository string (restic: --repo) 102 | Repo(String), 103 | /// A repository file (restic: --repository-file) 104 | File(String), 105 | } 106 | 107 | #[derive(Debug)] 108 | pub enum Password { 109 | /// A plain string (restic: RESTIC_PASSWORD env variable) 110 | Plain(String), 111 | /// A password command (restic: --password-command) 112 | Command(String), 113 | /// A password file (restic: --password-file) 114 | File(String), 115 | } 116 | 117 | impl Restic { 118 | pub fn new( 119 | repository: Repository, 120 | password: Password, 121 | no_cache: bool, 122 | ) -> Self { 123 | Restic { repository, password, no_cache } 124 | } 125 | 126 | pub fn config(&self) -> Result { 127 | self.run_greedy_command(["cat", "config"]) 128 | } 129 | 130 | pub fn snapshots(&self) -> Result, Error> { 131 | self.run_greedy_command(["snapshots"]) 132 | } 133 | 134 | pub fn ls( 135 | &self, 136 | snapshot: &str, 137 | ) -> Result> + 'static, LaunchError> 138 | { 139 | fn parse_file(mut v: Value) -> Option { 140 | let mut m = mem::take(v.as_object_mut()?); 141 | Some(File { 142 | path: Utf8PathBuf::from(m.remove("path")?.as_str()?), 143 | size: m.remove("size")?.as_u64()? as usize, 144 | }) 145 | } 146 | 147 | Ok(self 148 | .run_lazy_command(["ls", snapshot])? 149 | .filter_map(|r| r.map(parse_file).transpose())) 150 | } 151 | 152 | // This is a trait object because of 153 | // https://github.com/rust-lang/rust/issues/125075 154 | fn run_lazy_command( 155 | &self, 156 | args: impl IntoIterator, 157 | ) -> Result> + 'static>, LaunchError> 158 | where 159 | T: DeserializeOwned + 'static, 160 | A: AsRef, 161 | { 162 | let child = self.run_command(args)?; 163 | Ok(Box::new(Iter::new(child))) 164 | } 165 | 166 | fn run_greedy_command( 167 | &self, 168 | args: impl IntoIterator, 169 | ) -> Result 170 | where 171 | T: DeserializeOwned, 172 | A: AsRef, 173 | { 174 | let child = self.run_command(args)?; 175 | let id = child.id(); 176 | defer! { info!("finished pid {}", id); } 177 | let output = child.wait_with_output().map_err(|e| Error { 178 | kind: ErrorKind::Run(RunError::Io(e)), 179 | stderr: None, 180 | })?; 181 | let r_value: Result = if output.status.success() { 182 | match str::from_utf8(&output.stdout) { 183 | Ok(s) => serde_json::from_str(s).map_err(|e| e.into()), 184 | Err(e) => Err(e.into()), 185 | } 186 | } else { 187 | Err(ErrorKind::Run(RunError::Exit(output.status.code()))) 188 | }; 189 | match r_value { 190 | Err(kind) => Err(Error { 191 | kind, 192 | stderr: Some( 193 | String::from_utf8_lossy(&output.stderr).into_owned(), 194 | ), 195 | }), 196 | Ok(value) => Ok(value), 197 | } 198 | } 199 | 200 | fn run_command>( 201 | &self, 202 | args: impl IntoIterator, 203 | ) -> Result { 204 | let mut cmd = Command::new("restic"); 205 | // Need to detach process from terminal 206 | #[cfg(not(target_os = "windows"))] 207 | unsafe { 208 | cmd.pre_exec(|| { 209 | nix::unistd::setsid()?; 210 | Ok(()) 211 | }); 212 | } 213 | match &self.repository { 214 | Repository::Repo(repo) => cmd.arg("--repo").arg(repo), 215 | Repository::File(file) => cmd.arg("--repository-file").arg(file), 216 | }; 217 | match &self.password { 218 | Password::Command(command) => { 219 | cmd.arg("--password-command").arg(command); 220 | cmd.stdin(Stdio::null()); 221 | } 222 | Password::File(file) => { 223 | cmd.arg("--password-file").arg(file); 224 | cmd.stdin(Stdio::null()); 225 | } 226 | Password::Plain(_) => { 227 | // passed via stdin after the process is started 228 | cmd.stdin(Stdio::piped()); 229 | } 230 | }; 231 | if self.no_cache { 232 | cmd.arg("--no-cache"); 233 | } 234 | cmd.arg("--json"); 235 | // pass --quiet to remove informational messages in stdout mixed up with the JSON we want 236 | // (https://github.com/restic/restic/issues/5236) 237 | cmd.arg("--quiet"); 238 | cmd.args(args); 239 | let mut child = cmd 240 | .stdout(Stdio::piped()) 241 | .stderr(Stdio::piped()) 242 | .spawn() 243 | .map_err(LaunchError)?; 244 | info!("running \"{cmd:?}\" (pid {})", child.id()); 245 | if let Password::Plain(ref password) = self.password { 246 | let mut stdin = child 247 | .stdin 248 | .take() 249 | .expect("child has no stdin when it should have"); 250 | stdin.write_all(password.as_bytes()).map_err(LaunchError)?; 251 | stdin.write_all(b"\n").map_err(LaunchError)?; 252 | } 253 | Ok(child) 254 | } 255 | } 256 | 257 | struct Iter { 258 | child: Child, 259 | lines: Lines>, 260 | finished: bool, 261 | _phantom_data: PhantomData, 262 | } 263 | 264 | impl Iter { 265 | fn new(mut child: Child) -> Self { 266 | let stdout = child.stdout.take().unwrap(); 267 | Iter { 268 | child, 269 | lines: BufReader::new(stdout).lines(), 270 | finished: false, 271 | _phantom_data: PhantomData, 272 | } 273 | } 274 | 275 | fn read_stderr(&mut self, kind: ErrorKind) -> Result { 276 | let mut buf = String::new(); 277 | // read_to_string would block forever if the child was still running. 278 | let _ = self.child.kill(); 279 | match self.child.stderr.take().unwrap().read_to_string(&mut buf) { 280 | Err(e) => Err(Error { 281 | kind: ErrorKind::Run(RunError::Io(e)), 282 | stderr: None, 283 | }), 284 | Ok(_) => Err(Error { kind, stderr: Some(buf) }), 285 | } 286 | } 287 | 288 | fn finish(&mut self) { 289 | if !self.finished { 290 | info!("finished pid {}", self.child.id()); 291 | } 292 | } 293 | } 294 | 295 | impl Iterator for Iter { 296 | type Item = Result; 297 | 298 | fn next(&mut self) -> Option { 299 | if let Some(r_line) = self.lines.next() { 300 | let r_value: Result = 301 | r_line.map_err(|e| e.into()).and_then(|line| { 302 | serde_json::from_str(&line).map_err(|e| e.into()) 303 | }); 304 | Some(match r_value { 305 | Err(kind) => { 306 | self.finish(); 307 | self.read_stderr(kind) 308 | } 309 | Ok(value) => Ok(value), 310 | }) 311 | } else { 312 | self.finish(); 313 | match self.child.wait() { 314 | Err(e) => { 315 | Some(self.read_stderr(ErrorKind::Run(RunError::Io(e)))) 316 | } 317 | Ok(status) => { 318 | if status.success() { 319 | None 320 | } else { 321 | Some(self.read_stderr(ErrorKind::Run(RunError::Exit( 322 | status.code(), 323 | )))) 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | #[derive(Clone, Debug, Deserialize)] 332 | pub struct Snapshot { 333 | pub id: String, 334 | pub time: DateTime, 335 | #[serde(default)] 336 | pub parent: Option, 337 | pub tree: String, 338 | pub paths: HashSet, 339 | #[serde(default)] 340 | pub hostname: Option, 341 | #[serde(default)] 342 | pub username: Option, 343 | #[serde(default)] 344 | pub uid: Option, 345 | #[serde(default)] 346 | pub gid: Option, 347 | #[serde(default)] 348 | pub excludes: HashSet, 349 | #[serde(default)] 350 | pub tags: HashSet, 351 | #[serde(default)] 352 | pub original_id: Option, 353 | #[serde(default)] 354 | pub program_version: Option, 355 | } 356 | 357 | #[derive(Clone, Debug, Eq, PartialEq)] 358 | pub struct File { 359 | pub path: Utf8PathBuf, 360 | pub size: usize, 361 | } 362 | 363 | pub fn escape_for_exclude(path: &str) -> Cow<'_, str> { 364 | fn is_special(c: char) -> bool { 365 | ['*', '?', '[', '\\', '\r', '\n'].contains(&c) 366 | } 367 | 368 | fn char_backward(c: char) -> char { 369 | char::from_u32( 370 | (c as u32).checked_sub(1).expect( 371 | "char_backward: underflow when computing previous char", 372 | ), 373 | ) 374 | .expect("char_backward: invalid resulting character") 375 | } 376 | 377 | fn char_forward(c: char) -> char { 378 | char::from_u32( 379 | (c as u32) 380 | .checked_add(1) 381 | .expect("char_backward: overflow when computing next char"), 382 | ) 383 | .expect("char_forward: invalid resulting character") 384 | } 385 | 386 | fn push_as_inverse_range(buf: &mut String, c: char) { 387 | #[rustfmt::skip] 388 | let cs = [ 389 | '[', '^', 390 | char::MIN, '-', char_backward(c), 391 | char_forward(c), '-', char::MAX, 392 | ']', 393 | ]; 394 | for d in cs { 395 | buf.push(d); 396 | } 397 | } 398 | 399 | match path.find(is_special) { 400 | None => Cow::Borrowed(path), 401 | Some(index) => { 402 | let (left, right) = path.split_at(index); 403 | let mut escaped = String::with_capacity(path.len() + 1); // the +1 is for the extra \ 404 | escaped.push_str(left); 405 | for c in right.chars() { 406 | match c { 407 | '*' | '?' | '[' => { 408 | escaped.push('['); 409 | escaped.push(c); 410 | escaped.push(']'); 411 | } 412 | '\\' => { 413 | #[cfg(target_os = "windows")] 414 | escaped.push('\\'); 415 | #[cfg(not(target_os = "windows"))] 416 | escaped.push_str("\\\\"); 417 | } 418 | '\r' | '\n' => push_as_inverse_range(&mut escaped, c), 419 | c => escaped.push(c), 420 | } 421 | } 422 | Cow::Owned(escaped) 423 | } 424 | } 425 | } 426 | 427 | #[cfg(test)] 428 | mod test { 429 | use super::escape_for_exclude; 430 | 431 | #[cfg(not(target_os = "windows"))] 432 | #[test] 433 | fn escape_for_exclude_test() { 434 | assert_eq!( 435 | escape_for_exclude("foo* bar?[somethin\\g]]]\r\n"), 436 | "foo[*] bar[?][[]somethin\\\\g]]][^\0-\u{000C}\u{000E}-\u{10FFFF}][^\0-\u{0009}\u{000B}-\u{10FFFF}]" 437 | ); 438 | } 439 | 440 | #[cfg(target_os = "windows")] 441 | #[test] 442 | fn escape_for_exclude_test() { 443 | assert_eq!( 444 | escape_for_exclude("foo* bar?[somethin\\g]]]\r\n"), 445 | "foo[*] bar[?][[]somethin\\g]]][^\0-\u{000C}\u{000E}-\u{10FFFF}][^\0-\u{0009}\u{000B}-\u{10FFFF}]" 446 | ); 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /src/cache/tests.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Reverse, collections::HashSet, convert::Infallible, env, fs, iter, 3 | mem, path::PathBuf, 4 | }; 5 | 6 | use camino::{Utf8Path, Utf8PathBuf}; 7 | use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; 8 | use rusqlite::Connection; 9 | use uuid::Uuid; 10 | 11 | use crate::{ 12 | cache::{ 13 | determine_version, 14 | filetree::{InsertError, SizeTree}, 15 | get_tables, timestamp_to_datetime, Cache, EntryDetails, Migrator, 16 | }, 17 | restic::Snapshot, 18 | }; 19 | 20 | pub fn mk_datetime( 21 | year: i32, 22 | month: u32, 23 | day: u32, 24 | hour: u32, 25 | minute: u32, 26 | second: u32, 27 | ) -> DateTime { 28 | NaiveDateTime::new( 29 | NaiveDate::from_ymd_opt(year, month, day).unwrap(), 30 | NaiveTime::from_hms_opt(hour, minute, second).unwrap(), 31 | ) 32 | .and_utc() 33 | } 34 | 35 | pub struct Tempfile(pub PathBuf); 36 | 37 | impl Drop for Tempfile { 38 | fn drop(&mut self) { 39 | fs::remove_file(mem::take(&mut self.0)).unwrap(); 40 | } 41 | } 42 | 43 | impl Tempfile { 44 | pub fn new() -> Self { 45 | let mut path = env::temp_dir(); 46 | path.push(Uuid::new_v4().to_string()); 47 | Tempfile(path) 48 | } 49 | } 50 | 51 | pub fn path_parent(path: &Utf8Path) -> Option { 52 | let parent = path.parent().map(ToOwned::to_owned); 53 | parent.and_then(|p| if p.as_str().is_empty() { None } else { Some(p) }) 54 | } 55 | 56 | pub struct PathGenerator { 57 | branching_factor: usize, 58 | state: Vec<(usize, Utf8PathBuf, usize)>, 59 | } 60 | 61 | impl PathGenerator { 62 | pub fn new(depth: usize, branching_factor: usize) -> Self { 63 | let mut state = Vec::with_capacity(depth); 64 | state.push((depth, Utf8PathBuf::new(), 0)); 65 | PathGenerator { branching_factor, state } 66 | } 67 | } 68 | 69 | impl Iterator for PathGenerator { 70 | type Item = Utf8PathBuf; 71 | 72 | fn next(&mut self) -> Option { 73 | loop { 74 | let (depth, prefix, child) = self.state.pop()?; 75 | if child < self.branching_factor { 76 | let mut new_prefix = prefix.clone(); 77 | new_prefix.push(Utf8PathBuf::from(child.to_string())); 78 | self.state.push((depth, prefix, child + 1)); 79 | if depth == 1 { 80 | break Some(new_prefix); 81 | } else { 82 | self.state.push((depth - 1, new_prefix, 0)); 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | pub fn generate_sizetree(depth: usize, branching_factor: usize) -> SizeTree { 90 | let mut sizetree = SizeTree::new(); 91 | for path in PathGenerator::new(depth, branching_factor) { 92 | sizetree.insert(path.components(), 1).unwrap(); 93 | } 94 | sizetree 95 | } 96 | 97 | fn sort_entries(entries: &mut [(Vec<&str>, usize, bool)]) { 98 | entries.sort_unstable_by(|e0, e1| e0.0.cmp(&e1.0)); 99 | } 100 | 101 | fn to_sorted_entries(tree: &SizeTree) -> Vec<(Vec<&str>, usize, bool)> { 102 | let mut entries = Vec::new(); 103 | tree.0 104 | .traverse_with_context(|context, component, size, is_dir| { 105 | let mut path = Vec::from(context); 106 | path.push(component); 107 | entries.push((path, *size, is_dir)); 108 | Ok::<&str, Infallible>(component) 109 | }) 110 | .unwrap(); 111 | sort_entries(&mut entries); 112 | entries 113 | } 114 | 115 | fn assert_get_entries_correct_at_path>( 116 | cache: &Cache, 117 | tree: &SizeTree, 118 | path: P, 119 | ) { 120 | let mut db_entries = { 121 | let path_id = if path.as_ref().as_str().is_empty() { 122 | None 123 | } else { 124 | cache.get_path_id_by_path(path.as_ref()).unwrap() 125 | }; 126 | if path_id.is_none() && !path.as_ref().as_str().is_empty() { 127 | // path was not found 128 | vec![] 129 | } else { 130 | cache 131 | .get_entries(path_id) 132 | .unwrap() 133 | .into_iter() 134 | .map(|e| (e.component, e.size, e.is_dir)) 135 | .collect::>() 136 | } 137 | }; 138 | db_entries.sort_by_key(|(component, _, _)| component.clone()); 139 | let mut entries = to_sorted_entries(&tree) 140 | .iter() 141 | .filter_map(|(components, size, is_dir)| { 142 | // keep only the ones with parent == loc 143 | let (last, parent_cs) = components.split_last()?; 144 | let parent = parent_cs.iter().collect::(); 145 | if parent == path.as_ref() { 146 | Some((last.to_string(), *size, *is_dir)) 147 | } else { 148 | None 149 | } 150 | }) 151 | .collect::>(); 152 | entries.sort_by_key(|(_, size, _)| Reverse(*size)); 153 | entries.sort_by_key(|(component, _, _)| component.clone()); 154 | assert_eq!(db_entries, entries); 155 | } 156 | 157 | fn example_tree_0() -> SizeTree { 158 | let mut sizetree = SizeTree::new(); 159 | assert_eq!(sizetree.insert(["a", "0", "x"], 1), Ok(())); 160 | assert_eq!(sizetree.insert(["a", "0", "y"], 2), Ok(())); 161 | assert_eq!(sizetree.insert(["a", "1", "x", "0"], 7), Ok(())); 162 | assert_eq!(sizetree.insert(["a", "0", "z", "0"], 1), Ok(())); 163 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 2), Ok(())); 164 | sizetree 165 | } 166 | 167 | fn example_tree_1() -> SizeTree { 168 | let mut sizetree = SizeTree::new(); 169 | assert_eq!(sizetree.insert(["a", "0", "x"], 3), Ok(())); 170 | assert_eq!(sizetree.insert(["a", "0", "y"], 2), Ok(())); 171 | assert_eq!(sizetree.insert(["a", "2", "x", "0"], 7), Ok(())); 172 | assert_eq!(sizetree.insert(["a", "0", "z", "0"], 9), Ok(())); 173 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 1), Ok(())); 174 | sizetree 175 | } 176 | 177 | fn example_tree_2() -> SizeTree { 178 | let mut sizetree = SizeTree::new(); 179 | assert_eq!(sizetree.insert(["b", "0", "x"], 3), Ok(())); 180 | assert_eq!(sizetree.insert(["b", "0", "y"], 2), Ok(())); 181 | assert_eq!(sizetree.insert(["a", "2", "x", "0"], 7), Ok(())); 182 | assert_eq!(sizetree.insert(["b", "0", "z", "0"], 9), Ok(())); 183 | assert_eq!(sizetree.insert(["a", "1", "x", "1"], 1), Ok(())); 184 | sizetree 185 | } 186 | 187 | #[test] 188 | fn sizetree_iter_empty() { 189 | let sizetree = SizeTree::new(); 190 | assert_eq!(sizetree.iter().next(), None); 191 | } 192 | 193 | #[test] 194 | fn insert_uniques_0() { 195 | let tree = example_tree_0(); 196 | let entries = to_sorted_entries(&tree); 197 | assert_eq!( 198 | entries, 199 | vec![ 200 | (vec!["a"], 13, true), 201 | (vec!["a", "0"], 4, true), 202 | (vec!["a", "0", "x"], 1, false), 203 | (vec!["a", "0", "y"], 2, false), 204 | (vec!["a", "0", "z"], 1, true), 205 | (vec!["a", "0", "z", "0"], 1, false), 206 | (vec!["a", "1"], 9, true), 207 | (vec!["a", "1", "x"], 9, true), 208 | (vec!["a", "1", "x", "0"], 7, false), 209 | (vec!["a", "1", "x", "1"], 2, false), 210 | ] 211 | ); 212 | } 213 | 214 | #[test] 215 | fn insert_uniques_1() { 216 | let tree = example_tree_1(); 217 | let entries = to_sorted_entries(&tree); 218 | assert_eq!( 219 | entries, 220 | vec![ 221 | (vec!["a"], 22, true), 222 | (vec!["a", "0"], 14, true), 223 | (vec!["a", "0", "x"], 3, false), 224 | (vec!["a", "0", "y"], 2, false), 225 | (vec!["a", "0", "z"], 9, true), 226 | (vec!["a", "0", "z", "0"], 9, false), 227 | (vec!["a", "1"], 1, true), 228 | (vec!["a", "1", "x"], 1, true), 229 | (vec!["a", "1", "x", "1"], 1, false), 230 | (vec!["a", "2"], 7, true), 231 | (vec!["a", "2", "x"], 7, true), 232 | (vec!["a", "2", "x", "0"], 7, false), 233 | ] 234 | ); 235 | } 236 | 237 | #[test] 238 | fn insert_uniques_2() { 239 | let tree = example_tree_2(); 240 | let entries = to_sorted_entries(&tree); 241 | assert_eq!( 242 | entries, 243 | vec![ 244 | (vec!["a"], 8, true), 245 | (vec!["a", "1"], 1, true), 246 | (vec!["a", "1", "x"], 1, true), 247 | (vec!["a", "1", "x", "1"], 1, false), 248 | (vec!["a", "2"], 7, true), 249 | (vec!["a", "2", "x"], 7, true), 250 | (vec!["a", "2", "x", "0"], 7, false), 251 | (vec!["b"], 14, true), 252 | (vec!["b", "0"], 14, true), 253 | (vec!["b", "0", "x"], 3, false), 254 | (vec!["b", "0", "y"], 2, false), 255 | (vec!["b", "0", "z"], 9, true), 256 | (vec!["b", "0", "z", "0"], 9, false), 257 | ] 258 | ); 259 | } 260 | 261 | #[test] 262 | fn insert_existing() { 263 | let mut sizetree = example_tree_0(); 264 | assert_eq!( 265 | sizetree.insert(Vec::<&str>::new(), 1), 266 | Err(InsertError::EntryExists) 267 | ); 268 | assert_eq!(sizetree.insert(["a", "0"], 1), Err(InsertError::EntryExists)); 269 | assert_eq!( 270 | sizetree.insert(["a", "0", "z", "0"], 1), 271 | Err(InsertError::EntryExists) 272 | ); 273 | } 274 | 275 | #[test] 276 | fn merge_test() { 277 | let tree = example_tree_0().merge(example_tree_1()); 278 | let entries = to_sorted_entries(&tree); 279 | assert_eq!( 280 | entries, 281 | vec![ 282 | (vec!["a"], 22, true), 283 | (vec!["a", "0"], 14, true), 284 | (vec!["a", "0", "x"], 3, false), 285 | (vec!["a", "0", "y"], 2, false), 286 | (vec!["a", "0", "z"], 9, true), 287 | (vec!["a", "0", "z", "0"], 9, false), 288 | (vec!["a", "1"], 9, true), 289 | (vec!["a", "1", "x"], 9, true), 290 | (vec!["a", "1", "x", "0"], 7, false), 291 | (vec!["a", "1", "x", "1"], 2, false), 292 | (vec!["a", "2"], 7, true), 293 | (vec!["a", "2", "x"], 7, true), 294 | (vec!["a", "2", "x", "0"], 7, false), 295 | ] 296 | ); 297 | } 298 | 299 | #[test] 300 | fn merge_reflexivity() { 301 | assert_eq!(example_tree_0().merge(example_tree_0()), example_tree_0()); 302 | assert_eq!(example_tree_1().merge(example_tree_1()), example_tree_1()); 303 | } 304 | 305 | #[test] 306 | fn merge_associativity() { 307 | assert_eq!( 308 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 309 | example_tree_0().merge(example_tree_1().merge(example_tree_2())) 310 | ); 311 | } 312 | 313 | #[test] 314 | fn merge_commutativity() { 315 | assert_eq!( 316 | example_tree_0().merge(example_tree_1()), 317 | example_tree_1().merge(example_tree_0()) 318 | ); 319 | } 320 | 321 | #[test] 322 | fn cache_snapshots_entries() { 323 | fn test_snapshots(cache: &Cache, mut snapshots: Vec<&Snapshot>) { 324 | let mut db_snapshots = cache.get_snapshots().unwrap(); 325 | db_snapshots.sort_unstable_by(|s0, s1| s0.id.cmp(&s1.id)); 326 | snapshots.sort_unstable_by(|s0, s1| s0.id.cmp(&s1.id)); 327 | for (s0, s1) in iter::zip(db_snapshots.iter(), snapshots.iter()) { 328 | assert_eq!(s0.id, s1.id); 329 | assert_eq!(s0.time, s1.time); 330 | assert_eq!(s0.parent, s1.parent); 331 | assert_eq!(s0.tree, s1.tree); 332 | assert_eq!(s0.hostname, s1.hostname); 333 | assert_eq!(s0.username, s1.username); 334 | assert_eq!(s0.uid, s1.uid); 335 | assert_eq!(s0.gid, s1.gid); 336 | assert_eq!(s0.original_id, s1.original_id); 337 | assert_eq!(s0.program_version, s1.program_version); 338 | 339 | let mut s0_paths: Vec = s0.paths.iter().cloned().collect(); 340 | s0_paths.sort(); 341 | let mut s1_paths: Vec = s1.paths.iter().cloned().collect(); 342 | s1_paths.sort(); 343 | assert_eq!(s0_paths, s1_paths); 344 | 345 | let mut s0_excludes: Vec = 346 | s0.excludes.iter().cloned().collect(); 347 | s0_excludes.sort(); 348 | let mut s1_excludes: Vec = 349 | s1.excludes.iter().cloned().collect(); 350 | s1_excludes.sort(); 351 | assert_eq!(s0_excludes, s1_excludes); 352 | 353 | let mut s0_tags: Vec = s0.tags.iter().cloned().collect(); 354 | s0_tags.sort(); 355 | let mut s1_tags: Vec = s1.tags.iter().cloned().collect(); 356 | s1_tags.sort(); 357 | assert_eq!(s0_tags, s1_tags); 358 | } 359 | } 360 | 361 | let tempfile = Tempfile::new(); 362 | let mut cache = Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 363 | 364 | let foo = Snapshot { 365 | id: "foo".to_string(), 366 | time: mk_datetime(2024, 4, 12, 12, 00, 00), 367 | parent: Some("bar".to_string()), 368 | tree: "sometree".to_string(), 369 | paths: vec![ 370 | "/home/user".to_string(), 371 | "/etc".to_string(), 372 | "/var".to_string(), 373 | ] 374 | .into_iter() 375 | .collect(), 376 | hostname: Some("foo.com".to_string()), 377 | username: Some("user".to_string()), 378 | uid: Some(123), 379 | gid: Some(456), 380 | excludes: vec![ 381 | ".cache".to_string(), 382 | "Cache".to_string(), 383 | "/home/user/Downloads".to_string(), 384 | ] 385 | .into_iter() 386 | .collect(), 387 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 388 | .into_iter() 389 | .collect(), 390 | original_id: Some("fefwfwew".to_string()), 391 | program_version: Some("restic 0.16.0".to_string()), 392 | }; 393 | 394 | let bar = Snapshot { 395 | id: "bar".to_string(), 396 | time: mk_datetime(2025, 5, 12, 17, 00, 00), 397 | parent: Some("wat".to_string()), 398 | tree: "anothertree".to_string(), 399 | paths: vec!["/home/user".to_string()].into_iter().collect(), 400 | hostname: Some("foo.com".to_string()), 401 | username: Some("user".to_string()), 402 | uid: Some(123), 403 | gid: Some(456), 404 | excludes: vec![ 405 | ".cache".to_string(), 406 | "Cache".to_string(), 407 | "/home/user/Downloads".to_string(), 408 | ] 409 | .into_iter() 410 | .collect(), 411 | tags: vec!["foo_machine".to_string(), "rewrite".to_string()] 412 | .into_iter() 413 | .collect(), 414 | original_id: Some("fefwfwew".to_string()), 415 | program_version: Some("restic 0.16.0".to_string()), 416 | }; 417 | 418 | let wat = Snapshot { 419 | id: "wat".to_string(), 420 | time: mk_datetime(2023, 5, 12, 17, 00, 00), 421 | parent: None, 422 | tree: "fwefwfwwefwefwe".to_string(), 423 | paths: HashSet::new(), 424 | hostname: None, 425 | username: None, 426 | uid: None, 427 | gid: None, 428 | excludes: HashSet::new(), 429 | tags: HashSet::new(), 430 | original_id: None, 431 | program_version: None, 432 | }; 433 | 434 | cache.save_snapshot(&foo, example_tree_0()).unwrap(); 435 | cache.save_snapshot(&bar, example_tree_1()).unwrap(); 436 | cache.save_snapshot(&wat, example_tree_2()).unwrap(); 437 | 438 | test_snapshots(&cache, vec![&foo, &bar, &wat]); 439 | 440 | fn test_entries(cache: &Cache, sizetree: SizeTree) { 441 | assert_get_entries_correct_at_path(cache, &sizetree, ""); 442 | assert_get_entries_correct_at_path(cache, &sizetree, "a"); 443 | assert_get_entries_correct_at_path(cache, &sizetree, "b"); 444 | assert_get_entries_correct_at_path(cache, &sizetree, "a/0"); 445 | assert_get_entries_correct_at_path(cache, &sizetree, "a/1"); 446 | assert_get_entries_correct_at_path(cache, &sizetree, "a/2"); 447 | assert_get_entries_correct_at_path(cache, &sizetree, "b/0"); 448 | assert_get_entries_correct_at_path(cache, &sizetree, "b/1"); 449 | assert_get_entries_correct_at_path(cache, &sizetree, "b/2"); 450 | assert_get_entries_correct_at_path(cache, &sizetree, "something"); 451 | assert_get_entries_correct_at_path(cache, &sizetree, "a/something"); 452 | } 453 | 454 | test_entries( 455 | &cache, 456 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 457 | ); 458 | 459 | // Deleting a non-existent snapshot does nothing 460 | cache.delete_snapshot("non-existent").unwrap(); 461 | test_snapshots(&cache, vec![&foo, &bar, &wat]); 462 | test_entries( 463 | &cache, 464 | example_tree_0().merge(example_tree_1()).merge(example_tree_2()), 465 | ); 466 | 467 | // Remove bar 468 | cache.delete_snapshot("bar").unwrap(); 469 | test_snapshots(&cache, vec![&foo, &wat]); 470 | test_entries(&cache, example_tree_0().merge(example_tree_2())); 471 | } 472 | 473 | // TODO: Ideally we would run more than 10_000 but at the moment this is too slow. 474 | #[test] 475 | fn lots_of_snapshots() { 476 | let tempfile = Tempfile::new(); 477 | let mut cache = Migrator::open(&tempfile.0).unwrap().migrate().unwrap(); 478 | 479 | const NUM_SNAPSHOTS: usize = 10_000; 480 | 481 | // Insert lots of snapshots 482 | for i in 0..NUM_SNAPSHOTS { 483 | let snapshot = Snapshot { 484 | id: i.to_string(), 485 | time: timestamp_to_datetime(i as i64).unwrap(), 486 | parent: None, 487 | tree: i.to_string(), 488 | paths: HashSet::new(), 489 | hostname: None, 490 | username: None, 491 | uid: None, 492 | gid: None, 493 | excludes: HashSet::new(), 494 | tags: HashSet::new(), 495 | original_id: None, 496 | program_version: None, 497 | }; 498 | cache.save_snapshot(&snapshot, example_tree_0()).unwrap(); 499 | } 500 | 501 | // get_entries 502 | let tree = example_tree_0(); 503 | for path in ["", "a", "a/0", "a/1", "a/1/x", "a/something"] { 504 | assert_get_entries_correct_at_path(&cache, &tree, path); 505 | } 506 | 507 | // get_entry_details 508 | let path_id = cache.get_path_id_by_path("a/0".into()).unwrap().unwrap(); 509 | let details = cache.get_entry_details(path_id).unwrap().unwrap(); 510 | assert_eq!( 511 | details, 512 | EntryDetails { 513 | max_size: 4, 514 | max_size_snapshot_hash: (NUM_SNAPSHOTS - 1).to_string(), 515 | first_seen: timestamp_to_datetime(0).unwrap(), 516 | first_seen_snapshot_hash: 0.to_string(), 517 | last_seen: timestamp_to_datetime((NUM_SNAPSHOTS - 1) as i64) 518 | .unwrap(), 519 | last_seen_snapshot_hash: (NUM_SNAPSHOTS - 1).to_string(), 520 | } 521 | ); 522 | } 523 | 524 | ////////// Migrations ////////////////////////////////////////////////////////// 525 | fn assert_tables(conn: &Connection, tables: &[&str]) { 526 | let mut actual_tables: Vec = 527 | get_tables(conn).unwrap().into_iter().collect(); 528 | actual_tables.sort(); 529 | let mut expected_tables: Vec = 530 | tables.iter().map(ToString::to_string).collect(); 531 | expected_tables.sort(); 532 | assert_eq!(actual_tables, expected_tables); 533 | } 534 | 535 | fn assert_marks(cache: &Cache, marks: &[&str]) { 536 | let mut actual_marks = cache.get_marks().unwrap(); 537 | actual_marks.sort(); 538 | let mut expected_marks: Vec = 539 | marks.iter().map(Utf8PathBuf::from).collect(); 540 | expected_marks.sort(); 541 | assert_eq!(actual_marks, expected_marks); 542 | } 543 | 544 | fn populate_v0<'a>( 545 | marks: impl IntoIterator, 546 | ) -> Result { 547 | let file = Tempfile::new(); 548 | let mut cache = Migrator::open_with_target(&file.0, 0)?.migrate()?; 549 | let tx = cache.conn.transaction()?; 550 | { 551 | let mut marks_stmt = 552 | tx.prepare("INSERT INTO marks (path) VALUES (?)")?; 553 | for mark in marks { 554 | marks_stmt.execute([mark])?; 555 | } 556 | } 557 | tx.commit()?; 558 | Ok(file) 559 | } 560 | 561 | #[test] 562 | fn test_migrate_v0_to_v1() { 563 | let marks = ["/foo", "/bar/wat", "foo/a/b/c", "something"]; 564 | let file = populate_v0(marks).unwrap(); 565 | 566 | let cache = 567 | Migrator::open_with_target(&file.0, 1).unwrap().migrate().unwrap(); 568 | 569 | assert_tables( 570 | &cache.conn, 571 | &[ 572 | "metadata_integer", 573 | "paths", 574 | "snapshots", 575 | "snapshot_paths", 576 | "snapshot_excludes", 577 | "snapshot_tags", 578 | "marks", 579 | ], 580 | ); 581 | 582 | assert_marks(&cache, &marks); 583 | 584 | assert_eq!(determine_version(&cache.conn).unwrap(), Some(1)); 585 | 586 | cache_snapshots_entries(); 587 | } 588 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | io::{self, stderr}, 4 | sync::{ 5 | atomic::{AtomicBool, Ordering}, 6 | mpsc::{self, RecvTimeoutError}, 7 | Arc, Mutex, 8 | }, 9 | thread::{self, ScopedJoinHandle}, 10 | time::{Duration, Instant}, 11 | }; 12 | 13 | use anyhow::Context; 14 | use args::Args; 15 | use camino::{Utf8Path, Utf8PathBuf}; 16 | use chrono::Local; 17 | use crossterm::{ 18 | event::{KeyCode, KeyModifiers}, 19 | terminal::{ 20 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, 21 | LeaveAlternateScreen, 22 | }, 23 | ExecutableCommand, 24 | }; 25 | use directories::ProjectDirs; 26 | use log::{debug, error, info, trace, LevelFilter}; 27 | use rand::{rng, seq::SliceRandom}; 28 | use ratatui::{ 29 | backend::{Backend, CrosstermBackend}, 30 | layout::Size, 31 | style::Stylize, 32 | widgets::WidgetRef, 33 | CompletedFrame, Terminal, 34 | }; 35 | use redu::{ 36 | cache::{self, filetree::SizeTree, Cache, Migrator}, 37 | reporter::{Counter, NullReporter, Reporter, TermReporter}, 38 | restic::{self, escape_for_exclude, Restic, Snapshot}, 39 | }; 40 | use scopeguard::defer; 41 | use simplelog::{ThreadLogMode, WriteLogger}; 42 | use thiserror::Error; 43 | use util::snapshot_short_id; 44 | 45 | use crate::ui::{Action, App, Event}; 46 | 47 | mod args; 48 | mod ui; 49 | mod util; 50 | 51 | // Print the message via the reporter and log at INFO level 52 | macro_rules! info_report { 53 | ($reporter:expr, $($arg:expr),+) => {{ 54 | let msg = format!($($arg),+); 55 | $reporter.print(&msg); 56 | info!("{msg}"); 57 | }}; 58 | } 59 | 60 | fn main() -> anyhow::Result<()> { 61 | let args = Args::parse(); 62 | let restic = Restic::new(args.repository, args.password, args.no_cache); 63 | 64 | let dirs = ProjectDirs::from("eu", "drdo", "redu") 65 | .expect("unable to determine project directory"); 66 | 67 | // Initialize the logger 68 | let log_config = simplelog::ConfigBuilder::new() 69 | .set_target_level(LevelFilter::Error) 70 | .set_thread_mode(ThreadLogMode::Names) 71 | .build(); 72 | 73 | if args.non_interactive { 74 | WriteLogger::init(args.log_level, log_config, stderr())?; 75 | } else { 76 | fn generate_filename() -> String { 77 | format!("{}.log", Local::now().format("%Y-%m-%dT%H-%M-%S%.f%z")) 78 | } 79 | 80 | let mut path = dirs.data_local_dir().to_path_buf(); 81 | path.push(Utf8Path::new("logs")); 82 | fs::create_dir_all(&path)?; 83 | path.push(generate_filename()); 84 | let file = loop { 85 | // Spin until we hit a timestamp that isn't taken yet. 86 | // With the level of precision that we are using this should virtually 87 | // never run more than once. 88 | match fs::OpenOptions::new() 89 | .write(true) 90 | .create_new(true) 91 | .open(&path) 92 | { 93 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { 94 | path.set_file_name(generate_filename()) 95 | } 96 | x => break x, 97 | } 98 | }?; 99 | 100 | eprintln!("Logging to {:#?}", path); 101 | 102 | WriteLogger::init(args.log_level, log_config, file)?; 103 | } 104 | 105 | unsafe { 106 | rusqlite::trace::config_log(Some(|code, msg| { 107 | error!(target: "sqlite", "({code}) {msg}"); 108 | }))?; 109 | } 110 | 111 | let reporter: Arc = if args.non_interactive { 112 | Arc::new(NullReporter::new()) 113 | } else { 114 | Arc::new(TermReporter::new()) 115 | }; 116 | 117 | let mut cache = { 118 | // Get config to determine repo id and open cache 119 | let progress = reporter.add_loader(0, "Getting restic config"); 120 | let repo_id = restic.config()?.id; 121 | progress.end(); 122 | 123 | let cache_file = { 124 | let mut path = dirs.cache_dir().to_path_buf(); 125 | path.push(format!("{repo_id}.db")); 126 | path 127 | }; 128 | 129 | let err_msg = format!( 130 | "unable to create cache directory at {}", 131 | dirs.cache_dir().to_string_lossy(), 132 | ); 133 | fs::create_dir_all(dirs.cache_dir()).expect(&err_msg); 134 | 135 | info_report!(reporter, "Using cache file {cache_file:#?}"); 136 | let migrator = 137 | Migrator::open(&cache_file).context("unable to open cache file")?; 138 | if let Some((old, new)) = migrator.need_to_migrate() { 139 | info_report!( 140 | reporter, 141 | "Need to upgrade cache version from {old:?} to {new:?}" 142 | ); 143 | let mut msg = String::from("Upgrading cache version"); 144 | if migrator.resync_necessary() { 145 | msg.push_str(" (a resync will be necessary)"); 146 | } 147 | let progress = reporter.add_loader(0, &msg); 148 | let cache = migrator.migrate().context("cache migration failed")?; 149 | progress.end(); 150 | cache 151 | } else { 152 | migrator.migrate().context("there is a problem with the cache")? 153 | } 154 | }; 155 | 156 | sync_snapshots(&restic, &mut cache, reporter.clone(), args.parallelism)?; 157 | 158 | if args.non_interactive { 159 | info_report!(reporter, "Finished syncing"); 160 | } else { 161 | let paths = ui(&*reporter, cache)?; 162 | for line in paths { 163 | println!("{}", escape_for_exclude(line.as_str())); 164 | } 165 | } 166 | 167 | Ok(()) 168 | } 169 | 170 | fn sync_snapshots( 171 | restic: &Restic, 172 | cache: &mut Cache, 173 | reporter: Arc, 174 | fetching_thread_count: usize, 175 | ) -> anyhow::Result<()> { 176 | let progress = reporter.add_loader(0, "Fetching repository snapshot list"); 177 | let repo_snapshots = restic.snapshots()?; 178 | progress.end(); 179 | 180 | let cache_snapshots = cache.get_snapshots()?; 181 | 182 | // Delete snapshots from the DB that were deleted on the repo 183 | let snapshots_to_delete: Vec<&Snapshot> = cache_snapshots 184 | .iter() 185 | .filter(|cache_snapshot| { 186 | !repo_snapshots 187 | .iter() 188 | .any(|repo_snapshot| cache_snapshot.id == repo_snapshot.id) 189 | }) 190 | .collect(); 191 | if !snapshots_to_delete.is_empty() { 192 | info_report!( 193 | reporter, 194 | "Need to delete {} snapshot(s)", 195 | snapshots_to_delete.len() 196 | ); 197 | let mut bar = reporter.add_bar( 198 | 0, 199 | "Deleting snapshots ", 200 | snapshots_to_delete.len() as u64, 201 | ); 202 | for snapshot in snapshots_to_delete { 203 | cache.delete_snapshot(&snapshot.id)?; 204 | info!("deleted snapshot {}", snapshot.id); 205 | bar.inc(1); 206 | } 207 | bar.end(); 208 | } 209 | 210 | let mut missing_snapshots: Vec = repo_snapshots 211 | .into_iter() 212 | .filter(|repo_snapshot| { 213 | !cache_snapshots 214 | .iter() 215 | .any(|cache_snapshot| cache_snapshot.id == repo_snapshot.id) 216 | }) 217 | .collect(); 218 | missing_snapshots.shuffle(&mut rng()); 219 | let total_missing_snapshots = match missing_snapshots.len() { 220 | 0 => { 221 | info_report!(reporter, "Snapshots up to date"); 222 | return Ok(()); 223 | } 224 | n => { 225 | info_report!(reporter, "Need to fetch {n} snapshot(s)"); 226 | n 227 | } 228 | }; 229 | let missing_queue = FixedSizeQueue::new(missing_snapshots); 230 | 231 | let fetch_snapshots_bar = reporter.add_bar( 232 | 0, 233 | "Fetching snapshots ", 234 | total_missing_snapshots as u64, 235 | ); 236 | 237 | const SHOULD_QUIT_POLL_PERIOD: Duration = Duration::from_millis(500); 238 | 239 | thread::scope(|scope| { 240 | macro_rules! spawn { 241 | ($name_fmt:literal, $scope:expr, $thunk:expr) => { 242 | thread::Builder::new() 243 | .name(format!($name_fmt)) 244 | .spawn_scoped($scope, $thunk)? 245 | }; 246 | } 247 | let mut handles: Vec>> = Vec::new(); 248 | 249 | // TODO: Check that we are correctly handling the situation where a thread panics 250 | 251 | // The threads periodically poll this to see if they should 252 | // prematurely terminate (when other threads get unrecoverable errors). 253 | let should_quit: Arc = Arc::new(AtomicBool::new(false)); 254 | 255 | // Channel to funnel snapshots from the fetching threads to the db thread 256 | let (snapshot_sender, snapshot_receiver) = 257 | mpsc::sync_channel::<(Snapshot, SizeTree)>(fetching_thread_count); 258 | 259 | // Start fetching threads 260 | for i in 0..fetching_thread_count { 261 | let missing_queue = missing_queue.clone(); 262 | let snapshot_sender = snapshot_sender.clone(); 263 | let reporter = reporter.clone(); 264 | let should_quit = should_quit.clone(); 265 | handles.push(spawn!("fetching-{i}", &scope, move || { 266 | fetching_thread_body( 267 | restic, 268 | missing_queue, 269 | reporter, 270 | snapshot_sender, 271 | should_quit.clone(), 272 | ) 273 | .inspect_err(|_| should_quit.store(true, Ordering::SeqCst)) 274 | .map_err(anyhow::Error::from) 275 | })); 276 | } 277 | // Drop the leftover channel so that the db thread 278 | // can properly terminate when all snapshot senders are closed 279 | drop(snapshot_sender); 280 | 281 | // Start DB thread 282 | handles.push({ 283 | let reporter = reporter.clone(); 284 | let should_quit = should_quit.clone(); 285 | spawn!("db", &scope, move || { 286 | db_thread_body( 287 | cache, 288 | &*reporter, 289 | fetch_snapshots_bar, 290 | snapshot_receiver, 291 | should_quit.clone(), 292 | SHOULD_QUIT_POLL_PERIOD, 293 | ) 294 | .inspect_err(|_| should_quit.store(true, Ordering::SeqCst)) 295 | .map_err(anyhow::Error::from) 296 | }) 297 | }); 298 | 299 | for handle in handles { 300 | handle.join().unwrap()? 301 | } 302 | Ok(()) 303 | }) 304 | } 305 | 306 | #[derive(Debug, Error)] 307 | #[error("error in fetching thread")] 308 | enum FetchingThreadError { 309 | ResticLaunch(#[from] restic::LaunchError), 310 | Restic(#[from] restic::Error), 311 | Cache(#[from] rusqlite::Error), 312 | } 313 | 314 | fn fetching_thread_body( 315 | restic: &Restic, 316 | missing_queue: FixedSizeQueue, 317 | reporter: Arc, 318 | snapshot_sender: mpsc::SyncSender<(Snapshot, SizeTree)>, 319 | should_quit: Arc, 320 | ) -> Result<(), FetchingThreadError> { 321 | defer! { trace!("terminated") } 322 | trace!("started"); 323 | while let Some(snapshot) = missing_queue.pop() { 324 | let short_id = snapshot_short_id(&snapshot.id); 325 | let mut progress = reporter.add_counter( 326 | 4, 327 | &format!("fetching {short_id} "), 328 | " file(s)", 329 | ); 330 | let mut sizetree = SizeTree::new(); 331 | let files = restic.ls(&snapshot.id)?; 332 | trace!("started fetching snapshot ({short_id})"); 333 | let start = Instant::now(); 334 | for r in files { 335 | if should_quit.load(Ordering::SeqCst) { 336 | return Ok(()); 337 | } 338 | let file = r?; 339 | sizetree 340 | .insert(file.path.components(), file.size) 341 | .expect("repeated entry in restic snapshot ls"); 342 | progress.inc(1); 343 | } 344 | progress.end(); 345 | info!( 346 | "snapshot fetched in {}s ({short_id})", 347 | start.elapsed().as_secs_f64() 348 | ); 349 | if should_quit.load(Ordering::SeqCst) { 350 | return Ok(()); 351 | } 352 | let start = Instant::now(); 353 | snapshot_sender.send((snapshot.clone(), sizetree)).unwrap(); 354 | debug!( 355 | "waited {}s to send snapshot ({short_id})", 356 | start.elapsed().as_secs_f64() 357 | ); 358 | } 359 | Ok(()) 360 | } 361 | 362 | #[derive(Debug, Error)] 363 | #[error("error in db thread")] 364 | enum DBThreadError { 365 | CacheError(#[from] rusqlite::Error), 366 | } 367 | 368 | fn db_thread_body( 369 | cache: &mut Cache, 370 | reporter: &R, 371 | mut fetch_snapshots_bar: Box, 372 | snapshot_receiver: mpsc::Receiver<(Snapshot, SizeTree)>, 373 | should_quit: Arc, 374 | should_quit_poll_period: Duration, 375 | ) -> Result<(), DBThreadError> { 376 | defer! { trace!("terminated") } 377 | trace!("started"); 378 | loop { 379 | trace!("waiting for snapshot"); 380 | if should_quit.load(Ordering::SeqCst) { 381 | return Ok(()); 382 | } 383 | let start = Instant::now(); 384 | // We wait with timeout to poll the should_quit periodically 385 | match snapshot_receiver.recv_timeout(should_quit_poll_period) { 386 | Ok((snapshot, sizetree)) => { 387 | debug!( 388 | "waited {}s to get snapshot", 389 | start.elapsed().as_secs_f64() 390 | ); 391 | trace!("got snapshot, saving"); 392 | if should_quit.load(Ordering::SeqCst) { 393 | return Ok(()); 394 | } 395 | let short_id = snapshot_short_id(&snapshot.id); 396 | let progress = 397 | reporter.add_loader(4, &format!("saving {short_id}")); 398 | let start = Instant::now(); 399 | let file_count = cache.save_snapshot(&snapshot, sizetree)?; 400 | progress.end(); 401 | fetch_snapshots_bar.inc(1); 402 | info!( 403 | "waited {}s to save snapshot ({} files)", 404 | start.elapsed().as_secs_f64(), 405 | file_count 406 | ); 407 | trace!("snapshot saved"); 408 | } 409 | Err(RecvTimeoutError::Timeout) => continue, 410 | Err(RecvTimeoutError::Disconnected) => { 411 | trace!("loop done"); 412 | break Ok(()); 413 | } 414 | } 415 | } 416 | } 417 | 418 | fn convert_event(event: crossterm::event::Event) -> Option { 419 | use crossterm::event::{Event as TermEvent, KeyEventKind}; 420 | use ui::Event::*; 421 | 422 | const KEYBINDINGS: &[((KeyModifiers, KeyCode), Event)] = &[ 423 | ((KeyModifiers::empty(), KeyCode::Left), Left), 424 | ((KeyModifiers::empty(), KeyCode::Char('h')), Left), 425 | ((KeyModifiers::empty(), KeyCode::Right), Right), 426 | ((KeyModifiers::empty(), KeyCode::Char('l')), Right), 427 | ((KeyModifiers::empty(), KeyCode::Up), Up), 428 | ((KeyModifiers::empty(), KeyCode::Char('k')), Up), 429 | ((KeyModifiers::empty(), KeyCode::Down), Down), 430 | ((KeyModifiers::empty(), KeyCode::Char('j')), Down), 431 | ((KeyModifiers::empty(), KeyCode::PageUp), PageUp), 432 | ((KeyModifiers::CONTROL, KeyCode::Char('b')), PageUp), 433 | ((KeyModifiers::empty(), KeyCode::PageDown), PageDown), 434 | ((KeyModifiers::CONTROL, KeyCode::Char('f')), PageDown), 435 | ((KeyModifiers::empty(), KeyCode::Enter), Enter), 436 | ((KeyModifiers::empty(), KeyCode::Esc), Exit), 437 | ((KeyModifiers::empty(), KeyCode::Char('m')), Mark), 438 | ((KeyModifiers::empty(), KeyCode::Char('u')), Unmark), 439 | ((KeyModifiers::empty(), KeyCode::Char('c')), UnmarkAll), 440 | ((KeyModifiers::empty(), KeyCode::Char('q')), Quit), 441 | ((KeyModifiers::empty(), KeyCode::Char('g')), Generate), 442 | ]; 443 | match event { 444 | TermEvent::Resize(w, h) => Some(Resize(Size::new(w, h))), 445 | TermEvent::Key(event) if event.kind == KeyEventKind::Press => { 446 | KEYBINDINGS.iter().find_map(|((mods, code), ui_event)| { 447 | if event.modifiers == *mods && event.code == *code { 448 | Some(ui_event.clone()) 449 | } else { 450 | None 451 | } 452 | }) 453 | } 454 | _ => None, 455 | } 456 | } 457 | 458 | fn ui( 459 | reporter: &R, 460 | mut cache: Cache, 461 | ) -> anyhow::Result> { 462 | let entries = cache.get_entries(None)?; 463 | if entries.is_empty() { 464 | info_report!(reporter, "The repository is empty!"); 465 | return Ok(vec![]); 466 | } 467 | 468 | stderr().execute(EnterAlternateScreen)?; 469 | defer! { 470 | stderr().execute(LeaveAlternateScreen).unwrap(); 471 | } 472 | enable_raw_mode()?; 473 | defer! { 474 | disable_raw_mode().unwrap(); 475 | } 476 | let mut terminal = Terminal::new(CrosstermBackend::new(stderr()))?; 477 | terminal.clear()?; 478 | 479 | let mut app = { 480 | let rect = terminal.size()?; 481 | App::new( 482 | rect, 483 | None, 484 | Utf8PathBuf::new(), 485 | entries, 486 | cache.get_marks()?, 487 | vec![ 488 | "Enter".bold(), 489 | ":Details ".into(), 490 | "m".bold(), 491 | ":Mark ".into(), 492 | "u".bold(), 493 | ":Unmark ".into(), 494 | "c".bold(), 495 | ":ClearAllMarks ".into(), 496 | "g".bold(), 497 | ":Generate ".into(), 498 | "q".bold(), 499 | ":Quit".into(), 500 | ], 501 | ) 502 | }; 503 | 504 | render(&mut terminal, &app)?; 505 | loop { 506 | let mut o_event = convert_event(crossterm::event::read()?); 507 | while let Some(event) = o_event { 508 | o_event = match app.update(event) { 509 | Action::Nothing => None, 510 | Action::Render => { 511 | render(&mut terminal, &app)?; 512 | None 513 | } 514 | Action::Quit => return Ok(vec![]), 515 | Action::Generate(paths) => return Ok(paths), 516 | Action::GetParentEntries(path_id) => { 517 | let parent_id = cache.get_parent_id(path_id)? 518 | .expect("The UI requested a GetParentEntries with a path_id that does not exist"); 519 | let entries = cache.get_entries(parent_id)?; 520 | Some(Event::Entries { path_id: parent_id, entries }) 521 | } 522 | Action::GetEntries(path_id) => { 523 | let entries = cache.get_entries(path_id)?; 524 | Some(Event::Entries { path_id, entries }) 525 | } 526 | Action::GetEntryDetails(path_id) => 527 | Some(Event::EntryDetails(cache.get_entry_details(path_id)? 528 | .expect("The UI requested a GetEntryDetails with a path_id that does not exist"))), 529 | Action::UpsertMark(path) => { 530 | cache.upsert_mark(&path)?; 531 | Some(Event::Marks(cache.get_marks()?)) 532 | } 533 | Action::DeleteMark(loc) => { 534 | cache.delete_mark(&loc).unwrap(); 535 | Some(Event::Marks(cache.get_marks()?)) 536 | } 537 | Action::DeleteAllMarks => { 538 | cache.delete_all_marks()?; 539 | Some(Event::Marks(Vec::new())) 540 | } 541 | } 542 | } 543 | } 544 | } 545 | 546 | fn render<'a>( 547 | terminal: &'a mut Terminal, 548 | app: &App, 549 | ) -> io::Result> { 550 | terminal.draw(|frame| { 551 | let area = frame.area(); 552 | let buf = frame.buffer_mut(); 553 | app.render_ref(area, buf) 554 | }) 555 | } 556 | 557 | /// Util /////////////////////////////////////////////////////////////////////// 558 | #[derive(Clone)] 559 | struct FixedSizeQueue(Arc>>); 560 | 561 | impl FixedSizeQueue { 562 | fn new(data: Vec) -> Self { 563 | FixedSizeQueue(Arc::new(Mutex::new(data))) 564 | } 565 | 566 | fn pop(&self) -> Option { 567 | self.0.lock().unwrap().pop() 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::{max, Reverse}, 3 | collections::{HashMap, HashSet}, 4 | path::Path, 5 | }; 6 | 7 | use camino::{Utf8Path, Utf8PathBuf}; 8 | use chrono::{DateTime, Utc}; 9 | use log::trace; 10 | use rusqlite::{ 11 | functions::FunctionFlags, 12 | params, 13 | trace::{TraceEvent, TraceEventCodes}, 14 | types::FromSqlError, 15 | Connection, OptionalExtension, 16 | }; 17 | use thiserror::Error; 18 | 19 | use crate::{cache::filetree::SizeTree, restic::Snapshot}; 20 | 21 | pub mod filetree; 22 | #[cfg(any(test, feature = "bench"))] 23 | pub mod tests; 24 | 25 | #[derive(Debug)] 26 | pub struct Cache { 27 | conn: Connection, 28 | } 29 | 30 | #[derive(Error, Debug)] 31 | pub enum OpenError { 32 | #[error("Sqlite error")] 33 | Sqlite(#[from] rusqlite::Error), 34 | #[error("Error running migrations")] 35 | Migration(#[from] MigrationError), 36 | } 37 | 38 | #[derive(Error, Debug)] 39 | pub enum Error { 40 | #[error("SQL error")] 41 | Sql(#[from] rusqlite::Error), 42 | #[error("Unexpected SQL datatype")] 43 | FromSqlError(#[from] FromSqlError), 44 | #[error("Error parsing JSON")] 45 | Json(#[from] serde_json::Error), 46 | #[error("Exhausted timestamp precision (a couple hundred thousand years after the epoch).")] 47 | ExhaustedTimestampPrecision, 48 | } 49 | 50 | impl Cache { 51 | pub fn get_snapshots(&self) -> Result, Error> { 52 | self.conn 53 | .prepare( 54 | "SELECT \ 55 | hash, \ 56 | time, \ 57 | parent, \ 58 | tree, \ 59 | hostname, \ 60 | username, \ 61 | uid, \ 62 | gid, \ 63 | original_id, \ 64 | program_version, \ 65 | coalesce((SELECT json_group_array(path) FROM snapshot_paths WHERE hash = snapshots.hash), json_array()) as paths, \ 66 | coalesce((SELECT json_group_array(path) FROM snapshot_excludes WHERE hash = snapshots.hash), json_array()) as excludes, \ 67 | coalesce((SELECT json_group_array(tag) FROM snapshot_tags WHERE hash = snapshots.hash), json_array()) as tags \ 68 | FROM snapshots")? 69 | .query_and_then([], |row| 70 | Ok(Snapshot { 71 | id: row.get("hash")?, 72 | time: timestamp_to_datetime(row.get("time")?)?, 73 | parent: row.get("parent")?, 74 | tree: row.get("tree")?, 75 | paths: serde_json::from_str(row.get_ref("paths")?.as_str()?)?, 76 | hostname: row.get("hostname")?, 77 | username: row.get("username")?, 78 | uid: row.get("uid")?, 79 | gid: row.get("gid")?, 80 | excludes: serde_json::from_str(row.get_ref("excludes")?.as_str()?)?, 81 | tags: serde_json::from_str(row.get_ref("tags")?.as_str()?)?, 82 | original_id: row.get("original_id")?, 83 | program_version: row.get("program_version")?, 84 | }) 85 | )? 86 | .collect() 87 | } 88 | 89 | pub fn get_parent_id( 90 | &self, 91 | path_id: PathId, 92 | ) -> Result>, rusqlite::Error> { 93 | self.conn 94 | .query_row( 95 | "SELECT parent_id FROM paths WHERE id = ?", 96 | [path_id.0], 97 | |row| row.get("parent_id").map(raw_u64_to_o_path_id), 98 | ) 99 | .optional() 100 | } 101 | 102 | /// This is not very efficient, it does one query per path component. 103 | /// Mainly used for testing convenience. 104 | #[cfg(any(test, feature = "bench"))] 105 | pub fn get_path_id_by_path( 106 | &self, 107 | path: &Utf8Path, 108 | ) -> Result, rusqlite::Error> { 109 | let mut path_id = None; 110 | for component in path { 111 | path_id = self 112 | .conn 113 | .query_row( 114 | "SELECT id FROM paths \ 115 | WHERE parent_id = ? AND component = ?", 116 | params![o_path_id_to_raw_u64(path_id), component], 117 | |row| row.get(0).map(PathId), 118 | ) 119 | .optional()?; 120 | if path_id.is_none() { 121 | return Ok(None); 122 | } 123 | } 124 | Ok(path_id) 125 | } 126 | 127 | fn entries_tables( 128 | &self, 129 | ) -> Result, rusqlite::Error> { 130 | Ok(get_tables(&self.conn)? 131 | .into_iter() 132 | .filter(|name| name.starts_with("entries_"))) 133 | } 134 | 135 | /// This returns the children files/directories of the given path. 136 | /// Each entry's size is the largest size of that file/directory across 137 | /// all snapshots. 138 | pub fn get_entries( 139 | &self, 140 | path_id: Option, 141 | ) -> Result, rusqlite::Error> { 142 | let raw_path_id = o_path_id_to_raw_u64(path_id); 143 | let mut entries: Vec = Vec::new(); 144 | let mut index: HashMap = HashMap::new(); 145 | for table in self.entries_tables()? { 146 | let stmt_str = format!( 147 | "SELECT \ 148 | path_id, \ 149 | component, \ 150 | size, \ 151 | is_dir \ 152 | FROM \"{table}\" JOIN paths ON path_id = paths.id \ 153 | WHERE parent_id = {raw_path_id}\n", 154 | ); 155 | let mut stmt = self.conn.prepare(&stmt_str)?; 156 | let rows = stmt.query_map([], |row| { 157 | Ok(Entry { 158 | path_id: PathId(row.get("path_id")?), 159 | component: row.get("component")?, 160 | size: row.get("size")?, 161 | is_dir: row.get("is_dir")?, 162 | }) 163 | })?; 164 | for row in rows { 165 | let row = row?; 166 | let path_id = row.path_id; 167 | match index.get(&path_id) { 168 | None => { 169 | entries.push(row); 170 | index.insert(path_id, entries.len() - 1); 171 | } 172 | Some(i) => { 173 | let entry = &mut entries[*i]; 174 | entry.size = max(entry.size, row.size); 175 | entry.is_dir = entry.is_dir || row.is_dir; 176 | } 177 | } 178 | } 179 | } 180 | entries.sort_by_key(|e| Reverse(e.size)); 181 | Ok(entries) 182 | } 183 | 184 | pub fn get_entry_details( 185 | &self, 186 | path_id: PathId, 187 | ) -> Result, Error> { 188 | let raw_path_id = path_id.0; 189 | let run_query = |table: &str| -> Result< 190 | Option<(String, usize, DateTime)>, 191 | Error, 192 | > { 193 | let snapshot_hash = table.strip_prefix("entries_").unwrap(); 194 | let stmt_str = format!( 195 | "SELECT \ 196 | hash, \ 197 | size, \ 198 | time \ 199 | FROM \"{table}\" \ 200 | JOIN paths ON path_id = paths.id \ 201 | JOIN snapshots ON hash = '{snapshot_hash}' \ 202 | WHERE path_id = {raw_path_id}\n" 203 | ); 204 | let mut stmt = self.conn.prepare(&stmt_str)?; 205 | stmt.query_row([], |row| { 206 | Ok((row.get("hash")?, row.get("size")?, row.get("time")?)) 207 | }) 208 | .optional()? 209 | .map(|(hash, size, timestamp)| { 210 | Ok((hash, size, timestamp_to_datetime(timestamp)?)) 211 | }) 212 | .transpose() 213 | }; 214 | 215 | let mut entries_tables = self.entries_tables()?; 216 | let mut details = loop { 217 | match entries_tables.next() { 218 | None => return Ok(None), 219 | Some(table) => { 220 | if let Some((hash, size, time)) = run_query(&table)? { 221 | break EntryDetails { 222 | max_size: size, 223 | max_size_snapshot_hash: hash.clone(), 224 | first_seen: time, 225 | first_seen_snapshot_hash: hash.clone(), 226 | last_seen: time, 227 | last_seen_snapshot_hash: hash, 228 | }; 229 | } 230 | } 231 | } 232 | }; 233 | let mut max_size_time = details.first_seen; // Time of the max_size snapshot 234 | for table in entries_tables { 235 | if let Some((hash, size, time)) = run_query(&table)? { 236 | if size > details.max_size 237 | || (size == details.max_size && time > max_size_time) 238 | { 239 | details.max_size = size; 240 | details.max_size_snapshot_hash = hash.clone(); 241 | max_size_time = time; 242 | } 243 | if time < details.first_seen { 244 | details.first_seen = time; 245 | details.first_seen_snapshot_hash = hash.clone(); 246 | } 247 | if time > details.last_seen { 248 | details.last_seen = time; 249 | details.last_seen_snapshot_hash = hash; 250 | } 251 | } 252 | } 253 | Ok(Some(details)) 254 | } 255 | 256 | pub fn save_snapshot( 257 | &mut self, 258 | snapshot: &Snapshot, 259 | tree: SizeTree, 260 | ) -> Result { 261 | let mut file_count = 0; 262 | let tx = self.conn.transaction()?; 263 | { 264 | tx.execute( 265 | "INSERT INTO snapshots ( \ 266 | hash, \ 267 | time, \ 268 | parent, \ 269 | tree, \ 270 | hostname, \ 271 | username, \ 272 | uid, \ 273 | gid, \ 274 | original_id, \ 275 | program_version \ 276 | ) \ 277 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 278 | params![ 279 | snapshot.id, 280 | datetime_to_timestamp(snapshot.time), 281 | snapshot.parent, 282 | snapshot.tree, 283 | snapshot.hostname, 284 | snapshot.username, 285 | snapshot.uid, 286 | snapshot.gid, 287 | snapshot.original_id, 288 | snapshot.program_version 289 | ], 290 | )?; 291 | let mut snapshot_paths_stmt = tx.prepare( 292 | "INSERT INTO snapshot_paths (hash, path) VALUES (?, ?)", 293 | )?; 294 | for path in snapshot.paths.iter() { 295 | snapshot_paths_stmt.execute([&snapshot.id, path])?; 296 | } 297 | let mut snapshot_excludes_stmt = tx.prepare( 298 | "INSERT INTO snapshot_excludes (hash, path) VALUES (?, ?)", 299 | )?; 300 | for path in snapshot.excludes.iter() { 301 | snapshot_excludes_stmt.execute([&snapshot.id, path])?; 302 | } 303 | let mut snapshot_tags_stmt = tx.prepare( 304 | "INSERT INTO snapshot_tags (hash, tag) VALUES (?, ?)", 305 | )?; 306 | for path in snapshot.tags.iter() { 307 | snapshot_tags_stmt.execute([&snapshot.id, path])?; 308 | } 309 | } 310 | { 311 | let entries_table = format!("entries_{}", &snapshot.id); 312 | tx.execute( 313 | &format!( 314 | "CREATE TABLE \"{entries_table}\" ( 315 | path_id INTEGER PRIMARY KEY, 316 | size INTEGER NOT NULL, 317 | is_dir INTEGER NOT NULL, 318 | FOREIGN KEY (path_id) REFERENCES paths (id) 319 | )" 320 | ), 321 | [], 322 | )?; 323 | let mut entries_stmt = tx.prepare(&format!( 324 | "INSERT INTO \"{entries_table}\" (path_id, size, is_dir) \ 325 | VALUES (?, ?, ?)", 326 | ))?; 327 | 328 | let mut paths_stmt = tx.prepare( 329 | "INSERT INTO paths (parent_id, component) 330 | VALUES (?, ?) 331 | ON CONFLICT (parent_id, component) DO NOTHING", 332 | )?; 333 | let mut paths_query = tx.prepare( 334 | "SELECT id FROM paths WHERE parent_id = ? AND component = ?", 335 | )?; 336 | 337 | tree.0.traverse_with_context( 338 | |id_stack, component, size, is_dir| { 339 | let parent_id = id_stack.last().copied(); 340 | paths_stmt.execute(params![ 341 | o_path_id_to_raw_u64(parent_id), 342 | component, 343 | ])?; 344 | let path_id = paths_query.query_row( 345 | params![o_path_id_to_raw_u64(parent_id), component], 346 | |row| row.get(0).map(PathId), 347 | )?; 348 | entries_stmt.execute(params![path_id.0, size, is_dir])?; 349 | file_count += 1; 350 | Ok::(path_id) 351 | }, 352 | )?; 353 | } 354 | tx.commit()?; 355 | Ok(file_count) 356 | } 357 | 358 | pub fn delete_snapshot( 359 | &mut self, 360 | hash: impl AsRef, 361 | ) -> Result<(), rusqlite::Error> { 362 | let hash = hash.as_ref(); 363 | let tx = self.conn.transaction()?; 364 | tx.execute("DELETE FROM snapshots WHERE hash = ?", [hash])?; 365 | tx.execute("DELETE FROM snapshot_paths WHERE hash = ?", [hash])?; 366 | tx.execute("DELETE FROM snapshot_excludes WHERE hash = ?", [hash])?; 367 | tx.execute("DELETE FROM snapshot_tags WHERE hash = ?", [hash])?; 368 | tx.execute(&format!("DROP TABLE IF EXISTS \"entries_{}\"", hash), [])?; 369 | tx.commit() 370 | } 371 | 372 | // Marks //////////////////////////////////////////////// 373 | pub fn get_marks(&self) -> Result, rusqlite::Error> { 374 | let mut stmt = self.conn.prepare("SELECT path FROM marks")?; 375 | #[allow(clippy::let_and_return)] 376 | let result = stmt 377 | .query_map([], |row| Ok(row.get::<&str, String>("path")?.into()))? 378 | .collect(); 379 | result 380 | } 381 | 382 | pub fn upsert_mark( 383 | &mut self, 384 | path: &Utf8Path, 385 | ) -> Result { 386 | self.conn.execute( 387 | "INSERT INTO marks (path) VALUES (?) \ 388 | ON CONFLICT (path) DO NOTHING", 389 | [path.as_str()], 390 | ) 391 | } 392 | 393 | pub fn delete_mark( 394 | &mut self, 395 | path: &Utf8Path, 396 | ) -> Result { 397 | self.conn.execute("DELETE FROM marks WHERE path = ?", [path.as_str()]) 398 | } 399 | 400 | pub fn delete_all_marks(&mut self) -> Result { 401 | self.conn.execute("DELETE FROM marks", []) 402 | } 403 | } 404 | 405 | // A PathId should never be 0. 406 | // This is reserved for the absolute root and should match None 407 | #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] 408 | #[repr(transparent)] 409 | pub struct PathId(u64); 410 | 411 | fn raw_u64_to_o_path_id(id: u64) -> Option { 412 | if id == 0 { 413 | None 414 | } else { 415 | Some(PathId(id)) 416 | } 417 | } 418 | 419 | fn o_path_id_to_raw_u64(path_id: Option) -> u64 { 420 | path_id.map(|path_id| path_id.0).unwrap_or(0) 421 | } 422 | 423 | #[derive(Clone, Debug, Eq, PartialEq)] 424 | pub struct Entry { 425 | pub path_id: PathId, 426 | pub component: String, 427 | pub size: usize, 428 | pub is_dir: bool, 429 | } 430 | 431 | #[derive(Clone, Debug, Eq, PartialEq)] 432 | pub struct EntryDetails { 433 | pub max_size: usize, 434 | pub max_size_snapshot_hash: String, 435 | pub first_seen: DateTime, 436 | pub first_seen_snapshot_hash: String, 437 | pub last_seen: DateTime, 438 | pub last_seen_snapshot_hash: String, 439 | } 440 | 441 | ////////// Migrations ////////////////////////////////////////////////////////// 442 | type VersionId = u64; 443 | 444 | struct Migration { 445 | old: Option, 446 | new: VersionId, 447 | resync_necessary: bool, 448 | migration_fun: fn(&mut Connection) -> Result<(), rusqlite::Error>, 449 | } 450 | 451 | const INTEGER_METADATA_TABLE: &str = "metadata_integer"; 452 | 453 | pub const LATEST_VERSION: VersionId = 1; 454 | 455 | const MIGRATIONS: [Migration; 3] = [ 456 | Migration { 457 | old: None, 458 | new: 0, 459 | resync_necessary: false, 460 | migration_fun: migrate_none_to_v0, 461 | }, 462 | Migration { 463 | old: None, 464 | new: 1, 465 | resync_necessary: false, 466 | migration_fun: migrate_none_to_v1, 467 | }, 468 | Migration { 469 | old: Some(0), 470 | new: 1, 471 | resync_necessary: true, 472 | migration_fun: migrate_v0_to_v1, 473 | }, 474 | ]; 475 | 476 | #[derive(Debug, Error)] 477 | pub enum MigrationError { 478 | #[error("Invalid state, unable to determine version")] 479 | UnableToDetermineVersion, 480 | #[error("Do not know how to migrate from the current version")] 481 | NoMigrationPath { old: Option, new: VersionId }, 482 | #[error("Sqlite error")] 483 | Sql(#[from] rusqlite::Error), 484 | } 485 | 486 | pub struct Migrator<'a> { 487 | conn: Connection, 488 | migration: Option<&'a Migration>, 489 | } 490 | 491 | impl<'a> Migrator<'a> { 492 | pub fn open(file: &Path) -> Result { 493 | Self::open_(file, LATEST_VERSION) 494 | } 495 | 496 | #[cfg(any(test, feature = "bench"))] 497 | pub fn open_with_target( 498 | file: &Path, 499 | target: VersionId, 500 | ) -> Result { 501 | Self::open_(file, target) 502 | } 503 | 504 | // We don't try to find multi step migrations. 505 | fn open_(file: &Path, target: VersionId) -> Result { 506 | let conn = Connection::open(file)?; 507 | conn.pragma_update(None, "journal_mode", "WAL")?; 508 | conn.pragma_update(None, "synchronous", "NORMAL")?; 509 | // This is only used in V0 510 | conn.create_scalar_function( 511 | "path_parent", 512 | 1, 513 | FunctionFlags::SQLITE_UTF8 514 | | FunctionFlags::SQLITE_DETERMINISTIC 515 | | FunctionFlags::SQLITE_INNOCUOUS, 516 | |ctx| { 517 | let path = Utf8Path::new(ctx.get_raw(0).as_str()?); 518 | let parent = path.parent().map(ToOwned::to_owned); 519 | Ok(parent.and_then(|p| { 520 | let s = p.to_string(); 521 | if s.is_empty() { 522 | None 523 | } else { 524 | Some(s) 525 | } 526 | })) 527 | }, 528 | )?; 529 | conn.trace_v2( 530 | TraceEventCodes::SQLITE_TRACE_PROFILE, 531 | Some(|e| { 532 | if let TraceEvent::Profile(stmt, duration) = e { 533 | trace!("SQL {} (took {:#?})", stmt.sql(), duration); 534 | } 535 | }), 536 | ); 537 | let current = determine_version(&conn)?; 538 | if current == Some(target) { 539 | return Ok(Migrator { conn, migration: None }); 540 | } 541 | if let Some(migration) = 542 | MIGRATIONS.iter().find(|m| m.old == current && m.new == target) 543 | { 544 | Ok(Migrator { conn, migration: Some(migration) }) 545 | } else { 546 | Err(MigrationError::NoMigrationPath { old: current, new: target }) 547 | } 548 | } 549 | 550 | pub fn migrate(mut self) -> Result { 551 | if let Some(migration) = self.migration { 552 | (migration.migration_fun)(&mut self.conn)?; 553 | } 554 | Ok(Cache { conn: self.conn }) 555 | } 556 | 557 | pub fn need_to_migrate(&self) -> Option<(Option, VersionId)> { 558 | self.migration.map(|m| (m.old, m.new)) 559 | } 560 | 561 | pub fn resync_necessary(&self) -> bool { 562 | self.migration.map(|m| m.resync_necessary).unwrap_or(false) 563 | } 564 | } 565 | 566 | fn migrate_none_to_v0(conn: &mut Connection) -> Result<(), rusqlite::Error> { 567 | let tx = conn.transaction()?; 568 | tx.execute_batch(include_str!("cache/sql/none_to_v0.sql"))?; 569 | tx.commit() 570 | } 571 | 572 | fn migrate_none_to_v1(conn: &mut Connection) -> Result<(), rusqlite::Error> { 573 | let tx = conn.transaction()?; 574 | tx.execute_batch(include_str!("cache/sql/none_to_v1.sql"))?; 575 | tx.commit() 576 | } 577 | 578 | fn migrate_v0_to_v1(conn: &mut Connection) -> Result<(), rusqlite::Error> { 579 | let tx = conn.transaction()?; 580 | tx.execute_batch(include_str!("cache/sql/v0_to_v1.sql"))?; 581 | tx.commit() 582 | } 583 | 584 | fn determine_version( 585 | conn: &Connection, 586 | ) -> Result, MigrationError> { 587 | const V0_TABLES: [&str; 4] = ["snapshots", "files", "directories", "marks"]; 588 | 589 | let tables = get_tables(conn)?; 590 | if tables.contains(INTEGER_METADATA_TABLE) { 591 | conn.query_row( 592 | &format!( 593 | "SELECT value FROM {INTEGER_METADATA_TABLE} 594 | WHERE key = 'version'" 595 | ), 596 | [], 597 | |row| row.get::(0), 598 | ) 599 | .optional()? 600 | .map(|v| Ok(Some(v))) 601 | .unwrap_or(Err(MigrationError::UnableToDetermineVersion)) 602 | } else if V0_TABLES.iter().all(|t| tables.contains(*t)) { 603 | // The V0 tables are present but without a metadata table 604 | // Assume V0 (pre-versioning schema). 605 | Ok(Some(0)) 606 | } else { 607 | // No metadata table and no V0 tables, assume a fresh db. 608 | Ok(None) 609 | } 610 | } 611 | 612 | fn get_tables(conn: &Connection) -> Result, rusqlite::Error> { 613 | let mut stmt = 614 | conn.prepare("SELECT name FROM sqlite_master WHERE type='table'")?; 615 | let names = stmt.query_map([], |row| row.get(0))?; 616 | names.collect() 617 | } 618 | 619 | ////////// Misc //////////////////////////////////////////////////////////////// 620 | fn timestamp_to_datetime(timestamp: i64) -> Result, Error> { 621 | DateTime::from_timestamp_micros(timestamp) 622 | .map(Ok) 623 | .unwrap_or(Err(Error::ExhaustedTimestampPrecision)) 624 | } 625 | 626 | fn datetime_to_timestamp(datetime: DateTime) -> i64 { 627 | datetime.timestamp_micros() 628 | } 629 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | cmp::{max, min}, 4 | collections::HashSet, 5 | iter, 6 | }; 7 | 8 | use camino::Utf8PathBuf; 9 | use ratatui::{ 10 | buffer::Buffer, 11 | layout::{Constraint, Direction, Layout, Position, Rect, Size}, 12 | prelude::Line, 13 | style::{Style, Stylize}, 14 | text::Span, 15 | widgets::{ 16 | Block, BorderType, Clear, Padding, Paragraph, Row, Table, Widget, 17 | WidgetRef, Wrap, 18 | }, 19 | }; 20 | use redu::cache::EntryDetails; 21 | use unicode_segmentation::UnicodeSegmentation; 22 | 23 | use crate::{ 24 | cache::{Entry, PathId}, 25 | util::snapshot_short_id, 26 | }; 27 | 28 | #[derive(Clone, Debug)] 29 | pub enum Event { 30 | Resize(Size), 31 | Left, 32 | Right, 33 | Up, 34 | Down, 35 | PageUp, 36 | PageDown, 37 | Enter, 38 | Exit, 39 | Mark, 40 | Unmark, 41 | UnmarkAll, 42 | Quit, 43 | Generate, 44 | Entries { 45 | /// `entries` is expected to be sorted by size, largest first. 46 | path_id: Option, 47 | entries: Vec, 48 | }, 49 | EntryDetails(EntryDetails), 50 | Marks(Vec), 51 | } 52 | 53 | #[derive(Debug)] 54 | pub enum Action { 55 | Nothing, 56 | Render, 57 | Quit, 58 | Generate(Vec), 59 | GetParentEntries(PathId), 60 | GetEntries(Option), 61 | GetEntryDetails(PathId), 62 | UpsertMark(Utf8PathBuf), 63 | DeleteMark(Utf8PathBuf), 64 | DeleteAllMarks, 65 | } 66 | 67 | pub struct App { 68 | path_id: Option, 69 | path: Utf8PathBuf, 70 | entries: Vec, 71 | marks: HashSet, 72 | list_size: Size, 73 | selected: usize, 74 | offset: usize, 75 | footer_extra: Vec>, 76 | details_drawer: Option, 77 | confirm_dialog: Option, 78 | } 79 | 80 | impl App { 81 | /// `entries` is expected to be sorted by size, largest first. 82 | pub fn new( 83 | screen: Size, 84 | path_id: Option, 85 | path: Utf8PathBuf, 86 | entries: Vec, 87 | marks: Vec, 88 | footer_extra: Vec>, 89 | ) -> Self { 90 | let list_size = compute_list_size(screen); 91 | App { 92 | path_id, 93 | path, 94 | entries, 95 | marks: HashSet::from_iter(marks), 96 | list_size, 97 | selected: 0, 98 | offset: 0, 99 | footer_extra, 100 | details_drawer: None, 101 | confirm_dialog: None, 102 | } 103 | } 104 | 105 | pub fn update(&mut self, event: Event) -> Action { 106 | log::debug!("received {:?}", event); 107 | use Event::*; 108 | match event { 109 | Resize(new_size) => self.resize(new_size), 110 | Left => { 111 | if let Some(ref mut confirm_dialog) = self.confirm_dialog { 112 | confirm_dialog.yes_selected = false; 113 | Action::Render 114 | } else { 115 | self.left() 116 | } 117 | } 118 | Right => { 119 | if let Some(ref mut confirm_dialog) = self.confirm_dialog { 120 | confirm_dialog.yes_selected = true; 121 | Action::Render 122 | } else { 123 | self.right() 124 | } 125 | } 126 | Up => self.move_selection(-1, true), 127 | Down => self.move_selection(1, true), 128 | PageUp => { 129 | self.move_selection(-(self.list_size.height as isize), false) 130 | } 131 | PageDown => { 132 | self.move_selection(self.list_size.height as isize, false) 133 | } 134 | Enter => { 135 | if let Some(confirm_dialog) = self.confirm_dialog.take() { 136 | if confirm_dialog.yes_selected { 137 | confirm_dialog.action 138 | } else { 139 | Action::Render 140 | } 141 | } else if self.confirm_dialog.is_none() { 142 | Action::GetEntryDetails(self.entries[self.selected].path_id) 143 | } else { 144 | Action::Nothing 145 | } 146 | } 147 | Exit => { 148 | if self.confirm_dialog.take().is_some() 149 | || self.details_drawer.take().is_some() 150 | { 151 | Action::Render 152 | } else { 153 | Action::Nothing 154 | } 155 | } 156 | Mark => self.mark_selection(), 157 | Unmark => self.unmark_selection(), 158 | UnmarkAll => { 159 | if self.confirm_dialog.is_none() { 160 | self.confirm_dialog = Some(ConfirmDialog { 161 | text: "Are you sure you want to delete all marks?" 162 | .into(), 163 | yes: "Yes".into(), 164 | no: "No".into(), 165 | yes_selected: false, 166 | action: Action::DeleteAllMarks, 167 | }); 168 | Action::Render 169 | } else { 170 | Action::Nothing 171 | } 172 | } 173 | Quit => Action::Quit, 174 | Generate => self.generate(), 175 | Entries { path_id, entries } => self.set_entries(path_id, entries), 176 | EntryDetails(details) => { 177 | self.details_drawer = Some(DetailsDrawer { details }); 178 | Action::Render 179 | } 180 | Marks(new_marks) => self.set_marks(new_marks), 181 | } 182 | } 183 | 184 | fn resize(&mut self, new_size: Size) -> Action { 185 | self.list_size = compute_list_size(new_size); 186 | self.fix_offset(); 187 | Action::Render 188 | } 189 | 190 | fn left(&mut self) -> Action { 191 | if let Some(path_id) = self.path_id { 192 | Action::GetParentEntries(path_id) 193 | } else { 194 | Action::Nothing 195 | } 196 | } 197 | 198 | fn right(&mut self) -> Action { 199 | if !self.entries.is_empty() { 200 | let entry = &self.entries[self.selected]; 201 | if entry.is_dir { 202 | return Action::GetEntries(Some(entry.path_id)); 203 | } 204 | } 205 | Action::Nothing 206 | } 207 | 208 | fn move_selection(&mut self, delta: isize, wrap: bool) -> Action { 209 | if self.entries.is_empty() { 210 | return Action::Nothing; 211 | } 212 | 213 | let selected = self.selected as isize; 214 | let len = self.entries.len() as isize; 215 | self.selected = if wrap { 216 | (selected + delta).rem_euclid(len) 217 | } else { 218 | max(0, min(len - 1, selected + delta)) 219 | } as usize; 220 | self.fix_offset(); 221 | 222 | if self.details_drawer.is_some() { 223 | Action::GetEntryDetails(self.entries[self.selected].path_id) 224 | } else { 225 | Action::Render 226 | } 227 | } 228 | 229 | fn mark_selection(&mut self) -> Action { 230 | self.selected_entry().map(Action::UpsertMark).unwrap_or(Action::Nothing) 231 | } 232 | 233 | fn unmark_selection(&mut self) -> Action { 234 | self.selected_entry().map(Action::DeleteMark).unwrap_or(Action::Nothing) 235 | } 236 | 237 | fn generate(&self) -> Action { 238 | let mut lines = self.marks.iter().map(Clone::clone).collect::>(); 239 | lines.sort_unstable(); 240 | Action::Generate(lines) 241 | } 242 | 243 | fn set_entries( 244 | &mut self, 245 | path_id: Option, 246 | entries: Vec, 247 | ) -> Action { 248 | // See if any of the new entries matches the current directory 249 | // and pre-select it. This means that we went up to the parent dir. 250 | self.selected = entries 251 | .iter() 252 | .enumerate() 253 | .find(|(_, e)| Some(e.path_id) == self.path_id) 254 | .map(|(i, _)| i) 255 | .unwrap_or(0); 256 | self.offset = 0; 257 | self.path_id = path_id; 258 | { 259 | // Check if the new path_id matches any of the old entries. 260 | // If we find one this means that we are going down into that entry. 261 | if let Some(e) = 262 | self.entries.iter().find(|e| Some(e.path_id) == path_id) 263 | { 264 | self.path.push(&e.component); 265 | } else { 266 | self.path.pop(); 267 | } 268 | } 269 | self.entries = entries; 270 | self.fix_offset(); 271 | 272 | if self.details_drawer.is_some() { 273 | Action::GetEntryDetails(self.entries[self.selected].path_id) 274 | } else { 275 | Action::Render 276 | } 277 | } 278 | 279 | fn set_marks(&mut self, new_marks: Vec) -> Action { 280 | self.marks = HashSet::from_iter(new_marks); 281 | Action::Render 282 | } 283 | 284 | /// Adjust offset to make sure the selected item is visible. 285 | fn fix_offset(&mut self) { 286 | let offset = self.offset as isize; 287 | let selected = self.selected as isize; 288 | let h = self.list_size.height as isize; 289 | let first_visible = offset; 290 | let last_visible = offset + h - 1; 291 | let new_offset = if selected < first_visible { 292 | selected 293 | } else if last_visible < selected { 294 | selected - h + 1 295 | } else { 296 | offset 297 | }; 298 | self.offset = new_offset as usize; 299 | } 300 | 301 | fn selected_entry(&self) -> Option { 302 | if self.entries.is_empty() { 303 | return None; 304 | } 305 | Some(self.full_path(&self.entries[self.selected])) 306 | } 307 | 308 | fn full_path(&self, entry: &Entry) -> Utf8PathBuf { 309 | let mut full_loc = self.path.clone(); 310 | full_loc.push(&entry.component); 311 | full_loc 312 | } 313 | } 314 | 315 | fn compute_list_size(area: Size) -> Size { 316 | let (_, list, _) = compute_layout((Position::new(0, 0), area).into()); 317 | list.as_size() 318 | } 319 | 320 | fn compute_layout(area: Rect) -> (Rect, Rect, Rect) { 321 | let layout = Layout::default() 322 | .direction(Direction::Vertical) 323 | .constraints([ 324 | Constraint::Length(1), 325 | Constraint::Fill(100), 326 | Constraint::Length(1), 327 | ]) 328 | .split(area); 329 | (layout[0], layout[1], layout[2]) 330 | } 331 | 332 | impl WidgetRef for App { 333 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 334 | let (header_area, table_area, footer_area) = compute_layout(area); 335 | { 336 | // Header 337 | let mut string = "--- ".to_string(); 338 | string.push_str( 339 | shorten_to( 340 | if self.path.as_str().is_empty() { 341 | "#" 342 | } else { 343 | self.path.as_str() 344 | }, 345 | max(0, header_area.width as isize - string.len() as isize) 346 | as usize, 347 | ) 348 | .as_ref(), 349 | ); 350 | let mut remaining_width = max( 351 | 0, 352 | header_area.width as isize 353 | - string.graphemes(true).count() as isize, 354 | ) as usize; 355 | if remaining_width > 0 { 356 | string.push(' '); 357 | remaining_width -= 1; 358 | } 359 | string.push_str(&"-".repeat(remaining_width)); 360 | Paragraph::new(string).on_light_blue().render_ref(header_area, buf); 361 | } 362 | 363 | { 364 | // Table 365 | const MIN_WIDTH_SHOW_SIZEBAR: u16 = 50; 366 | let show_sizebar = table_area.width >= MIN_WIDTH_SHOW_SIZEBAR; 367 | let mut rows: Vec = Vec::with_capacity(self.entries.len()); 368 | let mut entries = self.entries.iter(); 369 | if let Some(first) = entries.next() { 370 | let largest_size = first.size as f64; 371 | for (index, entry) in iter::once(first) 372 | .chain(entries) 373 | .enumerate() 374 | .skip(self.offset) 375 | { 376 | let selected = index == self.selected; 377 | let mut spans = Vec::with_capacity(4); 378 | spans.push(render_mark( 379 | self.marks.contains(&self.full_path(entry)), 380 | )); 381 | spans.push(render_size(entry.size)); 382 | if show_sizebar { 383 | spans.push(render_sizebar( 384 | entry.size as f64 / largest_size, 385 | )); 386 | } 387 | let used_width: usize = spans 388 | .iter() 389 | .map(|s| grapheme_len(&s.content)) 390 | .sum::() 391 | + spans.len(); // separators 392 | let available_width = 393 | max(0, table_area.width as isize - used_width as isize) 394 | as usize; 395 | spans.push(render_name( 396 | &entry.component, 397 | entry.is_dir, 398 | selected, 399 | available_width, 400 | )); 401 | rows.push(Row::new(spans).style(if selected { 402 | Style::new().black().on_white() 403 | } else { 404 | Style::new() 405 | })); 406 | } 407 | } 408 | let mut constraints = Vec::with_capacity(4); 409 | constraints.push(Constraint::Min(MARK_LEN)); 410 | constraints.push(Constraint::Min(SIZE_LEN)); 411 | if show_sizebar { 412 | constraints.push(Constraint::Min(SIZEBAR_LEN)); 413 | } 414 | constraints.push(Constraint::Percentage(100)); 415 | Table::new(rows, constraints).render_ref(table_area, buf) 416 | } 417 | 418 | { 419 | // Footer 420 | let spans = vec![ 421 | Span::from(format!(" Marks: {}", self.marks.len())), 422 | Span::from(" | "), 423 | ] 424 | .into_iter() 425 | .chain(self.footer_extra.clone()) 426 | .collect::>(); 427 | Paragraph::new(Line::from(spans)) 428 | .on_light_blue() 429 | .render_ref(footer_area, buf); 430 | } 431 | 432 | if let Some(details_dialog) = &self.details_drawer { 433 | details_dialog.render_ref(table_area, buf); 434 | } 435 | 436 | if let Some(confirm_dialog) = &self.confirm_dialog { 437 | confirm_dialog.render_ref(area, buf); 438 | } 439 | } 440 | } 441 | 442 | const MARK_LEN: u16 = 1; 443 | 444 | fn render_mark(is_marked: bool) -> Span<'static> { 445 | Span::raw(if is_marked { "*" } else { " " }) 446 | } 447 | 448 | const SIZE_LEN: u16 = 11; 449 | 450 | fn render_size(size: usize) -> Span<'static> { 451 | Span::raw(format!( 452 | "{:>11}", 453 | humansize::format_size(size, humansize::BINARY) 454 | )) 455 | } 456 | 457 | const SIZEBAR_LEN: u16 = 16; 458 | 459 | fn render_sizebar(relative_size: f64) -> Span<'static> { 460 | Span::raw({ 461 | let bar_frac_width = 462 | (relative_size * (SIZEBAR_LEN * 8) as f64) as usize; 463 | let full_blocks = bar_frac_width / 8; 464 | let last_block = match (bar_frac_width % 8) as u32 { 465 | 0 => String::new(), 466 | x => String::from(unsafe { char::from_u32_unchecked(0x2590 - x) }), 467 | }; 468 | let empty_width = 469 | SIZEBAR_LEN as usize - full_blocks - grapheme_len(&last_block); 470 | let mut bar = String::with_capacity(1 + SIZEBAR_LEN as usize + 1); 471 | for _ in 0..full_blocks { 472 | bar.push('\u{2588}'); 473 | } 474 | bar.push_str(&last_block); 475 | for _ in 0..empty_width { 476 | bar.push(' '); 477 | } 478 | bar 479 | }) 480 | .green() 481 | } 482 | 483 | fn render_name( 484 | name: &str, 485 | is_dir: bool, 486 | selected: bool, 487 | available_width: usize, 488 | ) -> Span<'_> { 489 | let mut escaped = escape_name(name); 490 | if is_dir { 491 | if !escaped.ends_with('/') { 492 | escaped.to_mut().push('/'); 493 | } 494 | let span = 495 | Span::raw(shorten_to(&escaped, available_width).into_owned()) 496 | .bold(); 497 | if selected { 498 | span.dark_gray() 499 | } else { 500 | span.blue() 501 | } 502 | } else { 503 | Span::raw(shorten_to(&escaped, available_width).into_owned()) 504 | } 505 | } 506 | 507 | fn escape_name(name: &str) -> Cow<'_, str> { 508 | match name.find(char::is_control) { 509 | None => Cow::Borrowed(name), 510 | Some(index) => { 511 | let (left, right) = name.split_at(index); 512 | let mut escaped = String::with_capacity(name.len() + 1); // the +1 is for the extra \ 513 | escaped.push_str(left); 514 | for c in right.chars() { 515 | if c.is_control() { 516 | for d in c.escape_default() { 517 | escaped.push(d); 518 | } 519 | } else { 520 | escaped.push(c) 521 | } 522 | } 523 | Cow::Owned(escaped) 524 | } 525 | } 526 | } 527 | 528 | fn shorten_to(s: &str, width: usize) -> Cow<'_, str> { 529 | let len = s.graphemes(true).count(); 530 | let res = if len <= width { 531 | Cow::Borrowed(s) 532 | } else if width <= 3 { 533 | Cow::Owned(".".repeat(width)) 534 | } else { 535 | let front_width = (width - 3).div_euclid(2); 536 | let back_width = width - front_width - 3; 537 | let graphemes = s.graphemes(true); 538 | let mut name = graphemes.clone().take(front_width).collect::(); 539 | name.push_str("..."); 540 | for g in graphemes.skip(len - back_width) { 541 | name.push_str(g); 542 | } 543 | Cow::Owned(name) 544 | }; 545 | res 546 | } 547 | 548 | /// DetailsDialog ////////////////////////////////////////////////////////////// 549 | struct DetailsDrawer { 550 | details: EntryDetails, 551 | } 552 | 553 | impl WidgetRef for DetailsDrawer { 554 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 555 | let details = &self.details; 556 | let text = format!( 557 | "max size: {} ({})\n\ 558 | first seen: {} ({})\n\ 559 | last seen: {} ({})\n", 560 | humansize::format_size(details.max_size, humansize::BINARY), 561 | snapshot_short_id(&details.max_size_snapshot_hash), 562 | details.first_seen.date_naive(), 563 | snapshot_short_id(&details.first_seen_snapshot_hash), 564 | details.last_seen.date_naive(), 565 | snapshot_short_id(&details.last_seen_snapshot_hash), 566 | ); 567 | let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); 568 | let padding = Padding { left: 2, right: 2, top: 0, bottom: 0 }; 569 | let horiz_padding = padding.left + padding.right; 570 | let inner_width = { 571 | let desired_inner_width = paragraph.line_width() as u16; 572 | let max_inner_width = area.width.saturating_sub(2 + horiz_padding); 573 | min(max_inner_width, desired_inner_width) 574 | }; 575 | let outer_width = inner_width + 2 + horiz_padding; 576 | let outer_height = { 577 | let vert_padding = padding.top + padding.bottom; 578 | let inner_height = paragraph.line_count(inner_width) as u16; 579 | inner_height + 2 + vert_padding 580 | }; 581 | let block_area = Rect { 582 | x: area.x + area.width - outer_width, 583 | y: area.y + area.height - outer_height, 584 | width: outer_width, 585 | height: outer_height, 586 | }; 587 | let block = Block::bordered().title("Details").padding(padding); 588 | let paragraph_area = block.inner(block_area); 589 | Clear.render(block_area, buf); 590 | block.render(block_area, buf); 591 | paragraph.render(paragraph_area, buf); 592 | } 593 | } 594 | 595 | /// ConfirmDialog ////////////////////////////////////////////////////////////// 596 | struct ConfirmDialog { 597 | text: String, 598 | yes: String, 599 | no: String, 600 | yes_selected: bool, 601 | action: Action, 602 | } 603 | 604 | impl WidgetRef for ConfirmDialog { 605 | fn render_ref(&self, area: Rect, buf: &mut Buffer) { 606 | let main_text = Paragraph::new(self.text.clone()) 607 | .centered() 608 | .wrap(Wrap { trim: false }); 609 | 610 | let padding = Padding { left: 2, right: 2, top: 1, bottom: 0 }; 611 | let width = min(80, grapheme_len(&self.text) as u16); 612 | let height = main_text.line_count(width) as u16 + 1 + 3; // text + empty line + buttons 613 | let dialog_area = dialog(padding, width, height, area); 614 | 615 | let block = Block::bordered().title("Confirm").padding(padding); 616 | 617 | let (main_text_area, buttons_area) = { 618 | let layout = Layout::default() 619 | .direction(Direction::Vertical) 620 | .constraints([Constraint::Fill(100), Constraint::Length(3)]) 621 | .split(block.inner(dialog_area)); 622 | (layout[0], layout[1]) 623 | }; 624 | let (no_button_area, yes_button_area) = { 625 | let layout = Layout::default() 626 | .direction(Direction::Horizontal) 627 | .constraints([ 628 | Constraint::Fill(1), 629 | Constraint::Min(self.no.graphemes(true).count() as u16), 630 | Constraint::Fill(1), 631 | Constraint::Min(self.yes.graphemes(true).count() as u16), 632 | Constraint::Fill(1), 633 | ]) 634 | .split(buttons_area); 635 | (layout[1], layout[3]) 636 | }; 637 | 638 | fn render_button( 639 | label: &str, 640 | selected: bool, 641 | area: Rect, 642 | buf: &mut Buffer, 643 | ) { 644 | let mut block = Block::bordered().border_type(BorderType::Plain); 645 | let mut button = 646 | Paragraph::new(label).centered().wrap(Wrap { trim: false }); 647 | if selected { 648 | block = block.border_type(BorderType::QuadrantInside); 649 | button = button.black().on_white(); 650 | } 651 | button.render(block.inner(area), buf); 652 | block.render(area, buf); 653 | } 654 | 655 | Clear.render(dialog_area, buf); 656 | block.render(dialog_area, buf); 657 | main_text.render(main_text_area, buf); 658 | render_button(&self.no, !self.yes_selected, no_button_area, buf); 659 | render_button(&self.yes, self.yes_selected, yes_button_area, buf); 660 | } 661 | } 662 | 663 | /// Misc ////////////////////////////////////////////////////////////////////// 664 | fn dialog( 665 | padding: Padding, 666 | max_inner_width: u16, 667 | max_inner_height: u16, 668 | area: Rect, 669 | ) -> Rect { 670 | let horiz_padding = padding.left + padding.right; 671 | let vert_padding = padding.top + padding.bottom; 672 | let max_width = max_inner_width + 2 + horiz_padding; // The extra 2 is the border 673 | let max_height = max_inner_height + 2 + vert_padding; 674 | centered(max_width, max_height, area) 675 | } 676 | 677 | /// Returns a `Rect` centered in `area` with a maximum width and height. 678 | fn centered(max_width: u16, max_height: u16, area: Rect) -> Rect { 679 | let width = min(max_width, area.width); 680 | let height = min(max_height, area.height); 681 | Rect { 682 | x: area.width / 2 - width / 2, 683 | y: area.height / 2 - height / 2, 684 | width, 685 | height, 686 | } 687 | } 688 | 689 | fn grapheme_len(s: &str) -> usize { 690 | s.graphemes(true).count() 691 | } 692 | 693 | /// Tests ////////////////////////////////////////////////////////////////////// 694 | #[cfg(test)] 695 | mod tests { 696 | use super::{shorten_to, *}; 697 | 698 | #[test] 699 | fn render_sizebar_test() { 700 | fn aux(size: f64, content: &str) { 701 | assert_eq!(render_sizebar(size).content, content); 702 | } 703 | 704 | aux(0.00, " "); 705 | aux(0.25, "████ "); 706 | aux(0.50, "████████ "); 707 | aux(0.75, "████████████ "); 708 | aux(0.90, "██████████████▍ "); 709 | aux(1.00, "████████████████"); 710 | aux(0.5 + (1.0 / (8.0 * 16.0)), "████████▏ "); 711 | aux(0.5 + (2.0 / (8.0 * 16.0)), "████████▎ "); 712 | aux(0.5 + (3.0 / (8.0 * 16.0)), "████████▍ "); 713 | aux(0.5 + (4.0 / (8.0 * 16.0)), "████████▌ "); 714 | aux(0.5 + (5.0 / (8.0 * 16.0)), "████████▋ "); 715 | aux(0.5 + (6.0 / (8.0 * 16.0)), "████████▊ "); 716 | aux(0.5 + (7.0 / (8.0 * 16.0)), "████████▉ "); 717 | } 718 | 719 | #[test] 720 | fn escape_name_test() { 721 | assert_eq!( 722 | escape_name("f\no\\tóà 学校\r"), 723 | Cow::Borrowed("f\\no\\tóà 学校\\r") 724 | ); 725 | } 726 | 727 | #[test] 728 | fn shorten_to_test() { 729 | let s = "123456789"; 730 | assert_eq!(shorten_to(s, 0), Cow::Owned::("".to_owned())); 731 | assert_eq!(shorten_to(s, 1), Cow::Owned::(".".to_owned())); 732 | assert_eq!(shorten_to(s, 2), Cow::Owned::("..".to_owned())); 733 | assert_eq!(shorten_to(s, 3), Cow::Owned::("...".to_owned())); 734 | assert_eq!(shorten_to(s, 4), Cow::Owned::("...9".to_owned())); 735 | assert_eq!(shorten_to(s, 5), Cow::Owned::("1...9".to_owned())); 736 | assert_eq!(shorten_to(s, 8), Cow::Owned::("12...789".to_owned())); 737 | assert_eq!(shorten_to(s, 9), Cow::Borrowed(s)); 738 | } 739 | } 740 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "allocator-api2" 16 | version = "0.2.21" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 19 | 20 | [[package]] 21 | name = "android-tzdata" 22 | version = "0.1.1" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 25 | 26 | [[package]] 27 | name = "android_system_properties" 28 | version = "0.1.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 31 | dependencies = [ 32 | "libc", 33 | ] 34 | 35 | [[package]] 36 | name = "anes" 37 | version = "0.1.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" 40 | 41 | [[package]] 42 | name = "anstream" 43 | version = "0.6.19" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" 46 | dependencies = [ 47 | "anstyle", 48 | "anstyle-parse", 49 | "anstyle-query", 50 | "anstyle-wincon", 51 | "colorchoice", 52 | "is_terminal_polyfill", 53 | "utf8parse", 54 | ] 55 | 56 | [[package]] 57 | name = "anstyle" 58 | version = "1.0.11" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 61 | 62 | [[package]] 63 | name = "anstyle-parse" 64 | version = "0.2.7" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 67 | dependencies = [ 68 | "utf8parse", 69 | ] 70 | 71 | [[package]] 72 | name = "anstyle-query" 73 | version = "1.1.3" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" 76 | dependencies = [ 77 | "windows-sys 0.59.0", 78 | ] 79 | 80 | [[package]] 81 | name = "anstyle-wincon" 82 | version = "3.0.9" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" 85 | dependencies = [ 86 | "anstyle", 87 | "once_cell_polyfill", 88 | "windows-sys 0.59.0", 89 | ] 90 | 91 | [[package]] 92 | name = "anyhow" 93 | version = "1.0.98" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 96 | 97 | [[package]] 98 | name = "autocfg" 99 | version = "1.4.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 102 | 103 | [[package]] 104 | name = "bitflags" 105 | version = "2.9.1" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 108 | 109 | [[package]] 110 | name = "bumpalo" 111 | version = "3.18.1" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" 114 | 115 | [[package]] 116 | name = "camino" 117 | version = "1.1.10" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" 120 | 121 | [[package]] 122 | name = "cassowary" 123 | version = "0.3.0" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" 126 | 127 | [[package]] 128 | name = "cast" 129 | version = "0.3.0" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" 132 | 133 | [[package]] 134 | name = "castaway" 135 | version = "0.2.3" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" 138 | dependencies = [ 139 | "rustversion", 140 | ] 141 | 142 | [[package]] 143 | name = "cc" 144 | version = "1.2.27" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" 147 | dependencies = [ 148 | "shlex", 149 | ] 150 | 151 | [[package]] 152 | name = "cfg-if" 153 | version = "1.0.1" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" 156 | 157 | [[package]] 158 | name = "cfg_aliases" 159 | version = "0.2.1" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 162 | 163 | [[package]] 164 | name = "chrono" 165 | version = "0.4.41" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 168 | dependencies = [ 169 | "android-tzdata", 170 | "iana-time-zone", 171 | "js-sys", 172 | "num-traits", 173 | "serde", 174 | "wasm-bindgen", 175 | "windows-link", 176 | ] 177 | 178 | [[package]] 179 | name = "ciborium" 180 | version = "0.2.2" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 183 | dependencies = [ 184 | "ciborium-io", 185 | "ciborium-ll", 186 | "serde", 187 | ] 188 | 189 | [[package]] 190 | name = "ciborium-io" 191 | version = "0.2.2" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 194 | 195 | [[package]] 196 | name = "ciborium-ll" 197 | version = "0.2.2" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 200 | dependencies = [ 201 | "ciborium-io", 202 | "half", 203 | ] 204 | 205 | [[package]] 206 | name = "clap" 207 | version = "4.5.40" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" 210 | dependencies = [ 211 | "clap_builder", 212 | "clap_derive", 213 | ] 214 | 215 | [[package]] 216 | name = "clap_builder" 217 | version = "4.5.40" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" 220 | dependencies = [ 221 | "anstream", 222 | "anstyle", 223 | "clap_lex", 224 | "strsim", 225 | ] 226 | 227 | [[package]] 228 | name = "clap_derive" 229 | version = "4.5.40" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" 232 | dependencies = [ 233 | "heck", 234 | "proc-macro2", 235 | "quote", 236 | "syn", 237 | ] 238 | 239 | [[package]] 240 | name = "clap_lex" 241 | version = "0.7.5" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 244 | 245 | [[package]] 246 | name = "colorchoice" 247 | version = "1.0.4" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 250 | 251 | [[package]] 252 | name = "compact_str" 253 | version = "0.8.1" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 256 | dependencies = [ 257 | "castaway", 258 | "cfg-if", 259 | "itoa", 260 | "rustversion", 261 | "ryu", 262 | "static_assertions", 263 | ] 264 | 265 | [[package]] 266 | name = "console" 267 | version = "0.15.11" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 270 | dependencies = [ 271 | "encode_unicode", 272 | "libc", 273 | "once_cell", 274 | "unicode-width 0.2.0", 275 | "windows-sys 0.59.0", 276 | ] 277 | 278 | [[package]] 279 | name = "convert_case" 280 | version = "0.7.1" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" 283 | dependencies = [ 284 | "unicode-segmentation", 285 | ] 286 | 287 | [[package]] 288 | name = "core-foundation-sys" 289 | version = "0.8.7" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 292 | 293 | [[package]] 294 | name = "criterion" 295 | version = "0.5.1" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" 298 | dependencies = [ 299 | "anes", 300 | "cast", 301 | "ciborium", 302 | "clap", 303 | "criterion-plot", 304 | "is-terminal", 305 | "itertools 0.10.5", 306 | "num-traits", 307 | "once_cell", 308 | "oorandom", 309 | "plotters", 310 | "rayon", 311 | "regex", 312 | "serde", 313 | "serde_derive", 314 | "serde_json", 315 | "tinytemplate", 316 | "walkdir", 317 | ] 318 | 319 | [[package]] 320 | name = "criterion-plot" 321 | version = "0.5.0" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" 324 | dependencies = [ 325 | "cast", 326 | "itertools 0.10.5", 327 | ] 328 | 329 | [[package]] 330 | name = "crossbeam-deque" 331 | version = "0.8.6" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 334 | dependencies = [ 335 | "crossbeam-epoch", 336 | "crossbeam-utils", 337 | ] 338 | 339 | [[package]] 340 | name = "crossbeam-epoch" 341 | version = "0.9.18" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 344 | dependencies = [ 345 | "crossbeam-utils", 346 | ] 347 | 348 | [[package]] 349 | name = "crossbeam-utils" 350 | version = "0.8.21" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 353 | 354 | [[package]] 355 | name = "crossterm" 356 | version = "0.28.1" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 359 | dependencies = [ 360 | "bitflags", 361 | "crossterm_winapi", 362 | "mio", 363 | "parking_lot", 364 | "rustix 0.38.44", 365 | "signal-hook", 366 | "signal-hook-mio", 367 | "winapi", 368 | ] 369 | 370 | [[package]] 371 | name = "crossterm" 372 | version = "0.29.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 375 | dependencies = [ 376 | "bitflags", 377 | "crossterm_winapi", 378 | "derive_more", 379 | "document-features", 380 | "mio", 381 | "parking_lot", 382 | "rustix 1.0.7", 383 | "signal-hook", 384 | "signal-hook-mio", 385 | "winapi", 386 | ] 387 | 388 | [[package]] 389 | name = "crossterm_winapi" 390 | version = "0.9.1" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 393 | dependencies = [ 394 | "winapi", 395 | ] 396 | 397 | [[package]] 398 | name = "crunchy" 399 | version = "0.2.3" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" 402 | 403 | [[package]] 404 | name = "darling" 405 | version = "0.20.11" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 408 | dependencies = [ 409 | "darling_core", 410 | "darling_macro", 411 | ] 412 | 413 | [[package]] 414 | name = "darling_core" 415 | version = "0.20.11" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 418 | dependencies = [ 419 | "fnv", 420 | "ident_case", 421 | "proc-macro2", 422 | "quote", 423 | "strsim", 424 | "syn", 425 | ] 426 | 427 | [[package]] 428 | name = "darling_macro" 429 | version = "0.20.11" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 432 | dependencies = [ 433 | "darling_core", 434 | "quote", 435 | "syn", 436 | ] 437 | 438 | [[package]] 439 | name = "deranged" 440 | version = "0.4.0" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" 443 | dependencies = [ 444 | "powerfmt", 445 | ] 446 | 447 | [[package]] 448 | name = "derive_more" 449 | version = "2.0.1" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 452 | dependencies = [ 453 | "derive_more-impl", 454 | ] 455 | 456 | [[package]] 457 | name = "derive_more-impl" 458 | version = "2.0.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 461 | dependencies = [ 462 | "convert_case", 463 | "proc-macro2", 464 | "quote", 465 | "syn", 466 | ] 467 | 468 | [[package]] 469 | name = "directories" 470 | version = "6.0.0" 471 | source = "registry+https://github.com/rust-lang/crates.io-index" 472 | checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" 473 | dependencies = [ 474 | "dirs-sys", 475 | ] 476 | 477 | [[package]] 478 | name = "dirs-sys" 479 | version = "0.5.0" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 482 | dependencies = [ 483 | "libc", 484 | "option-ext", 485 | "redox_users", 486 | "windows-sys 0.60.2", 487 | ] 488 | 489 | [[package]] 490 | name = "document-features" 491 | version = "0.2.11" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" 494 | dependencies = [ 495 | "litrs", 496 | ] 497 | 498 | [[package]] 499 | name = "either" 500 | version = "1.15.0" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 503 | 504 | [[package]] 505 | name = "encode_unicode" 506 | version = "1.0.0" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 509 | 510 | [[package]] 511 | name = "equivalent" 512 | version = "1.0.2" 513 | source = "registry+https://github.com/rust-lang/crates.io-index" 514 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 515 | 516 | [[package]] 517 | name = "errno" 518 | version = "0.3.12" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" 521 | dependencies = [ 522 | "libc", 523 | "windows-sys 0.59.0", 524 | ] 525 | 526 | [[package]] 527 | name = "fallible-iterator" 528 | version = "0.3.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 531 | 532 | [[package]] 533 | name = "fallible-streaming-iterator" 534 | version = "0.1.9" 535 | source = "registry+https://github.com/rust-lang/crates.io-index" 536 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 537 | 538 | [[package]] 539 | name = "fnv" 540 | version = "1.0.7" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 543 | 544 | [[package]] 545 | name = "foldhash" 546 | version = "0.1.5" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 549 | 550 | [[package]] 551 | name = "getrandom" 552 | version = "0.2.16" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 555 | dependencies = [ 556 | "cfg-if", 557 | "libc", 558 | "wasi 0.11.1+wasi-snapshot-preview1", 559 | ] 560 | 561 | [[package]] 562 | name = "getrandom" 563 | version = "0.3.3" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 566 | dependencies = [ 567 | "cfg-if", 568 | "libc", 569 | "r-efi", 570 | "wasi 0.14.2+wasi-0.2.4", 571 | ] 572 | 573 | [[package]] 574 | name = "half" 575 | version = "2.6.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" 578 | dependencies = [ 579 | "cfg-if", 580 | "crunchy", 581 | ] 582 | 583 | [[package]] 584 | name = "hashbrown" 585 | version = "0.15.4" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" 588 | dependencies = [ 589 | "allocator-api2", 590 | "equivalent", 591 | "foldhash", 592 | ] 593 | 594 | [[package]] 595 | name = "hashlink" 596 | version = "0.10.0" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 599 | dependencies = [ 600 | "hashbrown", 601 | ] 602 | 603 | [[package]] 604 | name = "heck" 605 | version = "0.5.0" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 608 | 609 | [[package]] 610 | name = "hermit-abi" 611 | version = "0.5.2" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 614 | 615 | [[package]] 616 | name = "humansize" 617 | version = "2.1.3" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" 620 | dependencies = [ 621 | "libm", 622 | ] 623 | 624 | [[package]] 625 | name = "iana-time-zone" 626 | version = "0.1.63" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" 629 | dependencies = [ 630 | "android_system_properties", 631 | "core-foundation-sys", 632 | "iana-time-zone-haiku", 633 | "js-sys", 634 | "log", 635 | "wasm-bindgen", 636 | "windows-core", 637 | ] 638 | 639 | [[package]] 640 | name = "iana-time-zone-haiku" 641 | version = "0.1.2" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 644 | dependencies = [ 645 | "cc", 646 | ] 647 | 648 | [[package]] 649 | name = "ident_case" 650 | version = "1.0.1" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 653 | 654 | [[package]] 655 | name = "indicatif" 656 | version = "0.17.11" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" 659 | dependencies = [ 660 | "console", 661 | "number_prefix", 662 | "portable-atomic", 663 | "unicode-width 0.2.0", 664 | "web-time", 665 | ] 666 | 667 | [[package]] 668 | name = "indoc" 669 | version = "2.0.6" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 672 | 673 | [[package]] 674 | name = "instability" 675 | version = "0.3.7" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" 678 | dependencies = [ 679 | "darling", 680 | "indoc", 681 | "proc-macro2", 682 | "quote", 683 | "syn", 684 | ] 685 | 686 | [[package]] 687 | name = "is-terminal" 688 | version = "0.4.16" 689 | source = "registry+https://github.com/rust-lang/crates.io-index" 690 | checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" 691 | dependencies = [ 692 | "hermit-abi", 693 | "libc", 694 | "windows-sys 0.59.0", 695 | ] 696 | 697 | [[package]] 698 | name = "is_terminal_polyfill" 699 | version = "1.70.1" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 702 | 703 | [[package]] 704 | name = "itertools" 705 | version = "0.10.5" 706 | source = "registry+https://github.com/rust-lang/crates.io-index" 707 | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" 708 | dependencies = [ 709 | "either", 710 | ] 711 | 712 | [[package]] 713 | name = "itertools" 714 | version = "0.13.0" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 717 | dependencies = [ 718 | "either", 719 | ] 720 | 721 | [[package]] 722 | name = "itoa" 723 | version = "1.0.15" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 726 | 727 | [[package]] 728 | name = "js-sys" 729 | version = "0.3.77" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 732 | dependencies = [ 733 | "once_cell", 734 | "wasm-bindgen", 735 | ] 736 | 737 | [[package]] 738 | name = "libc" 739 | version = "0.2.173" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" 742 | 743 | [[package]] 744 | name = "libm" 745 | version = "0.2.15" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" 748 | 749 | [[package]] 750 | name = "libredox" 751 | version = "0.1.3" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 754 | dependencies = [ 755 | "bitflags", 756 | "libc", 757 | ] 758 | 759 | [[package]] 760 | name = "libsqlite3-sys" 761 | version = "0.33.0" 762 | source = "registry+https://github.com/rust-lang/crates.io-index" 763 | checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" 764 | dependencies = [ 765 | "cc", 766 | "pkg-config", 767 | "vcpkg", 768 | ] 769 | 770 | [[package]] 771 | name = "linux-raw-sys" 772 | version = "0.4.15" 773 | source = "registry+https://github.com/rust-lang/crates.io-index" 774 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 775 | 776 | [[package]] 777 | name = "linux-raw-sys" 778 | version = "0.9.4" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 781 | 782 | [[package]] 783 | name = "litrs" 784 | version = "0.4.1" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" 787 | 788 | [[package]] 789 | name = "lock_api" 790 | version = "0.4.13" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 793 | dependencies = [ 794 | "autocfg", 795 | "scopeguard", 796 | ] 797 | 798 | [[package]] 799 | name = "log" 800 | version = "0.4.27" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 803 | 804 | [[package]] 805 | name = "lru" 806 | version = "0.12.5" 807 | source = "registry+https://github.com/rust-lang/crates.io-index" 808 | checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 809 | dependencies = [ 810 | "hashbrown", 811 | ] 812 | 813 | [[package]] 814 | name = "memchr" 815 | version = "2.7.5" 816 | source = "registry+https://github.com/rust-lang/crates.io-index" 817 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 818 | 819 | [[package]] 820 | name = "mio" 821 | version = "1.0.4" 822 | source = "registry+https://github.com/rust-lang/crates.io-index" 823 | checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 824 | dependencies = [ 825 | "libc", 826 | "log", 827 | "wasi 0.11.1+wasi-snapshot-preview1", 828 | "windows-sys 0.59.0", 829 | ] 830 | 831 | [[package]] 832 | name = "nix" 833 | version = "0.29.0" 834 | source = "registry+https://github.com/rust-lang/crates.io-index" 835 | checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" 836 | dependencies = [ 837 | "bitflags", 838 | "cfg-if", 839 | "cfg_aliases", 840 | "libc", 841 | ] 842 | 843 | [[package]] 844 | name = "num-conv" 845 | version = "0.1.0" 846 | source = "registry+https://github.com/rust-lang/crates.io-index" 847 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 848 | 849 | [[package]] 850 | name = "num-traits" 851 | version = "0.2.19" 852 | source = "registry+https://github.com/rust-lang/crates.io-index" 853 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 854 | dependencies = [ 855 | "autocfg", 856 | ] 857 | 858 | [[package]] 859 | name = "num_threads" 860 | version = "0.1.7" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 863 | dependencies = [ 864 | "libc", 865 | ] 866 | 867 | [[package]] 868 | name = "number_prefix" 869 | version = "0.4.0" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" 872 | 873 | [[package]] 874 | name = "once_cell" 875 | version = "1.21.3" 876 | source = "registry+https://github.com/rust-lang/crates.io-index" 877 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 878 | 879 | [[package]] 880 | name = "once_cell_polyfill" 881 | version = "1.70.1" 882 | source = "registry+https://github.com/rust-lang/crates.io-index" 883 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 884 | 885 | [[package]] 886 | name = "oorandom" 887 | version = "11.1.5" 888 | source = "registry+https://github.com/rust-lang/crates.io-index" 889 | checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" 890 | 891 | [[package]] 892 | name = "option-ext" 893 | version = "0.2.0" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 896 | 897 | [[package]] 898 | name = "parking_lot" 899 | version = "0.12.4" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 902 | dependencies = [ 903 | "lock_api", 904 | "parking_lot_core", 905 | ] 906 | 907 | [[package]] 908 | name = "parking_lot_core" 909 | version = "0.9.11" 910 | source = "registry+https://github.com/rust-lang/crates.io-index" 911 | checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 912 | dependencies = [ 913 | "cfg-if", 914 | "libc", 915 | "redox_syscall", 916 | "smallvec", 917 | "windows-targets 0.52.6", 918 | ] 919 | 920 | [[package]] 921 | name = "paste" 922 | version = "1.0.15" 923 | source = "registry+https://github.com/rust-lang/crates.io-index" 924 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 925 | 926 | [[package]] 927 | name = "pkg-config" 928 | version = "0.3.32" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 931 | 932 | [[package]] 933 | name = "plotters" 934 | version = "0.3.7" 935 | source = "registry+https://github.com/rust-lang/crates.io-index" 936 | checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" 937 | dependencies = [ 938 | "num-traits", 939 | "plotters-backend", 940 | "plotters-svg", 941 | "wasm-bindgen", 942 | "web-sys", 943 | ] 944 | 945 | [[package]] 946 | name = "plotters-backend" 947 | version = "0.3.7" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" 950 | 951 | [[package]] 952 | name = "plotters-svg" 953 | version = "0.3.7" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" 956 | dependencies = [ 957 | "plotters-backend", 958 | ] 959 | 960 | [[package]] 961 | name = "portable-atomic" 962 | version = "1.11.1" 963 | source = "registry+https://github.com/rust-lang/crates.io-index" 964 | checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 965 | 966 | [[package]] 967 | name = "powerfmt" 968 | version = "0.2.0" 969 | source = "registry+https://github.com/rust-lang/crates.io-index" 970 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 971 | 972 | [[package]] 973 | name = "ppv-lite86" 974 | version = "0.2.21" 975 | source = "registry+https://github.com/rust-lang/crates.io-index" 976 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 977 | dependencies = [ 978 | "zerocopy", 979 | ] 980 | 981 | [[package]] 982 | name = "proc-macro2" 983 | version = "1.0.95" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 986 | dependencies = [ 987 | "unicode-ident", 988 | ] 989 | 990 | [[package]] 991 | name = "quote" 992 | version = "1.0.40" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 995 | dependencies = [ 996 | "proc-macro2", 997 | ] 998 | 999 | [[package]] 1000 | name = "r-efi" 1001 | version = "5.2.0" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1004 | 1005 | [[package]] 1006 | name = "rand" 1007 | version = "0.9.1" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1010 | dependencies = [ 1011 | "rand_chacha", 1012 | "rand_core", 1013 | ] 1014 | 1015 | [[package]] 1016 | name = "rand_chacha" 1017 | version = "0.9.0" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1020 | dependencies = [ 1021 | "ppv-lite86", 1022 | "rand_core", 1023 | ] 1024 | 1025 | [[package]] 1026 | name = "rand_core" 1027 | version = "0.9.3" 1028 | source = "registry+https://github.com/rust-lang/crates.io-index" 1029 | checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1030 | dependencies = [ 1031 | "getrandom 0.3.3", 1032 | ] 1033 | 1034 | [[package]] 1035 | name = "ratatui" 1036 | version = "0.29.0" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 1039 | dependencies = [ 1040 | "bitflags", 1041 | "cassowary", 1042 | "compact_str", 1043 | "crossterm 0.28.1", 1044 | "indoc", 1045 | "instability", 1046 | "itertools 0.13.0", 1047 | "lru", 1048 | "paste", 1049 | "strum", 1050 | "unicode-segmentation", 1051 | "unicode-truncate", 1052 | "unicode-width 0.2.0", 1053 | ] 1054 | 1055 | [[package]] 1056 | name = "rayon" 1057 | version = "1.10.0" 1058 | source = "registry+https://github.com/rust-lang/crates.io-index" 1059 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 1060 | dependencies = [ 1061 | "either", 1062 | "rayon-core", 1063 | ] 1064 | 1065 | [[package]] 1066 | name = "rayon-core" 1067 | version = "1.12.1" 1068 | source = "registry+https://github.com/rust-lang/crates.io-index" 1069 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 1070 | dependencies = [ 1071 | "crossbeam-deque", 1072 | "crossbeam-utils", 1073 | ] 1074 | 1075 | [[package]] 1076 | name = "redox_syscall" 1077 | version = "0.5.13" 1078 | source = "registry+https://github.com/rust-lang/crates.io-index" 1079 | checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" 1080 | dependencies = [ 1081 | "bitflags", 1082 | ] 1083 | 1084 | [[package]] 1085 | name = "redox_users" 1086 | version = "0.5.0" 1087 | source = "registry+https://github.com/rust-lang/crates.io-index" 1088 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 1089 | dependencies = [ 1090 | "getrandom 0.2.16", 1091 | "libredox", 1092 | "thiserror", 1093 | ] 1094 | 1095 | [[package]] 1096 | name = "redu" 1097 | version = "0.2.14" 1098 | dependencies = [ 1099 | "anyhow", 1100 | "camino", 1101 | "chrono", 1102 | "clap", 1103 | "criterion", 1104 | "crossterm 0.29.0", 1105 | "directories", 1106 | "humansize", 1107 | "indicatif", 1108 | "log", 1109 | "nix", 1110 | "rand", 1111 | "ratatui", 1112 | "rpassword", 1113 | "rusqlite", 1114 | "scopeguard", 1115 | "serde", 1116 | "serde_json", 1117 | "simplelog", 1118 | "thiserror", 1119 | "unicode-segmentation", 1120 | "uuid", 1121 | ] 1122 | 1123 | [[package]] 1124 | name = "regex" 1125 | version = "1.11.1" 1126 | source = "registry+https://github.com/rust-lang/crates.io-index" 1127 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1128 | dependencies = [ 1129 | "aho-corasick", 1130 | "memchr", 1131 | "regex-automata", 1132 | "regex-syntax", 1133 | ] 1134 | 1135 | [[package]] 1136 | name = "regex-automata" 1137 | version = "0.4.9" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1140 | dependencies = [ 1141 | "aho-corasick", 1142 | "memchr", 1143 | "regex-syntax", 1144 | ] 1145 | 1146 | [[package]] 1147 | name = "regex-syntax" 1148 | version = "0.8.5" 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" 1150 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1151 | 1152 | [[package]] 1153 | name = "rpassword" 1154 | version = "7.4.0" 1155 | source = "registry+https://github.com/rust-lang/crates.io-index" 1156 | checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" 1157 | dependencies = [ 1158 | "libc", 1159 | "rtoolbox", 1160 | "windows-sys 0.59.0", 1161 | ] 1162 | 1163 | [[package]] 1164 | name = "rtoolbox" 1165 | version = "0.0.3" 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" 1167 | checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" 1168 | dependencies = [ 1169 | "libc", 1170 | "windows-sys 0.52.0", 1171 | ] 1172 | 1173 | [[package]] 1174 | name = "rusqlite" 1175 | version = "0.35.0" 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" 1177 | checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" 1178 | dependencies = [ 1179 | "bitflags", 1180 | "fallible-iterator", 1181 | "fallible-streaming-iterator", 1182 | "hashlink", 1183 | "libsqlite3-sys", 1184 | "smallvec", 1185 | ] 1186 | 1187 | [[package]] 1188 | name = "rustix" 1189 | version = "0.38.44" 1190 | source = "registry+https://github.com/rust-lang/crates.io-index" 1191 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1192 | dependencies = [ 1193 | "bitflags", 1194 | "errno", 1195 | "libc", 1196 | "linux-raw-sys 0.4.15", 1197 | "windows-sys 0.59.0", 1198 | ] 1199 | 1200 | [[package]] 1201 | name = "rustix" 1202 | version = "1.0.7" 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" 1204 | checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" 1205 | dependencies = [ 1206 | "bitflags", 1207 | "errno", 1208 | "libc", 1209 | "linux-raw-sys 0.9.4", 1210 | "windows-sys 0.59.0", 1211 | ] 1212 | 1213 | [[package]] 1214 | name = "rustversion" 1215 | version = "1.0.21" 1216 | source = "registry+https://github.com/rust-lang/crates.io-index" 1217 | checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 1218 | 1219 | [[package]] 1220 | name = "ryu" 1221 | version = "1.0.20" 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" 1223 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1224 | 1225 | [[package]] 1226 | name = "same-file" 1227 | version = "1.0.6" 1228 | source = "registry+https://github.com/rust-lang/crates.io-index" 1229 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1230 | dependencies = [ 1231 | "winapi-util", 1232 | ] 1233 | 1234 | [[package]] 1235 | name = "scopeguard" 1236 | version = "1.2.0" 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" 1238 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1239 | 1240 | [[package]] 1241 | name = "serde" 1242 | version = "1.0.219" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1245 | dependencies = [ 1246 | "serde_derive", 1247 | ] 1248 | 1249 | [[package]] 1250 | name = "serde_derive" 1251 | version = "1.0.219" 1252 | source = "registry+https://github.com/rust-lang/crates.io-index" 1253 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1254 | dependencies = [ 1255 | "proc-macro2", 1256 | "quote", 1257 | "syn", 1258 | ] 1259 | 1260 | [[package]] 1261 | name = "serde_json" 1262 | version = "1.0.140" 1263 | source = "registry+https://github.com/rust-lang/crates.io-index" 1264 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1265 | dependencies = [ 1266 | "itoa", 1267 | "memchr", 1268 | "ryu", 1269 | "serde", 1270 | ] 1271 | 1272 | [[package]] 1273 | name = "shlex" 1274 | version = "1.3.0" 1275 | source = "registry+https://github.com/rust-lang/crates.io-index" 1276 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1277 | 1278 | [[package]] 1279 | name = "signal-hook" 1280 | version = "0.3.18" 1281 | source = "registry+https://github.com/rust-lang/crates.io-index" 1282 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 1283 | dependencies = [ 1284 | "libc", 1285 | "signal-hook-registry", 1286 | ] 1287 | 1288 | [[package]] 1289 | name = "signal-hook-mio" 1290 | version = "0.2.4" 1291 | source = "registry+https://github.com/rust-lang/crates.io-index" 1292 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 1293 | dependencies = [ 1294 | "libc", 1295 | "mio", 1296 | "signal-hook", 1297 | ] 1298 | 1299 | [[package]] 1300 | name = "signal-hook-registry" 1301 | version = "1.4.5" 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" 1303 | checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 1304 | dependencies = [ 1305 | "libc", 1306 | ] 1307 | 1308 | [[package]] 1309 | name = "simplelog" 1310 | version = "0.12.2" 1311 | source = "registry+https://github.com/rust-lang/crates.io-index" 1312 | checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" 1313 | dependencies = [ 1314 | "log", 1315 | "termcolor", 1316 | "time", 1317 | ] 1318 | 1319 | [[package]] 1320 | name = "smallvec" 1321 | version = "1.15.1" 1322 | source = "registry+https://github.com/rust-lang/crates.io-index" 1323 | checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 1324 | 1325 | [[package]] 1326 | name = "static_assertions" 1327 | version = "1.1.0" 1328 | source = "registry+https://github.com/rust-lang/crates.io-index" 1329 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 1330 | 1331 | [[package]] 1332 | name = "strsim" 1333 | version = "0.11.1" 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" 1335 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 1336 | 1337 | [[package]] 1338 | name = "strum" 1339 | version = "0.26.3" 1340 | source = "registry+https://github.com/rust-lang/crates.io-index" 1341 | checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" 1342 | dependencies = [ 1343 | "strum_macros", 1344 | ] 1345 | 1346 | [[package]] 1347 | name = "strum_macros" 1348 | version = "0.26.4" 1349 | source = "registry+https://github.com/rust-lang/crates.io-index" 1350 | checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" 1351 | dependencies = [ 1352 | "heck", 1353 | "proc-macro2", 1354 | "quote", 1355 | "rustversion", 1356 | "syn", 1357 | ] 1358 | 1359 | [[package]] 1360 | name = "syn" 1361 | version = "2.0.103" 1362 | source = "registry+https://github.com/rust-lang/crates.io-index" 1363 | checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" 1364 | dependencies = [ 1365 | "proc-macro2", 1366 | "quote", 1367 | "unicode-ident", 1368 | ] 1369 | 1370 | [[package]] 1371 | name = "termcolor" 1372 | version = "1.4.1" 1373 | source = "registry+https://github.com/rust-lang/crates.io-index" 1374 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 1375 | dependencies = [ 1376 | "winapi-util", 1377 | ] 1378 | 1379 | [[package]] 1380 | name = "thiserror" 1381 | version = "2.0.12" 1382 | source = "registry+https://github.com/rust-lang/crates.io-index" 1383 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 1384 | dependencies = [ 1385 | "thiserror-impl", 1386 | ] 1387 | 1388 | [[package]] 1389 | name = "thiserror-impl" 1390 | version = "2.0.12" 1391 | source = "registry+https://github.com/rust-lang/crates.io-index" 1392 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 1393 | dependencies = [ 1394 | "proc-macro2", 1395 | "quote", 1396 | "syn", 1397 | ] 1398 | 1399 | [[package]] 1400 | name = "time" 1401 | version = "0.3.41" 1402 | source = "registry+https://github.com/rust-lang/crates.io-index" 1403 | checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" 1404 | dependencies = [ 1405 | "deranged", 1406 | "itoa", 1407 | "libc", 1408 | "num-conv", 1409 | "num_threads", 1410 | "powerfmt", 1411 | "serde", 1412 | "time-core", 1413 | "time-macros", 1414 | ] 1415 | 1416 | [[package]] 1417 | name = "time-core" 1418 | version = "0.1.4" 1419 | source = "registry+https://github.com/rust-lang/crates.io-index" 1420 | checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" 1421 | 1422 | [[package]] 1423 | name = "time-macros" 1424 | version = "0.2.22" 1425 | source = "registry+https://github.com/rust-lang/crates.io-index" 1426 | checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" 1427 | dependencies = [ 1428 | "num-conv", 1429 | "time-core", 1430 | ] 1431 | 1432 | [[package]] 1433 | name = "tinytemplate" 1434 | version = "1.2.1" 1435 | source = "registry+https://github.com/rust-lang/crates.io-index" 1436 | checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" 1437 | dependencies = [ 1438 | "serde", 1439 | "serde_json", 1440 | ] 1441 | 1442 | [[package]] 1443 | name = "unicode-ident" 1444 | version = "1.0.18" 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" 1446 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 1447 | 1448 | [[package]] 1449 | name = "unicode-segmentation" 1450 | version = "1.12.0" 1451 | source = "registry+https://github.com/rust-lang/crates.io-index" 1452 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1453 | 1454 | [[package]] 1455 | name = "unicode-truncate" 1456 | version = "1.1.0" 1457 | source = "registry+https://github.com/rust-lang/crates.io-index" 1458 | checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" 1459 | dependencies = [ 1460 | "itertools 0.13.0", 1461 | "unicode-segmentation", 1462 | "unicode-width 0.1.14", 1463 | ] 1464 | 1465 | [[package]] 1466 | name = "unicode-width" 1467 | version = "0.1.14" 1468 | source = "registry+https://github.com/rust-lang/crates.io-index" 1469 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1470 | 1471 | [[package]] 1472 | name = "unicode-width" 1473 | version = "0.2.0" 1474 | source = "registry+https://github.com/rust-lang/crates.io-index" 1475 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1476 | 1477 | [[package]] 1478 | name = "utf8parse" 1479 | version = "0.2.2" 1480 | source = "registry+https://github.com/rust-lang/crates.io-index" 1481 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1482 | 1483 | [[package]] 1484 | name = "uuid" 1485 | version = "1.17.0" 1486 | source = "registry+https://github.com/rust-lang/crates.io-index" 1487 | checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" 1488 | dependencies = [ 1489 | "getrandom 0.3.3", 1490 | "js-sys", 1491 | "wasm-bindgen", 1492 | ] 1493 | 1494 | [[package]] 1495 | name = "vcpkg" 1496 | version = "0.2.15" 1497 | source = "registry+https://github.com/rust-lang/crates.io-index" 1498 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1499 | 1500 | [[package]] 1501 | name = "walkdir" 1502 | version = "2.5.0" 1503 | source = "registry+https://github.com/rust-lang/crates.io-index" 1504 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1505 | dependencies = [ 1506 | "same-file", 1507 | "winapi-util", 1508 | ] 1509 | 1510 | [[package]] 1511 | name = "wasi" 1512 | version = "0.11.1+wasi-snapshot-preview1" 1513 | source = "registry+https://github.com/rust-lang/crates.io-index" 1514 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 1515 | 1516 | [[package]] 1517 | name = "wasi" 1518 | version = "0.14.2+wasi-0.2.4" 1519 | source = "registry+https://github.com/rust-lang/crates.io-index" 1520 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 1521 | dependencies = [ 1522 | "wit-bindgen-rt", 1523 | ] 1524 | 1525 | [[package]] 1526 | name = "wasm-bindgen" 1527 | version = "0.2.100" 1528 | source = "registry+https://github.com/rust-lang/crates.io-index" 1529 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1530 | dependencies = [ 1531 | "cfg-if", 1532 | "once_cell", 1533 | "rustversion", 1534 | "wasm-bindgen-macro", 1535 | ] 1536 | 1537 | [[package]] 1538 | name = "wasm-bindgen-backend" 1539 | version = "0.2.100" 1540 | source = "registry+https://github.com/rust-lang/crates.io-index" 1541 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1542 | dependencies = [ 1543 | "bumpalo", 1544 | "log", 1545 | "proc-macro2", 1546 | "quote", 1547 | "syn", 1548 | "wasm-bindgen-shared", 1549 | ] 1550 | 1551 | [[package]] 1552 | name = "wasm-bindgen-macro" 1553 | version = "0.2.100" 1554 | source = "registry+https://github.com/rust-lang/crates.io-index" 1555 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1556 | dependencies = [ 1557 | "quote", 1558 | "wasm-bindgen-macro-support", 1559 | ] 1560 | 1561 | [[package]] 1562 | name = "wasm-bindgen-macro-support" 1563 | version = "0.2.100" 1564 | source = "registry+https://github.com/rust-lang/crates.io-index" 1565 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1566 | dependencies = [ 1567 | "proc-macro2", 1568 | "quote", 1569 | "syn", 1570 | "wasm-bindgen-backend", 1571 | "wasm-bindgen-shared", 1572 | ] 1573 | 1574 | [[package]] 1575 | name = "wasm-bindgen-shared" 1576 | version = "0.2.100" 1577 | source = "registry+https://github.com/rust-lang/crates.io-index" 1578 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1579 | dependencies = [ 1580 | "unicode-ident", 1581 | ] 1582 | 1583 | [[package]] 1584 | name = "web-sys" 1585 | version = "0.3.77" 1586 | source = "registry+https://github.com/rust-lang/crates.io-index" 1587 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 1588 | dependencies = [ 1589 | "js-sys", 1590 | "wasm-bindgen", 1591 | ] 1592 | 1593 | [[package]] 1594 | name = "web-time" 1595 | version = "1.1.0" 1596 | source = "registry+https://github.com/rust-lang/crates.io-index" 1597 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 1598 | dependencies = [ 1599 | "js-sys", 1600 | "wasm-bindgen", 1601 | ] 1602 | 1603 | [[package]] 1604 | name = "winapi" 1605 | version = "0.3.9" 1606 | source = "registry+https://github.com/rust-lang/crates.io-index" 1607 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1608 | dependencies = [ 1609 | "winapi-i686-pc-windows-gnu", 1610 | "winapi-x86_64-pc-windows-gnu", 1611 | ] 1612 | 1613 | [[package]] 1614 | name = "winapi-i686-pc-windows-gnu" 1615 | version = "0.4.0" 1616 | source = "registry+https://github.com/rust-lang/crates.io-index" 1617 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1618 | 1619 | [[package]] 1620 | name = "winapi-util" 1621 | version = "0.1.9" 1622 | source = "registry+https://github.com/rust-lang/crates.io-index" 1623 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1624 | dependencies = [ 1625 | "windows-sys 0.59.0", 1626 | ] 1627 | 1628 | [[package]] 1629 | name = "winapi-x86_64-pc-windows-gnu" 1630 | version = "0.4.0" 1631 | source = "registry+https://github.com/rust-lang/crates.io-index" 1632 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1633 | 1634 | [[package]] 1635 | name = "windows-core" 1636 | version = "0.61.2" 1637 | source = "registry+https://github.com/rust-lang/crates.io-index" 1638 | checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 1639 | dependencies = [ 1640 | "windows-implement", 1641 | "windows-interface", 1642 | "windows-link", 1643 | "windows-result", 1644 | "windows-strings", 1645 | ] 1646 | 1647 | [[package]] 1648 | name = "windows-implement" 1649 | version = "0.60.0" 1650 | source = "registry+https://github.com/rust-lang/crates.io-index" 1651 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 1652 | dependencies = [ 1653 | "proc-macro2", 1654 | "quote", 1655 | "syn", 1656 | ] 1657 | 1658 | [[package]] 1659 | name = "windows-interface" 1660 | version = "0.59.1" 1661 | source = "registry+https://github.com/rust-lang/crates.io-index" 1662 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 1663 | dependencies = [ 1664 | "proc-macro2", 1665 | "quote", 1666 | "syn", 1667 | ] 1668 | 1669 | [[package]] 1670 | name = "windows-link" 1671 | version = "0.1.3" 1672 | source = "registry+https://github.com/rust-lang/crates.io-index" 1673 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 1674 | 1675 | [[package]] 1676 | name = "windows-result" 1677 | version = "0.3.4" 1678 | source = "registry+https://github.com/rust-lang/crates.io-index" 1679 | checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 1680 | dependencies = [ 1681 | "windows-link", 1682 | ] 1683 | 1684 | [[package]] 1685 | name = "windows-strings" 1686 | version = "0.4.2" 1687 | source = "registry+https://github.com/rust-lang/crates.io-index" 1688 | checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 1689 | dependencies = [ 1690 | "windows-link", 1691 | ] 1692 | 1693 | [[package]] 1694 | name = "windows-sys" 1695 | version = "0.52.0" 1696 | source = "registry+https://github.com/rust-lang/crates.io-index" 1697 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1698 | dependencies = [ 1699 | "windows-targets 0.52.6", 1700 | ] 1701 | 1702 | [[package]] 1703 | name = "windows-sys" 1704 | version = "0.59.0" 1705 | source = "registry+https://github.com/rust-lang/crates.io-index" 1706 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1707 | dependencies = [ 1708 | "windows-targets 0.52.6", 1709 | ] 1710 | 1711 | [[package]] 1712 | name = "windows-sys" 1713 | version = "0.60.2" 1714 | source = "registry+https://github.com/rust-lang/crates.io-index" 1715 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1716 | dependencies = [ 1717 | "windows-targets 0.53.2", 1718 | ] 1719 | 1720 | [[package]] 1721 | name = "windows-targets" 1722 | version = "0.52.6" 1723 | source = "registry+https://github.com/rust-lang/crates.io-index" 1724 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1725 | dependencies = [ 1726 | "windows_aarch64_gnullvm 0.52.6", 1727 | "windows_aarch64_msvc 0.52.6", 1728 | "windows_i686_gnu 0.52.6", 1729 | "windows_i686_gnullvm 0.52.6", 1730 | "windows_i686_msvc 0.52.6", 1731 | "windows_x86_64_gnu 0.52.6", 1732 | "windows_x86_64_gnullvm 0.52.6", 1733 | "windows_x86_64_msvc 0.52.6", 1734 | ] 1735 | 1736 | [[package]] 1737 | name = "windows-targets" 1738 | version = "0.53.2" 1739 | source = "registry+https://github.com/rust-lang/crates.io-index" 1740 | checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" 1741 | dependencies = [ 1742 | "windows_aarch64_gnullvm 0.53.0", 1743 | "windows_aarch64_msvc 0.53.0", 1744 | "windows_i686_gnu 0.53.0", 1745 | "windows_i686_gnullvm 0.53.0", 1746 | "windows_i686_msvc 0.53.0", 1747 | "windows_x86_64_gnu 0.53.0", 1748 | "windows_x86_64_gnullvm 0.53.0", 1749 | "windows_x86_64_msvc 0.53.0", 1750 | ] 1751 | 1752 | [[package]] 1753 | name = "windows_aarch64_gnullvm" 1754 | version = "0.52.6" 1755 | source = "registry+https://github.com/rust-lang/crates.io-index" 1756 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1757 | 1758 | [[package]] 1759 | name = "windows_aarch64_gnullvm" 1760 | version = "0.53.0" 1761 | source = "registry+https://github.com/rust-lang/crates.io-index" 1762 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 1763 | 1764 | [[package]] 1765 | name = "windows_aarch64_msvc" 1766 | version = "0.52.6" 1767 | source = "registry+https://github.com/rust-lang/crates.io-index" 1768 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1769 | 1770 | [[package]] 1771 | name = "windows_aarch64_msvc" 1772 | version = "0.53.0" 1773 | source = "registry+https://github.com/rust-lang/crates.io-index" 1774 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 1775 | 1776 | [[package]] 1777 | name = "windows_i686_gnu" 1778 | version = "0.52.6" 1779 | source = "registry+https://github.com/rust-lang/crates.io-index" 1780 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1781 | 1782 | [[package]] 1783 | name = "windows_i686_gnu" 1784 | version = "0.53.0" 1785 | source = "registry+https://github.com/rust-lang/crates.io-index" 1786 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 1787 | 1788 | [[package]] 1789 | name = "windows_i686_gnullvm" 1790 | version = "0.52.6" 1791 | source = "registry+https://github.com/rust-lang/crates.io-index" 1792 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1793 | 1794 | [[package]] 1795 | name = "windows_i686_gnullvm" 1796 | version = "0.53.0" 1797 | source = "registry+https://github.com/rust-lang/crates.io-index" 1798 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 1799 | 1800 | [[package]] 1801 | name = "windows_i686_msvc" 1802 | version = "0.52.6" 1803 | source = "registry+https://github.com/rust-lang/crates.io-index" 1804 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1805 | 1806 | [[package]] 1807 | name = "windows_i686_msvc" 1808 | version = "0.53.0" 1809 | source = "registry+https://github.com/rust-lang/crates.io-index" 1810 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 1811 | 1812 | [[package]] 1813 | name = "windows_x86_64_gnu" 1814 | version = "0.52.6" 1815 | source = "registry+https://github.com/rust-lang/crates.io-index" 1816 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1817 | 1818 | [[package]] 1819 | name = "windows_x86_64_gnu" 1820 | version = "0.53.0" 1821 | source = "registry+https://github.com/rust-lang/crates.io-index" 1822 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 1823 | 1824 | [[package]] 1825 | name = "windows_x86_64_gnullvm" 1826 | version = "0.52.6" 1827 | source = "registry+https://github.com/rust-lang/crates.io-index" 1828 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1829 | 1830 | [[package]] 1831 | name = "windows_x86_64_gnullvm" 1832 | version = "0.53.0" 1833 | source = "registry+https://github.com/rust-lang/crates.io-index" 1834 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 1835 | 1836 | [[package]] 1837 | name = "windows_x86_64_msvc" 1838 | version = "0.52.6" 1839 | source = "registry+https://github.com/rust-lang/crates.io-index" 1840 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1841 | 1842 | [[package]] 1843 | name = "windows_x86_64_msvc" 1844 | version = "0.53.0" 1845 | source = "registry+https://github.com/rust-lang/crates.io-index" 1846 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1847 | 1848 | [[package]] 1849 | name = "wit-bindgen-rt" 1850 | version = "0.39.0" 1851 | source = "registry+https://github.com/rust-lang/crates.io-index" 1852 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 1853 | dependencies = [ 1854 | "bitflags", 1855 | ] 1856 | 1857 | [[package]] 1858 | name = "zerocopy" 1859 | version = "0.8.25" 1860 | source = "registry+https://github.com/rust-lang/crates.io-index" 1861 | checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" 1862 | dependencies = [ 1863 | "zerocopy-derive", 1864 | ] 1865 | 1866 | [[package]] 1867 | name = "zerocopy-derive" 1868 | version = "0.8.25" 1869 | source = "registry+https://github.com/rust-lang/crates.io-index" 1870 | checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" 1871 | dependencies = [ 1872 | "proc-macro2", 1873 | "quote", 1874 | "syn", 1875 | ] 1876 | --------------------------------------------------------------------------------