mirror of
https://github.com/rustic-rs/rustic.git
synced 2025-10-26 11:18:51 +00:00
Merge branch 'main' into simonsan-patch-1
This commit is contained in:
commit
f7509adee7
10
.cargo/audit.toml
Normal file
10
.cargo/audit.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[advisories]
|
||||
ignore = [
|
||||
# FIXME!: See https://github.com/RustCrypto/RSA/issues/19#issuecomment-1822995643.
|
||||
# There is no workaround available yet.
|
||||
"RUSTSEC-2023-0071",
|
||||
# FIXME!: proc-macro-error's maintainer seems to be unreachable, we use `merge` which is using this.
|
||||
# `merge` is self-hosted on another platform, we should check if and how to replace it/open upstream
|
||||
# issue for updating the dependency.
|
||||
"RUSTSEC-2024-0370",
|
||||
]
|
||||
50
.github/renovate.json
vendored
50
.github/renovate.json
vendored
@ -1,52 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":dependencyDashboard",
|
||||
"helpers:pinGitHubActionDigests"
|
||||
],
|
||||
"separateMinorPatch": false,
|
||||
"prHourlyLimit": 1,
|
||||
"prConcurrentLimit": 1,
|
||||
"major": {
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
"labels": [
|
||||
"A-dependencies"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Automerge pin updates for GitHub Actions",
|
||||
"matchDatasources": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchDepTypes": [
|
||||
"action"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"pin",
|
||||
"digest",
|
||||
"pinDigest"
|
||||
],
|
||||
"labels": [
|
||||
"A-ci"
|
||||
],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchDatasources": [
|
||||
"cargo"
|
||||
],
|
||||
"extends": [
|
||||
"schedule:weekly"
|
||||
]
|
||||
}
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"automerge": true,
|
||||
"extends": [
|
||||
"schedule:monthly"
|
||||
]
|
||||
}
|
||||
"extends": ["local>rustic-rs/.github:renovate-config"]
|
||||
}
|
||||
|
||||
15
.github/workflows/audit.yml
vendored
15
.github/workflows/audit.yml
vendored
@ -14,31 +14,36 @@ on:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
# Ensure that the latest version of Cargo is installed
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: rustsec/audit-check@dd51754d4e59da7395a4cd9b593f0ff2d61a9b95 # v1.4.1
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
- uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ignore: RUSTSEC-2023-0071 # rsa thingy, ignored for now
|
||||
|
||||
cargo-deny:
|
||||
name: Run cargo-deny
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: EmbarkStudios/cargo-deny-action@1e59595bed8fc55c969333d08d7817b36888f0c5 # v1
|
||||
- uses: EmbarkStudios/cargo-deny-action@8371184bd11e21dcf8ac82ebf8c9c9f74ebf7268 # v2
|
||||
with:
|
||||
command: check bans licenses sources
|
||||
|
||||
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -15,12 +15,16 @@ on:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: Rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
@ -34,17 +38,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
feature: [default]
|
||||
feature: [release]
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --features ${{ matrix.feature }} -- -D warnings
|
||||
run: cargo clippy --locked --all-targets --features ${{ matrix.feature }} -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
@ -52,18 +56,18 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
rust: [stable]
|
||||
feature: [default]
|
||||
feature: [release]
|
||||
job:
|
||||
- os: macos-latest
|
||||
- os: ubuntu-latest
|
||||
- os: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@ -73,7 +77,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
- name: Run Cargo Test
|
||||
run: cargo test -r --all-targets --features ${{ matrix.feature }} --workspace
|
||||
|
||||
@ -88,12 +92,12 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
- os: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
@ -103,7 +107,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
- name: Run Cargo Doc
|
||||
run: cargo doc --no-deps --all-features --workspace --examples
|
||||
|
||||
|
||||
17
.github/workflows/cross-ci.yml
vendored
17
.github/workflows/cross-ci.yml
vendored
@ -7,8 +7,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "renovate/**"
|
||||
- "release/**"
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
merge_group:
|
||||
@ -18,6 +16,10 @@ defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cross-check:
|
||||
name: Cross checking ${{ matrix.job.target }}
|
||||
@ -26,7 +28,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rust: [stable]
|
||||
feature: [default]
|
||||
feature: [release]
|
||||
job:
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
@ -38,7 +40,7 @@ jobs:
|
||||
target: x86_64-pc-windows-gnu
|
||||
architecture: x86_64
|
||||
use-cross: false
|
||||
- os: macos-latest
|
||||
- os: macos-13
|
||||
os-name: macos
|
||||
target: x86_64-apple-darwin
|
||||
architecture: x86_64
|
||||
@ -63,6 +65,11 @@ jobs:
|
||||
target: aarch64-unknown-linux-gnu
|
||||
architecture: arm64
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: aarch64-unknown-linux-musl
|
||||
architecture: arm64
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: i686-unknown-linux-gnu
|
||||
@ -81,7 +88,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Run Cross-CI action
|
||||
uses: rustic-rs/cross-ci-action@main
|
||||
|
||||
6
.github/workflows/lint-docs.yml
vendored
6
.github/workflows/lint-docs.yml
vendored
@ -7,11 +7,15 @@ on:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
style:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- uses: dprint/check@2f1cf31537886c3bfb05591c031f7744e48ba8a1 # v2.2
|
||||
|
||||
|
||||
14
.github/workflows/nightly.yml
vendored
14
.github/workflows/nightly.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
architecture: x86_64
|
||||
binary-postfix: ".exe"
|
||||
use-cross: false
|
||||
- os: macos-latest
|
||||
- os: macos-13
|
||||
os-name: macos
|
||||
target: x86_64-apple-darwin
|
||||
architecture: x86_64
|
||||
@ -67,6 +67,12 @@ jobs:
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: aarch64-unknown-linux-musl
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: i686-unknown-linux-gnu
|
||||
@ -89,7 +95,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history so that git describe works
|
||||
- name: Create binary artifact
|
||||
@ -120,7 +126,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all workflow run artifacts
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
- name: Releasing nightly builds
|
||||
shell: bash
|
||||
run: |
|
||||
@ -148,7 +154,7 @@ jobs:
|
||||
mkdir -p $WORKING_DIR/$DEST_DIR
|
||||
|
||||
# do the copy
|
||||
for i in binary-*; do cp -a $i/* $WORKING_DIR/$DEST_DIR; done
|
||||
for artifact_dir in binary-*; do cp -a $artifact_dir/* $WORKING_DIR/$DEST_DIR; done
|
||||
|
||||
# create the commit
|
||||
cd $WORKING_DIR
|
||||
|
||||
6
.github/workflows/prebuilt-pr.yml
vendored
6
.github/workflows/prebuilt-pr.yml
vendored
@ -10,6 +10,10 @@ on:
|
||||
- "docs/**/*"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
BINARY_NAME: rustic
|
||||
|
||||
@ -79,7 +83,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history so that git describe works
|
||||
- name: Create binary artifact
|
||||
|
||||
165
.github/workflows/release-cd.yml
vendored
Normal file
165
.github/workflows/release-cd.yml
vendored
Normal file
@ -0,0 +1,165 @@
|
||||
name: Continuous Deployment (Release)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# Run on stable releases
|
||||
- "v*.*.*"
|
||||
# Run on release candidates
|
||||
- "v*.*.*-rc*"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
discussions: write
|
||||
|
||||
env:
|
||||
BINARY_NAME: rustic
|
||||
BINARY_NIGHTLY_DIR: rustic
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
name: Publishing ${{ matrix.job.target }}
|
||||
runs-on: ${{ matrix.job.os }}
|
||||
strategy:
|
||||
fail-fast: false # so we upload the artifacts even if one of the jobs fails
|
||||
matrix:
|
||||
rust: [stable]
|
||||
job:
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
target: x86_64-pc-windows-msvc
|
||||
architecture: x86_64
|
||||
binary-postfix: ".exe"
|
||||
use-cross: false
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
target: x86_64-pc-windows-gnu
|
||||
architecture: x86_64
|
||||
binary-postfix: ".exe"
|
||||
use-cross: false
|
||||
- os: macos-13
|
||||
os-name: macos
|
||||
target: x86_64-apple-darwin
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: macos-latest
|
||||
os-name: macos
|
||||
target: aarch64-apple-darwin
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: x86_64-unknown-linux-gnu
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: x86_64-unknown-linux-musl
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: aarch64-unknown-linux-gnu
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: aarch64-unknown-linux-musl
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: i686-unknown-linux-gnu
|
||||
architecture: i686
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
# TODO!: This needs a fix, linking `execinfo` fails
|
||||
# - os: ubuntu-latest
|
||||
# os-name: netbsd
|
||||
# target: x86_64-unknown-netbsd
|
||||
# architecture: x86_64
|
||||
# binary-postfix: ""
|
||||
# use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: armv7-unknown-linux-gnueabihf
|
||||
architecture: armv7
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history so that git describe works
|
||||
- name: Create binary artifact
|
||||
uses: rustic-rs/create-binary-artifact-action@main # dev
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
target: ${{ matrix.job.target }}
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
binary-postfix: ${{ matrix.job.binary-postfix }}
|
||||
os: ${{ runner.os }}
|
||||
binary-name: ${{ env.BINARY_NAME }}
|
||||
package-secondary-name: ${{ matrix.job.target}}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
gpg-release-private-key: ${{ secrets.GPG_RELEASE_PRIVATE_KEY }}
|
||||
gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
rsign-release-private-key: ${{ secrets.RSIGN_RELEASE_PRIVATE_KEY }}
|
||||
rsign-passphrase: ${{ secrets.RSIGN_PASSPHRASE }}
|
||||
github-ref: ${{ github.ref }}
|
||||
sign-release: true
|
||||
hash-release: true
|
||||
use-project-version: true
|
||||
use-tag-version: true # IMPORTANT: this is being used to make sure the tag that is built is in the archive filename, so automation can download the correct version
|
||||
|
||||
create-release:
|
||||
name: Creating release with artifacts
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Need to clone the repo again for the CHANGELOG.md
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history so that git describe works
|
||||
|
||||
- name: Download all workflow run artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
|
||||
- name: Creating Release
|
||||
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2
|
||||
with:
|
||||
discussion_category_name: "Announcements"
|
||||
draft: true
|
||||
body_path: ${{ github.workspace }}/CHANGELOG.md
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
binary-*/${{ env.BINARY_NAME }}-*.tar.gz*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
result:
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
name: Result (Release CD)
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish
|
||||
- create-release
|
||||
steps:
|
||||
- name: Mark the job as successful
|
||||
run: exit 0
|
||||
if: success()
|
||||
- name: Mark the job as unsuccessful
|
||||
run: exit 1
|
||||
if: "!success()"
|
||||
8
.github/workflows/release-ci.yml
vendored
8
.github/workflows/release-ci.yml
vendored
@ -15,6 +15,10 @@ on:
|
||||
# - rustic-rs
|
||||
# - rustic_testing
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# determine-package:
|
||||
# name: Determine package to release
|
||||
@ -39,12 +43,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
- name: Run Cargo Test
|
||||
run: cargo test --release -p rustic-rs --test completions -- --ignored
|
||||
#
|
||||
|
||||
25
.github/workflows/release-image.yml
vendored
Normal file
25
.github/workflows/release-image.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
name: Release Docker Image
|
||||
|
||||
on: [release]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ghcr.io/rustic-rs/rustic:latest,ghcr.io/rustic-rs/rustic:${{ github.ref_name }}
|
||||
build-args: RUSTIC_VERSION=${{ github.ref_name }}
|
||||
36
.github/workflows/release-plz.yml
vendored
Normal file
36
.github/workflows/release-plz.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: Release-plz
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release-plz:
|
||||
name: Release-plz
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1
|
||||
id: generate-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run release-plz
|
||||
uses: MarcoIeni/release-plz-action@394e0e463367550953346be95d427f80f4f7ae30 # v0.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
37
.github/workflows/release-pr.yml
vendored
37
.github/workflows/release-pr.yml
vendored
@ -1,37 +0,0 @@
|
||||
name: Open a release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
crate:
|
||||
description: Crate to release
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- rustic-rs
|
||||
version:
|
||||
description: Version to release
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
make-release-pr:
|
||||
if: ${{ github.repository_owner == 'rustic-rs' }}
|
||||
permissions:
|
||||
id-token: write # Enable OIDC
|
||||
pull-requests: write
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: chainguard-dev/actions/setup-gitsign@main
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- name: Install cargo-release
|
||||
uses: taiki-e/install-action@f7c663c03b51ed0d93e9cec22a575d3f02175989 # v2
|
||||
with:
|
||||
tool: cargo-release
|
||||
- uses: cargo-bins/release-pr@deeacca5a38bacc74a3f444b798f4b9bba40f6ad # v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
version: ${{ inputs.version }}
|
||||
crate-name: ${{ inputs.crate }}
|
||||
check-semver: false # FIXME: Set back to true and check rustic-rs library API
|
||||
122
.github/workflows/release.yaml
vendored
122
.github/workflows/release.yaml
vendored
@ -1,122 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
|
||||
name: Build release binaries
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
name: Publishing ${{ matrix.job.target }}
|
||||
runs-on: ${{ matrix.job.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust: [stable]
|
||||
job:
|
||||
- os: windows-latest
|
||||
os-name: windows
|
||||
target: x86_64-pc-windows-msvc
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: macos-latest
|
||||
os-name: macos
|
||||
target: x86_64-apple-darwin
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: macos-latest
|
||||
os-name: macos
|
||||
target: aarch64-apple-darwin
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: x86_64-unknown-linux-gnu
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: x86_64-unknown-linux-musl
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: false
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: aarch64-unknown-linux-gnu
|
||||
architecture: arm64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: linux
|
||||
target: i686-unknown-linux-gnu
|
||||
architecture: i686
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
- os: ubuntu-latest
|
||||
os-name: netbsd
|
||||
target: x86_64-unknown-netbsd
|
||||
architecture: x86_64
|
||||
binary-postfix: ""
|
||||
use-cross: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
targets: ${{ matrix.job.target }}
|
||||
- name: install compiler
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ ${{ matrix.job.target }} == x86_64-unknown-linux-musl ]]; then
|
||||
sudo apt update
|
||||
sudo apt-get install -y musl-tools
|
||||
fi
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
with:
|
||||
key: ${{ matrix.job.target }}
|
||||
- name: Set Version
|
||||
shell: bash
|
||||
run: echo "PROJECT_VERSION=$(git describe --tags)" >> $GITHUB_ENV
|
||||
- name: Cargo build
|
||||
uses: ClementTsang/cargo-action@a211c79cf22973eb590277586fbea20269ca3ca0 # v0 (attention: this should be double checked for security issues)
|
||||
with:
|
||||
command: build
|
||||
use-cross: ${{ matrix.job.use-cross }}
|
||||
toolchain: ${{ matrix.rust }}
|
||||
args: --release --target ${{ matrix.job.target }}
|
||||
|
||||
- name: Packaging final binary
|
||||
if: ${{ !contains(github.ref_name, '/') }}
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.job.target }}/release
|
||||
|
||||
########## create tar.gz ##########
|
||||
RELEASE_NAME=rustic-${{ github.ref_name }}-${{ matrix.job.target}}
|
||||
tar czvf $RELEASE_NAME.tar.gz rustic${{ matrix.job.binary-postfix }}
|
||||
|
||||
########## create sha256 ##########
|
||||
if [[ ${{ runner.os }} == 'Windows' ]]; then
|
||||
certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256
|
||||
else
|
||||
shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256
|
||||
fi
|
||||
- name: Storing binary as artefact
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3
|
||||
with:
|
||||
name: binary-${{ matrix.job.target}}
|
||||
path: target/${{ matrix.job.target }}/release/rustic-${{ github.ref_name }}-${{ matrix.job.target}}.tar.gz
|
||||
- name: Releasing release versions
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
files: |
|
||||
target/${{ matrix.job.target }}/release/rustic-*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
6
.github/workflows/triage.yml
vendored
6
.github/workflows/triage.yml
vendored
@ -13,4 +13,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
run: |
|
||||
gh issue edit $ISSUE_URL --add-label "S-triage"
|
||||
# check if issue doesn't have any labels
|
||||
if [[ $(gh issue view $ISSUE_URL --json labels -q '.labels | length') -eq 0 ]]; then
|
||||
# add S-triage label
|
||||
gh issue edit $ISSUE_URL --add-label "S-triage"
|
||||
fi
|
||||
|
||||
4
.github/workflows/update-completions.yml
vendored
4
.github/workflows/update-completions.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
ref: refs/heads/${{ inputs.pr_branch }}
|
||||
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
|
||||
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
|
||||
|
||||
- name: Run Cargo Install
|
||||
id: generated-fixtures
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -5,3 +5,8 @@ mutants.out
|
||||
cargo-test*
|
||||
coverage/*lcov
|
||||
.testscompletions-*
|
||||
|
||||
# Ignore generated test files
|
||||
/tests/generated/*.toml
|
||||
/tests/generated/*.log
|
||||
/tests/generated/test-restore
|
||||
|
||||
376
CHANGELOG.md
376
CHANGELOG.md
@ -2,6 +2,382 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.9.4](https://github.com/rustic-rs/rustic/compare/v0.9.3...v0.9.4) - 2024-10-24
|
||||
|
||||
### Added
|
||||
|
||||
- *(commands)* Add tar output to dump command ([#1328](https://github.com/rustic-rs/rustic/pull/1328))
|
||||
|
||||
### Fixed
|
||||
|
||||
- clippy lints for new Rust version ([#1329](https://github.com/rustic-rs/rustic/pull/1329))
|
||||
- *(deps)* downgrade self-update to fix problems finding right target ([#1323](https://github.com/rustic-rs/rustic/pull/1323))
|
||||
|
||||
### Other
|
||||
|
||||
- *(deps)* remove once_cell and replace with std::sync::LazyLock, increase MSRV to 1.80.0 ([#1337](https://github.com/rustic-rs/rustic/pull/1337))
|
||||
- *(deps)* update tokio, ratatui, and tui-textarea ([#1336](https://github.com/rustic-rs/rustic/pull/1336))
|
||||
- *(deps)* update rustic_core and rustic_backend ([#1334](https://github.com/rustic-rs/rustic/pull/1334))
|
||||
- *(deps)* update abscissa framework ([#1330](https://github.com/rustic-rs/rustic/pull/1330))
|
||||
- introduce a new feature 'release' that includes the 'self-update' feature ([#1307](https://github.com/rustic-rs/rustic/pull/1307))
|
||||
|
||||
## [0.9.3](https://github.com/rustic-rs/rustic/compare/v0.9.2...v0.9.3) - 2024-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(deps)* update rustic_core to version 0.5.3 ([#1314](https://github.com/rustic-rs/rustic/pull/1314))
|
||||
|
||||
### Other
|
||||
|
||||
- add status badge for docker image build and shorten workflow name ([#1311](https://github.com/rustic-rs/rustic/pull/1311))
|
||||
|
||||
## [0.9.2](https://github.com/rustic-rs/rustic/compare/v0.9.1...v0.9.2) - 2024-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- *(config)* Add hooks ([#1218](https://github.com/rustic-rs/rustic/pull/1218))
|
||||
|
||||
### Other
|
||||
|
||||
- *(deps)* update rustic_core ([#1309](https://github.com/rustic-rs/rustic/pull/1309))
|
||||
- build and publish docker image on release ([#1297](https://github.com/rustic-rs/rustic/pull/1297))
|
||||
|
||||
## [0.9.1](https://github.com/rustic-rs/rustic/compare/v0.9.0...v0.9.1) - 2024-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- *(config)* add more filters ([#1263](https://github.com/rustic-rs/rustic/pull/1263))
|
||||
- *(check)* Allow to only check trees+packs for given snapshots ([#1230](https://github.com/rustic-rs/rustic/pull/1230))
|
||||
- *(commands)* add a `docs` command to easily access the user, dev and config documentation ([#1276](https://github.com/rustic-rs/rustic/pull/1276))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(docs/cli)* improve the descriptions of the CLI commands ([#1277](https://github.com/rustic-rs/rustic/pull/1277))
|
||||
- *(deps)* update rustic_core and other dependencies and fix merge precedence ([#1282](https://github.com/rustic-rs/rustic/pull/1282))
|
||||
- *(docs)* update configuration documentation to align with recent changes ([#1280](https://github.com/rustic-rs/rustic/pull/1280))
|
||||
|
||||
### Other
|
||||
|
||||
- *(deps)* upgrade dependencies ([#1289](https://github.com/rustic-rs/rustic/pull/1289))
|
||||
- add triage label to new issues only if no label has been set when creating it ([#1287](https://github.com/rustic-rs/rustic/pull/1287))
|
||||
- *(interactive)* use update methods for refreshing snapshots ([#1285](https://github.com/rustic-rs/rustic/pull/1285))
|
||||
|
||||
## [0.9.0](https://github.com/rustic-rs/rustic/compare/v0.8.1...v0.9.0) - 2024-09-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- [**breaking**] use multiple options only as array in config profile
|
||||
([#1240](https://github.com/rustic-rs/rustic/pull/1240))
|
||||
- Allow snapshots to be modified and marked to forget
|
||||
([#1253](https://github.com/rustic-rs/rustic/pull/1253))
|
||||
- make ls and find show the year of mtime date
|
||||
([#1249](https://github.com/rustic-rs/rustic/pull/1249))
|
||||
- ls: Remove printing trailing space
|
||||
([#1247](https://github.com/rustic-rs/rustic/pull/1247))
|
||||
- webdav/forget: correctly use application config
|
||||
([#1241](https://github.com/rustic-rs/rustic/pull/1241))
|
||||
|
||||
### Features
|
||||
|
||||
- [**breaking**] copy: Use config profile as target
|
||||
([#1131](https://github.com/rustic-rs/rustic/pull/1131))
|
||||
- backup: Add option `stdin-command`
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- Add list indexpacks and list indexcontent commands
|
||||
([#1254](https://github.com/rustic-rs/rustic/pull/1254))
|
||||
- Add option `--only-identical` for `diff` to allow for bitrot check
|
||||
([#1250](https://github.com/rustic-rs/rustic/pull/1250))
|
||||
- ls: Add option --json ([#1251](https://github.com/rustic-rs/rustic/pull/1251))
|
||||
- backup: Add option `--long`
|
||||
([#1159](https://github.com/rustic-rs/rustic/pull/1159))
|
||||
|
||||
### Documentation
|
||||
|
||||
- update installation instructions in readme to use `--locked` flag for install
|
||||
from crates.io
|
||||
- update RepositoryErrorKind rustdoc following rustic_core change
|
||||
([#1237](https://github.com/rustic-rs/rustic/pull/1237))
|
||||
|
||||
### Other
|
||||
|
||||
- Remove self-update from default crate features
|
||||
([#1139](https://github.com/rustic-rs/rustic/pull/1139))
|
||||
- Reduce memory usage of restore
|
||||
([#1069](https://github.com/rustic-rs/rustic/pull/1069))
|
||||
- *(deps)* update rust crate libc to v0.2.159
|
||||
([#1257](https://github.com/rustic-rs/rustic/pull/1257))
|
||||
- *(deps)* lock file maintenance
|
||||
([#1269](https://github.com/rustic-rs/rustic/pull/1269))
|
||||
- *(deps)* update rust crate rstest to 0.23
|
||||
([#1267](https://github.com/rustic-rs/rustic/pull/1267))
|
||||
- *(deps)* update rust crate tempfile to v3.13.0
|
||||
([#1266](https://github.com/rustic-rs/rustic/pull/1266))
|
||||
- *(deps)* update marcoieni/release-plz-action digest to 8b0f89a
|
||||
([#1265](https://github.com/rustic-rs/rustic/pull/1265))
|
||||
- *(deps)* update embarkstudios/cargo-deny-action action to v2
|
||||
([#1259](https://github.com/rustic-rs/rustic/pull/1259))
|
||||
- *(deps)* update rustsec/audit-check action to v2
|
||||
([#1260](https://github.com/rustic-rs/rustic/pull/1260))
|
||||
- *(deps)* update softprops/action-gh-release action to v2
|
||||
([#1258](https://github.com/rustic-rs/rustic/pull/1258))
|
||||
- *(deps)* update embarkstudios/cargo-deny-action digest to 3f4a782
|
||||
([#1228](https://github.com/rustic-rs/rustic/pull/1228))
|
||||
|
||||
## [0.8.1] - 2024-09-08
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow to compile without tui feature
|
||||
([#1208](https://github.com/rustic-rs/rustic/issues/1208))
|
||||
- Use cargo --locked in CI pipeline
|
||||
([#1207](https://github.com/rustic-rs/rustic/issues/1207))
|
||||
- Return exitcode ([#1220](https://github.com/rustic-rs/rustic/issues/1220))
|
||||
- "Incorrect Password" error is now only shown if password is really incorrect.
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
|
||||
- Group by now works as expected
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
|
||||
- A bug in `keep-tags` and `filter-tags` has been fixed.
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
|
||||
- Building OpenBSD platform target is now possible again
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update config profile readme
|
||||
([#1221](https://github.com/rustic-rs/rustic/issues/1221))
|
||||
|
||||
### Features
|
||||
|
||||
- Add autocompletion hints
|
||||
([#1225](https://github.com/rustic-rs/rustic/issues/1225))
|
||||
- Allow to modify filters
|
||||
([#1210](https://github.com/rustic-rs/rustic/issues/1210))
|
||||
- Allow to view text files
|
||||
([#1216](https://github.com/rustic-rs/rustic/issues/1216))
|
||||
|
||||
### Generated
|
||||
|
||||
- Updated Completions fixtures
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Bump quinn-proto from 0.11.6 to 0.11.8
|
||||
([#1223](https://github.com/rustic-rs/rustic/issues/1223))
|
||||
- Dependency updates ([#1227](https://github.com/rustic-rs/rustic/issues/1227))
|
||||
|
||||
## [0.8.0] - 2024-08-21
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add comments for owncloud and nextcloud dependent settings
|
||||
- Rename service examples
|
||||
- Ask for password in backup and copy command if it is missing
|
||||
([#1061](https://github.com/rustic-rs/rustic/issues/1061))
|
||||
- Ask for missing password in copy when initializing
|
||||
([#1063](https://github.com/rustic-rs/rustic/issues/1063))
|
||||
- Fix possible overflow in progress bar ETA
|
||||
([#1079](https://github.com/rustic-rs/rustic/issues/1079))
|
||||
- Correct b2.toml ([#1072](https://github.com/rustic-rs/rustic/issues/1072))
|
||||
- Show log filename if open/creation failed
|
||||
([#1111](https://github.com/rustic-rs/rustic/issues/1111))
|
||||
- [**breaking**] Multiple paths in config profile as array
|
||||
([#1124](https://github.com/rustic-rs/rustic/issues/1124))
|
||||
- Respect delete-protection when running forget with ids
|
||||
([#1149](https://github.com/rustic-rs/rustic/issues/1149))
|
||||
- Reset terminal no matter what
|
||||
([#1175](https://github.com/rustic-rs/rustic/issues/1175))
|
||||
- Allow missing fields in snapshot summary (to support restic 0.17.0)
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- Allow non-value/null xattr fields
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- Backup file if listing xattrs fails
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- limit memory usage for restore when having large pack files
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- prune: correct number of packs to repack
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
|
||||
### Features
|
||||
|
||||
- [**breaking**] Show-config now outputs toml
|
||||
([#1095](https://github.com/rustic-rs/rustic/issues/1095))
|
||||
- [**breaking**] Allow specifying many options in config profile without array
|
||||
([#1130](https://github.com/rustic-rs/rustic/issues/1130))
|
||||
- Add interactive snapshots mode
|
||||
([#1114](https://github.com/rustic-rs/rustic/issues/1114))
|
||||
- The find command has been added
|
||||
([#1136](https://github.com/rustic-rs/rustic/issues/1136))
|
||||
- Allow setting extra repository options via env variables
|
||||
([#1081](https://github.com/rustic-rs/rustic/issues/1081))
|
||||
- Add --check-index option
|
||||
([#1078](https://github.com/rustic-rs/rustic/issues/1078))
|
||||
- Add extra check before writing data and add --set-extra-check config option
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- Add append-only repository mode
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- forget: Enforce to have a --keep-* option and add --keep-none.
|
||||
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
|
||||
- Add s3_idrive config and move configs to a services subdirectory
|
||||
([#1048](https://github.com/rustic-rs/rustic/issues/1048))
|
||||
- Add example config for owncloud and nextcloud
|
||||
([#1052](https://github.com/rustic-rs/rustic/issues/1052))
|
||||
- Use human-panic to print better error messages in case rustic panics
|
||||
([#1065](https://github.com/rustic-rs/rustic/issues/1065))
|
||||
- Prune: Add more debug output
|
||||
([#1064](https://github.com/rustic-rs/rustic/issues/1064))
|
||||
- Add interactive ls mode
|
||||
([#1117](https://github.com/rustic-rs/rustic/issues/1117))
|
||||
- Add interactive restore
|
||||
([#1123](https://github.com/rustic-rs/rustic/issues/1123))
|
||||
- Interactive Ls: remember parent position
|
||||
([#1126](https://github.com/rustic-rs/rustic/issues/1126))
|
||||
- Use RFC3339 time format in logfile
|
||||
([#1133](https://github.com/rustic-rs/rustic/issues/1133))
|
||||
- Add possibility to change snapshot description
|
||||
([#1137](https://github.com/rustic-rs/rustic/issues/1137))
|
||||
- Interactive: Allow to delete snapshots
|
||||
([#1143](https://github.com/rustic-rs/rustic/issues/1143))
|
||||
- Interactive: Prompt before exiting
|
||||
([#1146](https://github.com/rustic-rs/rustic/issues/1146))
|
||||
- Document opendal options connections and throttle
|
||||
- Add better progress bars
|
||||
([#1152](https://github.com/rustic-rs/rustic/issues/1152))
|
||||
- Show diff statistics
|
||||
([#1178](https://github.com/rustic-rs/rustic/issues/1178))
|
||||
|
||||
### Documentaton
|
||||
|
||||
- Update configuration README
|
||||
([#1088](https://github.com/rustic-rs/rustic/issues/1088))
|
||||
- Fix typo in find.rs ([#1187](https://github.com/rustic-rs/rustic/issues/1187))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Fix cargo-binstall metadata
|
||||
- Move rustic_testing into rustic_core
|
||||
- Break old ci jobs when new commits are pushed so we don't fill up the queue
|
||||
- Bump mio from 0.8.10 to 0.8.11
|
||||
([#1089](https://github.com/rustic-rs/rustic/issues/1089))
|
||||
- Update deps and adapt to rustic_core changes
|
||||
- Bump h2 from 0.3.25 to 0.3.26
|
||||
([#1113](https://github.com/rustic-rs/rustic/issues/1113))
|
||||
- Bump rustls from 0.21.10 to 0.21.11
|
||||
([#1127](https://github.com/rustic-rs/rustic/issues/1127))
|
||||
- Update rustic_core and rustic_backend
|
||||
([#1201](https://github.com/rustic-rs/rustic/issues/1201))
|
||||
|
||||
### Testing
|
||||
|
||||
- Replace missing crates folder with src
|
||||
- Refactor integration tests to assert_cmd and predicates, test all configs in
|
||||
config subdirectory ([#1060](https://github.com/rustic-rs/rustic/issues/1060))
|
||||
|
||||
## [0.7.0] - 2024-02-03
|
||||
|
||||
### Packaging
|
||||
|
||||
- Enable RPM file build target
|
||||
([#951](https://github.com/rustic-rs/rustic/issues/951))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove unmaintained `actions-rs` ci actions
|
||||
- Remove unmaintained `actions-rs/cargo` ci action with cross.
|
||||
- Remove unmaintained `actions-rs/toolchain` ci action
|
||||
- Log config file logs after reading config files
|
||||
([#961](https://github.com/rustic-rs/rustic/issues/961))
|
||||
- Fix progress for copy command
|
||||
([#965](https://github.com/rustic-rs/rustic/issues/965))
|
||||
- Enable abscissa_core testing feature only for dev
|
||||
([#976](https://github.com/rustic-rs/rustic/issues/976))
|
||||
- Update github action to download artifacts, as upload/download actions from
|
||||
nightly workflow were incompatible with each other
|
||||
- Update rust crate duct to 0.13.7
|
||||
([#991](https://github.com/rustic-rs/rustic/issues/991))
|
||||
- Update rust crate libc to 0.2.151
|
||||
([#992](https://github.com/rustic-rs/rustic/issues/992))
|
||||
- Diff: Add local: to path syntax
|
||||
([#1000](https://github.com/rustic-rs/rustic/issues/1000))
|
||||
- Update rust crate libc to 0.2.152
|
||||
([#1016](https://github.com/rustic-rs/rustic/issues/1016))
|
||||
- Error handling when entering passwords
|
||||
([#963](https://github.com/rustic-rs/rustic/issues/963))
|
||||
- Use hyphen in cli api for numeric-uid-gid
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update changelog
|
||||
- Fix new lines in changelog
|
||||
- Update changelog
|
||||
|
||||
### Features
|
||||
|
||||
- Add --quiet option to backup and forget
|
||||
([#964](https://github.com/rustic-rs/rustic/issues/964))
|
||||
- Allow building without self-update feature
|
||||
([#975](https://github.com/rustic-rs/rustic/issues/975))
|
||||
- Add option --numeric-uid-gid to ls
|
||||
([#1019](https://github.com/rustic-rs/rustic/issues/1019))
|
||||
- Add colors to help texts
|
||||
([#1007](https://github.com/rustic-rs/rustic/issues/1007))
|
||||
- Add webdav command ([#1024](https://github.com/rustic-rs/rustic/issues/1024))
|
||||
|
||||
### Generated
|
||||
|
||||
- Updated Completions fixtures
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- Run actions that need secrets.GITHUB_TOKEN only on rustic-rs org
|
||||
- Update dtolnay/rust-toolchain
|
||||
- Update taiki-e/install-action
|
||||
- Update rustsec/audit-check
|
||||
- Netbsd nightly builds fail due to missing execinfo, so we don't build on it
|
||||
for now
|
||||
- Upgrade dprint config
|
||||
- Activate automerge for github action digest update
|
||||
- Activate automerge for github action digest update
|
||||
- Automerge lockfile maintenance
|
||||
- Try to fix nightly build
|
||||
- Display structure of downloaded artifact files
|
||||
- Display structure of downloaded artifact files II
|
||||
- Release
|
||||
- Do not run twice on release branches
|
||||
- Remove release workflow and fix release continuous deployment
|
||||
- Run on tag push
|
||||
- Add release candidates to CD
|
||||
- Remove conditional for checking tags
|
||||
- Fix path for release files for CD
|
||||
- Fix path for release files for CD, second approach with full file name
|
||||
- Fix binstall pkg-url
|
||||
- Use tag version in directory names for automation to download new versions
|
||||
- Set `max-parallel` to 1 for build matrix
|
||||
- Replace max-parallel with an own job
|
||||
|
||||
### Refactor
|
||||
|
||||
- Adjust to changes in rustic_core for added rustic_backend
|
||||
([#966](https://github.com/rustic-rs/rustic/issues/966))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add missing powershell profile to completions test
|
||||
|
||||
### Build
|
||||
|
||||
- Bump zerocopy from 0.7.25 to 0.7.31
|
||||
([#967](https://github.com/rustic-rs/rustic/issues/967))
|
||||
- Bump h2 from 0.3.22 to 0.3.24
|
||||
([#1009](https://github.com/rustic-rs/rustic/issues/1009))
|
||||
|
||||
### Diff
|
||||
|
||||
- Improve code (better lifetime handling)
|
||||
|
||||
### Ls
|
||||
|
||||
- Add alternative option name --numeric-id
|
||||
|
||||
## [0.6.0] - 2023-10-23
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
3829
Cargo.lock
generated
3829
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
179
Cargo.toml
179
Cargo.toml
@ -1,40 +1,35 @@
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/rustic-rs/rustic"
|
||||
homepage = "https://rustic.cli.rs/"
|
||||
keywords = ["backup", "restic", "deduplication", "encryption", "cli"]
|
||||
[package]
|
||||
name = "rustic-rs"
|
||||
version = "0.9.4"
|
||||
authors = ["the rustic-rs team"]
|
||||
categories = ["command-line-utilities"]
|
||||
documentation = "https://docs.rs/rustic-rs"
|
||||
edition = "2021"
|
||||
homepage = "https://rustic.cli.rs/"
|
||||
include = ["src/**/*", "LICENSE-*", "README.md", "config/**/*"]
|
||||
keywords = ["backup", "restic", "deduplication", "encryption", "cli"]
|
||||
license = "Apache-2.0 OR MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/rustic-rs/rustic"
|
||||
resolver = "2"
|
||||
rust-version = "1.80.0"
|
||||
description = """
|
||||
rustic - fast, encrypted, deduplicated backups powered by Rust
|
||||
"""
|
||||
|
||||
[package]
|
||||
name = "rustic-rs"
|
||||
version = { workspace = true }
|
||||
authors = ["Alexander Weiss"]
|
||||
categories = { workspace = true }
|
||||
documentation = "https://docs.rs/rustic-rs"
|
||||
edition = { workspace = true }
|
||||
homepage = { workspace = true }
|
||||
include = ["src/**/*", "LICENSE-*", "README.md", "config/**/*"]
|
||||
keywords = { workspace = true }
|
||||
license = { workspace = true }
|
||||
readme = "README.md"
|
||||
repository = { workspace = true }
|
||||
resolver = "2"
|
||||
rust-version = "1.70.0"
|
||||
description = { workspace = true }
|
||||
|
||||
[workspace]
|
||||
members = ["crates/rustic_testing", "xtask"]
|
||||
|
||||
[features]
|
||||
default = ["self-update"]
|
||||
default = ["tui", "webdav"]
|
||||
release = ["default", "self-update"]
|
||||
|
||||
# Allocators
|
||||
mimalloc = ["dep:mimalloc"]
|
||||
jemallocator = ["dep:jemallocator-global"]
|
||||
|
||||
# Commands
|
||||
self-update = ["dep:self_update", "dep:semver"]
|
||||
tui = ["dep:ratatui", "dep:crossterm", "dep:tui-textarea"]
|
||||
webdav = ["dep:dav-server", "dep:warp", "dep:tokio", "rustic_core/webdav"]
|
||||
mount = ["dep:fuse_mt"]
|
||||
|
||||
[[bin]]
|
||||
name = "rustic"
|
||||
@ -51,109 +46,91 @@ all-features = true
|
||||
rustdoc-args = ["--document-private-items", "--generate-link-to-definition"]
|
||||
|
||||
[dependencies]
|
||||
abscissa_core = { workspace = true }
|
||||
rustic_core = { workspace = true }
|
||||
abscissa_core = { version = "0.8.1", default-features = false, features = ["application"] }
|
||||
rustic_backend = { version = "0.4.2", features = ["cli"] }
|
||||
rustic_core = { version = "0.5.5", features = ["cli"] }
|
||||
|
||||
# errors
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# logging
|
||||
log = { workspace = true }
|
||||
|
||||
# serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_with = { workspace = true }
|
||||
|
||||
# other dependencies
|
||||
chrono = { workspace = true }
|
||||
self_update = { workspace = true, optional = true }
|
||||
semver = { workspace = true, optional = true }
|
||||
|
||||
# commands
|
||||
clap = { workspace = true }
|
||||
clap_complete = { workspace = true }
|
||||
merge = { workspace = true }
|
||||
|
||||
bytesize = { workspace = true }
|
||||
comfy-table = { workspace = true }
|
||||
dialoguer = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
gethostname = { workspace = true }
|
||||
humantime = { workspace = true }
|
||||
indicatif = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
# allocators
|
||||
jemallocator-global = { version = "0.3.2", optional = true }
|
||||
mimalloc = { version = "0.1.39", default_features = false, optional = true }
|
||||
rhai = { workspace = true }
|
||||
simplelog = { workspace = true }
|
||||
mimalloc = { version = "0.1.43", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
abscissa_core = { workspace = true, features = ["testing"] }
|
||||
aho-corasick = { workspace = true }
|
||||
dircmp = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
rustic_testing = { path = "crates/rustic_testing" }
|
||||
tempfile = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
# webdav
|
||||
dav-server = { version = "0.7.0", default-features = false, features = ["warp-compat"], optional = true }
|
||||
tokio = { version = "1", optional = true }
|
||||
warp = { version = "0.3.7", optional = true }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.150"
|
||||
[workspace.dependencies]
|
||||
rustic_core = { version = "0.1.2", features = ["cli"] }
|
||||
abscissa_core = { version = "0.7.0", default-features = false, features = ["application"] }
|
||||
# tui
|
||||
crossterm = { version = "0.28", optional = true }
|
||||
ratatui = { version = "0.29.0", optional = true }
|
||||
tui-textarea = { version = "0.7.0", optional = true }
|
||||
|
||||
# logging
|
||||
log = "0.4"
|
||||
|
||||
# errors
|
||||
displaydoc = "0.2.4"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
displaydoc = "0.2.5"
|
||||
thiserror = "1"
|
||||
|
||||
# serialization
|
||||
serde = { version = "1", features = ["serde_derive"] }
|
||||
serde_with = { version = "3.4", features = ["base64"] }
|
||||
serde_json = "1"
|
||||
serde_with = { version = "3", features = ["base64"] }
|
||||
|
||||
# other dependencies
|
||||
aho-corasick = "1.1.2"
|
||||
aho-corasick = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
rhai = { version = "1.16", features = ["sync", "serde", "no_optimize", "no_module", "no_custom_syntax", "only_i64"] }
|
||||
semver = "1"
|
||||
comfy-table = "7"
|
||||
rhai = { version = "1", features = ["sync", "serde", "no_optimize", "no_module", "no_custom_syntax", "only_i64"] }
|
||||
scopeguard = "1"
|
||||
semver = { version = "1", optional = true }
|
||||
simplelog = "0.12"
|
||||
comfy-table = "7.1.0"
|
||||
|
||||
# commands
|
||||
merge = "0.1"
|
||||
directories = "5"
|
||||
dialoguer = "0.11.0"
|
||||
indicatif = "0.17"
|
||||
gethostname = "0.4"
|
||||
bytesize = "1"
|
||||
itertools = "0.12"
|
||||
humantime = "2"
|
||||
clap_complete = "4"
|
||||
cached = "0.53.1"
|
||||
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
|
||||
once_cell = "1.18"
|
||||
self_update = { version = "0.39", default-features = false, features = ["rustls", "archive-tar", "compression-flate2"] }
|
||||
clap_complete = "4"
|
||||
conflate = "0.2"
|
||||
convert_case = "0.6.0"
|
||||
dateparser = "0.2.1"
|
||||
derive_more = { version = "1", features = ["debug"] }
|
||||
dialoguer = "0.11.0"
|
||||
directories = "5"
|
||||
fuse_mt = { version = "0.6", optional = true }
|
||||
gethostname = "0.5"
|
||||
globset = "0.4.15"
|
||||
human-panic = "2"
|
||||
humantime = "2"
|
||||
indicatif = "0.17"
|
||||
itertools = "0.13"
|
||||
open = "5.3.0"
|
||||
self_update = { version = "0.39.0", default-features = false, optional = true, features = ["rustls", "archive-tar", "compression-flate2"] } # FIXME: Downgraded to 0.39.0 due to https://github.com/jaemk/self_update/issues/136
|
||||
tar = "0.4.42"
|
||||
toml = "0.8"
|
||||
|
||||
# dev dependencies
|
||||
rstest = "0.18"
|
||||
[dev-dependencies]
|
||||
abscissa_core = { version = "0.8.1", default-features = false, features = ["testing"] }
|
||||
assert_cmd = "2.0.16"
|
||||
dircmp = "0.2"
|
||||
insta = { version = "1.40.0", features = ["ron"] }
|
||||
predicates = "3.1.2"
|
||||
pretty_assertions = "1.4"
|
||||
quickcheck = "1"
|
||||
quickcheck_macros = "1"
|
||||
tempfile = "3.8"
|
||||
pretty_assertions = "1.4"
|
||||
rstest = "0.23"
|
||||
rustic_testing = "0.2.3"
|
||||
tempfile = "3.13"
|
||||
toml = "0.8"
|
||||
dircmp = "0.2"
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.159"
|
||||
|
||||
# cargo-binstall support
|
||||
# https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SUPPORT.md
|
||||
[package.metadata.binstall]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ repo }-v{ version }-{ target }{ archive-suffix }"
|
||||
bin-dir = "{ bin }-{ target }/{ bin }{ binary-ext }"
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ bin }-v{ version }-{ target }{ archive-suffix }"
|
||||
bin-dir = "{ bin }{ binary-ext }"
|
||||
pkg-fmt = "tgz"
|
||||
|
||||
[package.metadata.binstall.signing]
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@ -1,23 +1,18 @@
|
||||
# Improve build speed with cached deps
|
||||
ARG RUST_VERSION=1.70.0
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
|
||||
WORKDIR /app
|
||||
FROM alpine AS builder
|
||||
ARG RUSTIC_VERSION
|
||||
ARG TARGETARCH
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
ASSET="rustic-${RUSTIC_VERSION}-x86_64-unknown-linux-musl.tar.gz";\
|
||||
elif [ "$TARGETARCH" = "arm64" ]; then \
|
||||
ASSET="rustic-${RUSTIC_VERSION}-aarch64-unknown-linux-musl.tar.gz"; \
|
||||
fi; \
|
||||
wget https://github.com/rustic-rs/rustic/releases/download/${RUSTIC_VERSION}/${ASSET} && \
|
||||
tar -xzf ${ASSET} && \
|
||||
mkdir /etc_files && \
|
||||
touch /etc_files/passwd && \
|
||||
touch /etc_files/group
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
# Build dependencies - this is the caching Docker layer!
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
# Build application
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
# why we dont use alpine for base image - https://andygrove.io/2020/05/why-musl-extremely-slow/
|
||||
FROM debian:bookworm-slim as runtime
|
||||
|
||||
COPY --from=builder /app/target/release/rustic /usr/local/bin
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rustic"]
|
||||
FROM scratch
|
||||
COPY --from=builder /rustic /
|
||||
COPY --from=builder /etc_files/ /etc/
|
||||
ENTRYPOINT ["/rustic"]
|
||||
|
||||
66
ECOSYSTEM.md
Normal file
66
ECOSYSTEM.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Ecosystem
|
||||
|
||||
## Crates
|
||||
|
||||
### rustic_backend - [Link](https://crates.io/crates/rustic_backend)
|
||||
|
||||
A library for supporting various backends in `rustic` and `rustic_core`.
|
||||
|
||||
### rustic_core - [Link](https://crates.io/crates/rustic_core)
|
||||
|
||||
Core functionality for the `rustic` ecosystem. Can be found
|
||||
[here](https://github.com/rustic-rs/rustic_core).
|
||||
|
||||
### rustic_scheduler - [Link](https://crates.io/crates/rustic_scheduler)
|
||||
|
||||
Scheduling functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_server - [Link](https://crates.io/crates/rustic_server)
|
||||
|
||||
A possible server implementation for `rustic` to support multiple clients when
|
||||
backing up.
|
||||
|
||||
### rustic_testing (not published) - [Link](https://github.com/rustic-rs/rustic_core/tree/main/crates/testing)
|
||||
|
||||
Testing functionality for the `rustic` ecosystem.
|
||||
|
||||
<!-- ### rustic_bench (reserved) - [Link](https://crates.io/crates/rustic_bench)
|
||||
|
||||
Benchmarking functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_auth (reserved) - [Link](https://crates.io/crates/rustic_auth)
|
||||
|
||||
Authentication functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_ui (reserved) - [Link](https://crates.io/crates/rustic_ui)
|
||||
|
||||
General UI functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_gui (reserved) - [Link](https://crates.io/crates/rustic_gui)
|
||||
|
||||
Graphical UI for `rustic`. -->
|
||||
|
||||
<!-- ### rustic_tui (reserved) - [Link](https://crates.io/crates/rustic_tui)
|
||||
|
||||
Terminal UI for `rustic`. -->
|
||||
|
||||
<!-- ### rustic_cli (reserved) - [Link](https://crates.io/crates/rustic_cli)
|
||||
|
||||
Common used CLI functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_web (reserved) - [Link](https://crates.io/crates/rustic_web)
|
||||
|
||||
Possible WASM/WASI functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_store (reserved) - [Link](https://crates.io/crates/rustic_store)
|
||||
|
||||
Possible store functionality to support a plugin system for the `rustic`
|
||||
ecosystem. -->
|
||||
|
||||
<!-- ### rustic_plugins (reserved) - [Link](https://crates.io/crates/rustic_plugins)
|
||||
|
||||
Plugin functionality for the `rustic` ecosystem. -->
|
||||
|
||||
<!-- ### rustic_daemon (reserved) - [Link](https://crates.io/crates/rustic_daemon)
|
||||
|
||||
A daemon for `rustic` and running it as a service. -->
|
||||
15
README.md
15
README.md
@ -9,6 +9,7 @@
|
||||
<a href="https://raw.githubusercontent.com/rustic-rs/rustic/main/"><img src="https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg" /></a>
|
||||
<a href="https://crates.io/crates/rustic-rs"><img src="https://img.shields.io/crates/d/rustic-rs.svg" /></a>
|
||||
<a href="https://github.com/rustic-rs/rustic/actions/workflows/nightly.yml"><img src="https://github.com/rustic-rs/rustic/actions/workflows/nightly.yml/badge.svg" /></a>
|
||||
<a href="https://github.com/rustic-rs/rustic/actions/workflows/release-image.yml"><img src="https://github.com/rustic-rs/rustic/actions/workflows/release-image.yml/badge.svg" /></a>
|
||||
<p>
|
||||
|
||||
## About
|
||||
@ -35,8 +36,8 @@ The `rustic` project is split into multiple crates:
|
||||
|
||||
- [rustic](https://crates.io/crates/rustic-rs) - the main binary
|
||||
- [rustic-core](https://crates.io/crates/rustic_core) - the core library
|
||||
|
||||
<!-- - [rustic-testing](https://crates.io/crates/rustic_testing) - testing utilities -->
|
||||
- [rustic-backend](https://crates.io/crates/rustic_backend) - the library for
|
||||
supporting various backends
|
||||
|
||||
## Features
|
||||
|
||||
@ -97,6 +98,12 @@ Or you can check out the
|
||||
Nightly binaries are available
|
||||
[here](https://rustic.cli.rs/docs/nightly_builds.html).
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rustic-rs/rustic
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
**Beware**: This installs the latest development version, which might be
|
||||
@ -109,7 +116,7 @@ cargo install --git https://github.com/rustic-rs/rustic.git rustic-rs
|
||||
### crates.io
|
||||
|
||||
```bash
|
||||
cargo install rustic-rs
|
||||
cargo install --locked rustic-rs
|
||||
```
|
||||
|
||||
## Differences to `restic`?
|
||||
@ -136,7 +143,7 @@ Please make sure, that you read the
|
||||
|
||||
## Minimum Rust version policy
|
||||
|
||||
This crate's minimum supported `rustc` version is `1.70.0`.
|
||||
This crate's minimum supported `rustc` version is `1.80.0`.
|
||||
|
||||
The current policy is that the minimum Rust version required to use this crate
|
||||
can be increased in minor version updates. For example, if `crate 1.0` requires
|
||||
|
||||
40
build-dependencies.just
Normal file
40
build-dependencies.just
Normal file
@ -0,0 +1,40 @@
|
||||
### DEFAULT ###
|
||||
|
||||
# Install dependencies for the default feature on x86_64-unknown-linux-musl
|
||||
install-default-x86_64-unknown-linux-musl:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
|
||||
# Install dependencies for the default feature on aarch64-unknown-linux-musl
|
||||
install-default-aarch64-unknown-linux-musl:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y musl-tools
|
||||
|
||||
### MOUNT ###
|
||||
|
||||
# Install dependencies for the mount feature on x86_64-unknown-linux-gnu
|
||||
install-mount-x86_64-unknown-linux-gnu:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
|
||||
|
||||
# Install dependencies for the mount feature on aarch64-unknown-linux-gnu
|
||||
install-mount-aarch64-unknown-linux-gnu:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
|
||||
|
||||
# Install dependencies for the mount feature on i686-unknown-linux-gnu
|
||||
install-mount-i686-unknown-linux-gnu:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
|
||||
|
||||
# Install dependencies for the mount feature on x86_64-apple-darwin
|
||||
install-mount-x86_64-apple-darwin:
|
||||
brew install macfuse
|
||||
|
||||
# Install dependencies for the mount feature on aarch64-apple-darwin
|
||||
install-mount-aarch64-apple-darwin:
|
||||
brew install macfuse
|
||||
|
||||
# Install dependencies for the mount feature on x86_64-pc-windows-msvc
|
||||
install-mount-x86_64-pc-windows-msvc:
|
||||
winget install winfsp
|
||||
338
config/README.md
338
config/README.md
@ -2,37 +2,57 @@
|
||||
<img src="https://raw.githubusercontent.com/rustic-rs/assets/main/logos/readme_header_config.png" height="400" />
|
||||
</p>
|
||||
|
||||
# Rustic Configuration Specification
|
||||
# rustic Configuration Specification
|
||||
|
||||
`rustic` is a backup tool that allows users to define their backup options using
|
||||
a TOML configuration file. The configuration file consists of various sections
|
||||
`rustic` is a backup tool that allows users to define their backup options in
|
||||
profiles using TOML files. A configuration profile consists of various sections
|
||||
and attributes that control the behavior of `rustic` for different commands and
|
||||
sources.
|
||||
|
||||
This specification covers all the available sections and attributes in the
|
||||
`rustic` configuration file and includes their corresponding environment
|
||||
`rustic` configuration profile file and includes their corresponding environment
|
||||
variable names. Users can customize their backup behavior by modifying these
|
||||
attributes according to their needs.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Merge Precedence](#merge-precedence)
|
||||
- [Profiles](#profiles)
|
||||
- [Sections and Attributes](#sections-and-attributes)
|
||||
- [Global Options `[global]`](#global-options-global)
|
||||
- [Global Hooks `[global.hooks]`](#global-hooks-globalhooks)
|
||||
- [Global Options - env variables `[global.env]`](#global-options---env-variables-globalenv)
|
||||
- [Repository Options `[repository]`](#repository-options-repository)
|
||||
- [Repository Options (Additional) `[repository.options]`](#repository-options-additional-repositoryoptions)
|
||||
- [Repository Options for cold repo (Additional) `[repository.options-cold]`](#repository-options-for-cold-repo-additional-repositoryoptions-cold)
|
||||
- [Repository Options for hot repo (Additional) `[repository.options-hot]`](#repository-options-for-hot-repo-additional-repositoryoptions-hot)
|
||||
- [Repository Hooks `[repository.hooks]`](#repository-hooks-repositoryhooks)
|
||||
- [Snapshot-Filter Options `[snapshot-filter]`](#snapshot-filter-options-snapshot-filter)
|
||||
- [Backup Options `[backup]`](#backup-options-backup)
|
||||
- [Backup Hooks `[backup.hooks]`](#backup-hooks-backuphooks)
|
||||
- [Backup Snapshots `[[backup.snapshots]]`](#backup-snapshots-backupsnapshots)
|
||||
- [Forget Options `[forget]`](#forget-options-forget)
|
||||
- [Copy Targets `[copy]`](#copy-targets-copy)
|
||||
- [WebDAV Options `[webdav]`](#webdav-options-webdav)
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
The merge precedence for values is:
|
||||
|
||||
Commandline Arguments >> Environment Variables >> Configuration File
|
||||
Commandline Arguments >> Environment Variables >> Configuration Profile
|
||||
|
||||
Values parsed from the `configuration file` can be overwritten by
|
||||
Values parsed from the `configuration profile` can be overwritten by
|
||||
`environment variables`, which can be overwritten by `commandline arguments`
|
||||
options. Therefore `commandline arguments` have the highest precedence.
|
||||
|
||||
**NOTE**: There are the following restrictions:
|
||||
|
||||
- Not all options are available as environment variables or commandline
|
||||
arguments. There are also commandline options which cannot be set in the
|
||||
profile TOML files.
|
||||
- You can overwrite values, but for most values, you cannot "unset" them on a
|
||||
higher priority level.
|
||||
|
||||
- For some integer values, you cannot even overwrite with the value `0`, e.g.
|
||||
`keep-weekly = 5` in the `[forget]` section of the config file cannot be
|
||||
overwritten by `--keep-weekly 0`.
|
||||
|
||||
## Profiles
|
||||
|
||||
Configuration files can be placed in the user's local config directory, e.g.
|
||||
@ -41,136 +61,234 @@ use different config files, e.g. `myconfig.toml` and use the `-P` option to
|
||||
specify the profile name, e.g. `rustic -P myconfig`. Examples for different
|
||||
configuration files can be found here in the [/config/](/config) directory.
|
||||
|
||||
## Services
|
||||
|
||||
We have collected some examples how to configure `rustic` for various services
|
||||
in the [services/](/config/services/) subdirectory. Please note that these
|
||||
examples are not complete and may not work out of the box. They are intended to
|
||||
give you a starting point for your own configuration.
|
||||
|
||||
If you want to contribute your own configuration, please
|
||||
[open a pull request](https://rustic.cli.rs/dev-docs/contributing-to-rustic.html#submitting-pull-requests).
|
||||
|
||||
## Sections and Attributes
|
||||
|
||||
### Global Options
|
||||
### Global Options `[global]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | Environment Variable |
|
||||
| ----------------- | --------------------------------------------------------------------------------- | ------------- | ----------------- | ------------------------ |
|
||||
| dry-run | If true, performs a dry run without making any changes. | false | | RUSTIC_DRY_RUN |
|
||||
| log-level | Logging level. Possible values: "off", "error", "warn", "info", "debug", "trace". | "info" | | RUSTIC_LOG_LEVEL |
|
||||
| log-file | Path to the log file. | No log file | "/log/rustic.log" | RUSTIC_LOG_FILE |
|
||||
| no-progress | If true, disables progress indicators. | false | | RUSTIC_NO_PROGRESS |
|
||||
| progress-interval | The interval at which progress indicators are shown. | "100ms" | "1m" | RUSTIC_PROGRESS_INTERVAL |
|
||||
| use-profile | An array of profiles to use. | Empty array | | RUSTIC_USE_PROFILE |
|
||||
| Attribute | Description | Default Value | Example Value | Environment Variable | CLI Option |
|
||||
| ----------------- | --------------------------------------------------------------------------------- | ------------- | ----------------- | ------------------------ | ------------------- |
|
||||
| check-index | If true, check the index and read pack headers if index information is missing. | false | | RUSTIC_CHECK_INDEX | --check-index |
|
||||
| dry-run | If true, performs a dry run without making any changes. | false | | RUSTIC_DRY_RUN | --dry-run, -n |
|
||||
| log-level | Logging level. Possible values: "off", "error", "warn", "info", "debug", "trace". | "info" | | RUSTIC_LOG_LEVEL | --log-level |
|
||||
| log-file | Path to the log file. | No log file | "/log/rustic.log" | RUSTIC_LOG_FILE | --log-file |
|
||||
| no-progress | If true, disables progress indicators. | false | | RUSTIC_NO_PROGRESS | --no-progress |
|
||||
| progress-interval | The interval at which progress indicators are shown. | "100ms" | "1m" | RUSTIC_PROGRESS_INTERVAL | --progress-interval |
|
||||
| use-profiles | Array of profiles to use. Allows to recursively use other profiles. | Empty array | ["2nd", "3rd"] | RUSTIC_USE_PROFILE | --use-profile, -P |
|
||||
|
||||
### Global Options - env variables
|
||||
### Global Hooks `[global.hooks]`
|
||||
|
||||
These external commands are run before and after each commands, respectively.
|
||||
|
||||
**Note**: There are also repository hooks, which should be used for commands
|
||||
needed to set up the repository (like mounting the repo dir), see below.
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | Environment Variable |
|
||||
| ----------- | ------------------------------------------------- | ------------- | ------------- | -------------------- |
|
||||
| run-before | Run the given commands before execution | not set | ["echo test"] | |
|
||||
| run-after | Run the given commands after successful execution | not set | ["echo test"] | |
|
||||
| run-failed | Run the given commands after failed execution | not set | ["echo test"] | |
|
||||
| run-finally | Run the given commands after every execution | not set | ["echo test"] | |
|
||||
|
||||
### Global Options - env variables `[global.env]`
|
||||
|
||||
All given environment variables are set before processing. This is handy to
|
||||
configure e.g. the `rclone`-backend or some commands which will be called by
|
||||
rustic.
|
||||
|
||||
**Important**: Please do not forget to include environment variables set in the
|
||||
config file as a possible source of errors if you encounter problems. They could
|
||||
possibly shadow other values that you have already set.
|
||||
config profile as a possible source of errors if you encounter problems. They
|
||||
could possibly shadow other values that you have already set.
|
||||
|
||||
### Repository Options
|
||||
### Repository Options `[repository]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | Environment Variable |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------ | ---------------------- | ----------------------- |
|
||||
| cache-dir | Path to the cache directory. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ | RUSTIC_CACHE_DIR |
|
||||
| no-cache | If true, disables caching. | false | | RUSTIC_NO_CACHE |
|
||||
| repository | The path to the repository. Required. | Not set | "/tmp/rustic" | RUSTIC_REPOSITORY |
|
||||
| repo-hot | The path to the hot repository. | Not set | | RUSTIC_REPO_HOT |
|
||||
| password | The password for the repository. | Not set | "mySecretPassword" | RUSTIC_PASSWORD |
|
||||
| password-file | Path to a file containing the password for the repository. | Not set | | RUSTIC_PASSWORD_FILE |
|
||||
| password-command | Command to retrieve the password for the repository. | Not set | | RUSTIC_PASSWORD_COMMAND |
|
||||
| warm-up | If true, warms up the repository by file access. | false | | |
|
||||
| warm-up-command | Command to warm up the repository. | Not set | | |
|
||||
| warm-up-wait | The wait time for warming up the repository. | Not set | | |
|
||||
| Attribute | Description | Default Value | Example Value | Environment Variable | CLI Option |
|
||||
| ---------------- | ---------------------------------------------------------- | ------------------------ | ---------------------- | ----------------------- | ------------------- |
|
||||
| cache-dir | Path to the cache directory. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ | RUSTIC_CACHE_DIR | --cache-dir |
|
||||
| no-cache | If true, disables caching. | false | | RUSTIC_NO_CACHE | --no-cache |
|
||||
| repository | The path to the repository. Required. | Not set | "/tmp/rustic" | RUSTIC_REPOSITORY | --repositoy, -r |
|
||||
| repo-hot | The path to the hot repository. | Not set | | RUSTIC_REPO_HOT | --repo-hot |
|
||||
| password | The password for the repository. | Not set | "mySecretPassword" | RUSTIC_PASSWORD | --password |
|
||||
| password-file | Path to a file containing the password for the repository. | Not set | | RUSTIC_PASSWORD_FILE | --password-file, -p |
|
||||
| password-command | Command to retrieve the password for the repository. | Not set | | RUSTIC_PASSWORD_COMMAND | --password-command |
|
||||
| warm-up | If true, warms up the repository by file access. | false | | | ---warm-up |
|
||||
| warm-up-command | Command to warm up the repository. | Not set | | | --warm-up-command |
|
||||
| warm-up-wait | The wait time for warming up the repository. | Not set | | | --warm-up-wait |
|
||||
|
||||
### Repository Options (Additional)
|
||||
### Repository Options (Additional) `[repository.options]`
|
||||
|
||||
Additional repository options - depending on backend. These can be only set in
|
||||
the config file or using env variables. For env variables use upper snake case
|
||||
and prefix with "RUSTIC_REPO_OPT_", e.g. `use-password = "true"` becomes
|
||||
`RUSTIC_REPO_OPT_USE_PASSWORD=true`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| ------------------- | ------------------------------------------------------------------ | ------------- | ------------------------------ |
|
||||
| post-create-command | Command to execute after creating a snapshot in the local backend. | Not set | "par2create -qq -n1 -r5 %file" |
|
||||
| post-delete-command | Command to execute after deleting a snapshot in the local backend. | Not set | "sh -c \"rm -f %file*.par2\"" |
|
||||
|
||||
### Snapshot-Filter Options
|
||||
### Repository Options for cold repo (Additional) `[repository.options-cold]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| ------------ | ------------------------------------- | --------------- | ------------- |
|
||||
| filter-fn | Custom filter function for snapshots. | Not set | |
|
||||
| filter-host | Array of hosts to filter snapshots. | Not set | ["myhost"] |
|
||||
| filter-label | Array of labels to filter snapshots. | No label filter | |
|
||||
| filter-paths | Array of paths to filter snapshots. | No paths filter | |
|
||||
| filter-tags | Array of tags to filter snapshots. | No tags filter | |
|
||||
Additional repository options for cold repository - depending on backend. These
|
||||
can be only set in the config file or using env variables. For env variables use
|
||||
upper snake case and prefix with "RUSTIC_REPO_OPTCOLD_".
|
||||
|
||||
### Backup Options
|
||||
### Repository Options for hot repo (Additional) `[repository.options-hot]`
|
||||
|
||||
**Note**: Some options are not source-specific, but if set here, they apply for
|
||||
all sources, although they can be overwritten in the source-specifc
|
||||
configuration.
|
||||
Additional repository options for hot repository - depending on backend. These
|
||||
can be only set in the config file or using env variables. For env variables use
|
||||
upper snake case and prefix with "RUSTIC_REPO_OPTHOT_".
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| ---------------- | --------------------------------------------------------- | ------------- | ------------- |
|
||||
| description | Description for the backup. | Not set | |
|
||||
| description-from | Path to a file containing the description for the backup. | Not set | |
|
||||
| delete-never | If true, never delete the backup. | false | |
|
||||
| delete-after | Time duration after which the backup will be deleted. | Not set | |
|
||||
see Repository Options
|
||||
|
||||
### Backup Sources
|
||||
### Repository Hooks `[repository.hooks]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| --------- | ------------------------------------ | ------------- | --------------------- |
|
||||
| source | Source directory or file to back up. | Not set | "/tmp/dir/to_backup/" |
|
||||
These external commands are run before and after each repository-accessing
|
||||
commands, respectively.
|
||||
|
||||
#### Source-specific options
|
||||
See [Global Hooks](#global-hooks-globalhooks).
|
||||
|
||||
**Note**: The following options can be specified for each source individually in
|
||||
the source-individual section, see below. If they are specified here, they
|
||||
provide default values for all sources but can still be overwritten in the
|
||||
source-individual section.
|
||||
### Snapshot-Filter Options `[snapshot-filter]`
|
||||
|
||||
| Attribute | Description | Default Value |
|
||||
| ------------------ | --------------------------------------------------------------------------------------- | ------------- |
|
||||
| as-path | Specifies the path for the backup when the source contains a single path. | Not set |
|
||||
| exclude-if-present | Array of filenames to exclude from the backup if they are present. | Not set |
|
||||
| force | If true, forces the backup even if no changes are detected. | Not set |
|
||||
| git-ignore | If true, use .gitignore rules to exclude files from the backup in the source directory. | true |
|
||||
| glob-file | Array of glob files specifying additional files to include in the backup. | Not set |
|
||||
| group-by | Grouping strategy for the backup. | Not set |
|
||||
| host | Host name for the backup. | Not set |
|
||||
| ignore-ctime | If true, ignores file change time (ctime) for the backup. | Not set |
|
||||
| ignore-inode | If true, ignores file inode for the backup. | Not set |
|
||||
| init | If true, initializes repository if it doesn't exist, yet. | Not set |
|
||||
| json | If true, returns output of the command as json. | Not set |
|
||||
| label | Label for the backup. | Not set |
|
||||
| one-file-system | If true, only backs up files from the same filesystem as the source. | Not set |
|
||||
| parent | Parent snapshot ID for the backup. | Not set |
|
||||
| stdin-filename | File name to be used when reading from stdin. | Not set |
|
||||
| tag | Array of tags for the backup. | Not set |
|
||||
| with-atime | If true, includes file access time (atime) in the backup. | Not set |
|
||||
| Attribute | Description | Default Value | Example Value | CLI Option |
|
||||
| ------------------ | ---------------------------------------------------------------------- | ------------- | ------------------------ | -------------------- |
|
||||
| filter-hosts | Array of hosts to filter snapshots. | Not set | ["myhost", "host2"] | --filter-host |
|
||||
| filter-labels | Array of labels to filter snapshots. | Not set | ["mylabal"] | --filter-label |
|
||||
| filter-paths | Array of pathlists to filter snapshots. | Not set | ["/home,/root"] | --filter-paths |
|
||||
| filter-paths-exact | Array or string of paths to filter snapshots. Exact match. | Not set | ["path1,path2", "path3"] | --filter-paths-exact |
|
||||
| filter-tags | Array of taglists to filter snapshots. | Not set | ["tag1,tag2"] | --filter-tags |
|
||||
| filter-tags-exact | Array or string of tags to filter snapshots. Exact match. | Not set | ["tag1,tag2", "tag3"] | --filter-tags-exact |
|
||||
| filter-before | Filter snapshots before the given date/time | Not set | "2024-01-01" | --filter-before |
|
||||
| filter-after | Filter snapshots after the given date/time | Not set | "2023-01-01 11:15:23" | --filter-after |
|
||||
| filter-size | Filter snapshots for a total size in the size range. | Not set | "1MB..1GB" | --filter-size |
|
||||
| | If a single value is given, this is taken as lower bound. | | "500 k" | |
|
||||
| filter-size-added | Filter snapshots for a size added to the repository in the size range. | Not set | "1MB..1GB" | --filter-size-added |
|
||||
| | If a single value is given, this is taken as lower bound. | | "500 k" | |
|
||||
| filter-fn | Custom filter function for snapshots. | Not set | | --filter-fn |
|
||||
|
||||
### Forget Options
|
||||
### Backup Options `[backup]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| ----------------- | ---------------------------------------------------------- | ------------- | -------------- |
|
||||
| filter-host | Array of hosts to filter snapshots. | Not set | ["forgethost"] |
|
||||
| keep-daily | Number of daily backups to keep. | Not set | |
|
||||
| keep-within-daily | The time duration within which daily backups will be kept. | Not set | "7 days" |
|
||||
| keep-hourly | Number of hourly backups to keep. | Not set | |
|
||||
| keep-monthly | Number of monthly backups to keep. | Not set | |
|
||||
| keep-weekly | Number of weekly backups to keep. | Not set | |
|
||||
| keep-yearly | Number of yearly backups to keep. | Not set | |
|
||||
| keep-tags | Array of tags to keep. | Not set | ["mytag"] |
|
||||
**Note**: If set here, the backup options apply for all sources, although they
|
||||
can be overwritten in the source-specific configuration, see below.
|
||||
|
||||
### Copy Targets
|
||||
| Attribute | Description | Default Value | Example Value | CLI Option |
|
||||
| --------------------- | --------------------------------------------------------------------------------------- | --------------------- | ------------- | ----------------------- |
|
||||
| as-path | Specifies the path for the backup when the source contains a single path. | Not set | | --as-path |
|
||||
| command | Set the command saved in the snapshot. | The full command used | | --command |
|
||||
| custom-ignorefiles | Array of names of custom ignorefiles which will be used to exclude files. | [] | | --custom-ignorefile |
|
||||
| description | Description for the snapshot. | Not set | | --description |
|
||||
| description-from | Path to a file containing the description for the snapshot. | Not set | | --description-from |
|
||||
| delete-never | If true, never delete the snapshot. | false | | --delete-never |
|
||||
| delete-after | Time duration after which the snapshot be deleted. | Not set | | --delete-after |
|
||||
| exclude-if-present | Array of filenames to exclude from the backup if they are present. | [] | | --exclude-if-present |
|
||||
| force | If true, forces the backup even if no changes are detected. | false | | --force |
|
||||
| git-ignore | If true, use .gitignore rules to exclude files from the backup in the source directory. | false | | --git-ignore |
|
||||
| globs | Array of globs specifying what to include/exclude in the backup. | [] | | --glob |
|
||||
| glob-files | Array or string of glob files specifying what to include/exclude in the backup. | [] | | --glob-file |
|
||||
| group-by | Grouping strategy to find parent snapshot. | "host,label,paths" | | --group-by |
|
||||
| host | Host name used in the snapshot. | local hostname | | --host |
|
||||
| iglobs | Like glob, but apply case-insensitive | [] | | --iglob |
|
||||
| iglob-files | Like glob-file, but apply case-insensitive | [] | | --iglob-file |
|
||||
| ignore-devid | If true, don't save device ID. | false | | --ignore-devid |
|
||||
| ignore-ctime | If true, ignore file change time (ctime). | false | | --ignore-ctime |
|
||||
| ignore-inode | If true, ignore file inode for the backup. | false | | --ignore-inode |
|
||||
| init | If true, initialize repository if it doesn't exist, yet. | false | | --init |
|
||||
| json | If true, returns output of the command as json. | false | | --json |
|
||||
| label | Set label fot the snapshot. | Not set | | --label |
|
||||
| no-require-git | (with git-ignore:) Apply .git-ignore files even if they are not in a git repository. | false | | --no-require-git |
|
||||
| no-scan | Don't scan the backup source for its size (disables ETA). | false | | --no-scan |
|
||||
| one-file-system | If true, only backs up files from the same filesystem as the source. | false | | --one-file-system |
|
||||
| parent | Parent snapshot ID for the backup. | Not set | | --parent |
|
||||
| quiet | Don't output backup summary. | false | | --quiet |
|
||||
| skip-identical-parent | Skip saving of the snapshot if it is identical to the parent. | false | | --skip-identical-parent |
|
||||
| stdin-filename | File name to be used when reading from stdin. | Not set | | --stdin-filename |
|
||||
| tags | Array of tags for the backup. | [] | | --tag |
|
||||
| time | Set the time saved in the snapshot. | current time | | --time |
|
||||
| with-atime | If true, includes file access time (atime) in the backup. | false | | --with-atime |
|
||||
|
||||
**Note**: Copy-targets are simply repositories with the same defaults as within
|
||||
the repository section.
|
||||
### Backup Hooks `[backup.hooks]`
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| ------------------- | ---------------------------------------------------------------------- | ------------------------ | ---------------------- |
|
||||
| cache-dir | Path to the cache directory for the target repository. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ |
|
||||
| no-cache | If true, disables caching for the target repository. | false | |
|
||||
| password | The password for the target repository. | Not set | |
|
||||
| password-file | Path to a file containing the password for the target repository. | Not set | |
|
||||
| password-command | Command to retrieve the password for the target repository. | Not set | |
|
||||
| post-create-command | Command to execute after creating a snapshot in the target repository. | Not set | |
|
||||
| post-delete-command | Command to execute after deleting a snapshot in the target repository. | Not set | |
|
||||
| repository | The path or URL to the target repository. | Not set | |
|
||||
| repo-hot | The path or URL to the hot target repository. | Not set | |
|
||||
| warm-up | If true, warms up the target repository by file access. | Not set | |
|
||||
| warm-up-command | Command to warm up the target repository. | Not set | |
|
||||
| warm-up-wait | The wait time for warming up the target repository. | Not set | |
|
||||
These external commands are run before and after each backup, respectively.
|
||||
|
||||
**Note**: Global hooks and repository hooks are run additionaly.
|
||||
|
||||
See [Global Hooks](#global-hooks-globalhooks).
|
||||
|
||||
### Backup Snapshots `[[backup.snapshots]]`
|
||||
|
||||
**Note**: All of the backup options mentioned before can also be used as
|
||||
snapshot-specific option and then only apply to this snapshot.
|
||||
|
||||
| Attribute | Description | Default Value | Example Value |
|
||||
| --------- | ------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
|
||||
| sources | Array of source directories or file(s) to back up. | [] | ["/dir1", "/dir2"] |
|
||||
| hooks | Hooks to run before and after backing up the defined sources. | Not set | { run-before = [], run-after = [], run-failed = [], run-finally = [] } |
|
||||
|
||||
Source-specific hooks are called additionally to global, repository and backup
|
||||
hooks when backing up the defined sources into a snapshot.
|
||||
|
||||
### Forget Options `[forget]`
|
||||
|
||||
**Note**: At lest on of the `keep-*` options must be given. Use
|
||||
`keep-none = true` if you want to remove all snapshots.
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | CLI Option |
|
||||
| -------------------------- | ----------------------------------------------------------------------- | ------------------ | ---------------------- | ---------------------------- |
|
||||
| group-by | Group snapshots by given criteria before applying keep policies. | "host,label,paths" | | --group-by |
|
||||
| keep-last | Number of most recent snapshots to keep. | Not set | 15 | --keep-last, -l |
|
||||
| keep-hourly, -H | Number of hourly snapshots to keep. | Not set | | --keep-hourly |
|
||||
| keep-daily, -d | Number of daily snapshots to keep. | Not set | 8 | --keep-daily |
|
||||
| keep-weekly, -w | Number of weekly snapshots to keep. | Not set | | --keep-weekly |
|
||||
| keep-monthly, -m | Number of monthly snapshots to keep. | Not set | | --keep-monthly |
|
||||
| keep-quarter-yearly | Number of quarter-yearly snapshots to keep. | Not set | | --keep-quarter-yearly |
|
||||
| keep-half-yearly | Number of half-yearly snapshots to keep. | Not set | | --keep-half-yearly |
|
||||
| keep-yearly, -y | Number of yearly snapshots to keep. | Not set | | --keep-yearly |
|
||||
| keep-within-hourly | The time duration within which hourly snapshots will be kept. | Not set | "1 day" | --keep-within-hourly |
|
||||
| keep-within-daily | The time duration within which daily snapshots will be kept. | Not set | "7 days" | --keep-within-daily |
|
||||
| keep-within-weekly | The time duration within which weekly snapshots will be kept. | Not set | | --keep-within-weekly |
|
||||
| keep-within-monthly | The time duration within which monthly snapshots will be kept. | Not set | | --keep-within-monthly |
|
||||
| keep-within-quarter-yearly | The time duration within which quarter-yearly snapshots will be kept. | Not set | | --keep-within-quarter-yearly |
|
||||
| keep-within-half-yearly | The time duration within which half-yearly snapshots will be kept. | Not set | | --keep-within-half-yearly |
|
||||
| keep-within-yearly | The time duration within which yearly snapshots will be kept. | Not set | | --keep-within-yearly |
|
||||
| keep-tags | Keep snapshots containing one of these taglists. | [] | ["keep", "important" ] | --keep-tags |
|
||||
| keep-ids | Keep snapshots containing one of these IDs. | [] | ["6e58f3d32" ] | --keep-id |
|
||||
| keep-none | Allow to keep no snapshots. | false | true | --keep-none |
|
||||
| prune | If set to true, prune the repository after snapshots have been removed. | false | | --prune |
|
||||
|
||||
Additionally extra snapshot filter options can be given for the `forget` command
|
||||
here, see Snapshot-Filter options.
|
||||
|
||||
### Copy Targets `[copy]`
|
||||
|
||||
**Note**: Copy-targets must be defined in their own config profile files.
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | CLI Option |
|
||||
| --------- | ------------------ | ------------- | ------------------------ | ---------- |
|
||||
| targets | Targets to copy to | [] | ["profile1", "profile2"] | --target |
|
||||
|
||||
### WebDAV Options `[webdav]`
|
||||
|
||||
`rustic` supports mounting snapshots via WebDAV. This is useful if you want to
|
||||
access your snapshots via a file manager.
|
||||
|
||||
**Note**: `https://` and Authentication are not supported yet.
|
||||
|
||||
The following options are available to be used in your configuration file:
|
||||
|
||||
| Attribute | Description | Default Value | Example Value | CLI Option |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------- | --------------- |
|
||||
| address | Address of the WebDAV server. | localhost:8000 | | --address |
|
||||
| path-template | The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. | `[{hostname}]/[{label}]/{time}` | | --path-template |
|
||||
| time-template | The time template to use to display times in the path template. See <https://docs.rs/chrono/latest/chrono/format/strftime/index.html> for format options. | `%Y-%m-%d_%H-%M-%S` | | --time-template |
|
||||
| symlinks | If true, follows symlinks. | false | | --symlinks |
|
||||
| file-access | How to handle access to files. | "forbidden" for hot/cold repositories, else "read" | | --file-access |
|
||||
| snapshot-path | Specify directly which snapshot/path to serve | Not set, this will generate a virtual tree with all snapshots using path-template | | --snapshot-path |
|
||||
|
||||
@ -8,14 +8,6 @@
|
||||
repository = "/tmp/repo"
|
||||
password = "test"
|
||||
|
||||
# you can specify multiple targets
|
||||
[[copy.targets]]
|
||||
repository = "/tmp/repo2"
|
||||
password = "test"
|
||||
no-cache = true
|
||||
|
||||
[[copy.targets]]
|
||||
repository = "rclone:ovh:backup"
|
||||
repo-hot = "clone:ovh:backup-hot"
|
||||
password-file = "/root/key-rustic-ovh"
|
||||
cache-dir = "/var/lib/cache/rustic" # explicitly specify cache dir for remote repository
|
||||
# you can specify multiple targets. Note that each target must be configured via a config profile file
|
||||
[copy]
|
||||
targets = ["full", "rustic"]
|
||||
|
||||
178
config/full.toml
178
config/full.toml
@ -8,12 +8,23 @@
|
||||
|
||||
# Global options: These options are used for all commands.
|
||||
[global]
|
||||
use-profile = []
|
||||
use-profiles = []
|
||||
log-level = "info" # any of "off", "error", "warn", "info", "debug", "trace"; default: "info"
|
||||
log-file = "/path/to/rustic.log" # Default: not set
|
||||
no-progress = false
|
||||
progress-interval = "100ms"
|
||||
dry-run = false
|
||||
check-index = false
|
||||
|
||||
# Global hooks: The given commands are called for every command
|
||||
[global.hooks]
|
||||
run-before = [
|
||||
# long form giving command and args explicitely and allow to specify failure behavior
|
||||
{ command = "echo", args = ["before"], on-failure = "warn" }, # allowed values for on-failure: "error" (default), "warn", "ignore"
|
||||
] # Default: []
|
||||
run-after = ["echo after"] # Run after if successful, short version, default: []
|
||||
run-failed = ["echo failed"] # Default: []
|
||||
run-finally = ["echo finally"] # Always run after, default: []
|
||||
|
||||
# Global env variables: These are set by rustic before calling a subcommand, e.g. rclone or commands
|
||||
# defined in the repository options.
|
||||
@ -36,26 +47,60 @@ warm-up = false
|
||||
warm-up-command = "warmup.sh %id" # Default: not set
|
||||
warm-up-wait = "10min" # Default: not set
|
||||
|
||||
# Additional repository options - depending on backend. These can be only set in the config file.
|
||||
# Additional repository options - depending on backend. These can be only set in the config file or using env variables.
|
||||
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPT_", e.g. `use-passwort = "true"` becomes
|
||||
# `RUSTIC_REPO_OPT_USE_PASSWORT=true`
|
||||
[repository.options]
|
||||
post-create-command = "par2create -qq -n1 -r5 %file" # Only local backend; Default: not set
|
||||
post-delete-command = "sh -c \"rm -f %file*.par2\"" # Only local backend; Default: not set
|
||||
retry = "default" # Only rest/rclone backend; Allowed values: "false"/"off", "default" or number of retries
|
||||
retry = "default" # Only rest/rclone/all opendal backends; Allowed values: "false"/"off", "default" or number of retries
|
||||
timeout = "10min" # Only rest/rclone backend
|
||||
rclone-command = "rclone serve restic --addr localhost:0" # Only rclone; Default: not set
|
||||
use-password = "true" # Only rclone
|
||||
rest-url = "http://localhost:8000" # Only rclone; Default: determine REST URL from rclone output
|
||||
connections = "20" # Only opendal backends; Default: Not set
|
||||
throttle = "10kB,10MB" # limit and burst per second; only opendal backends; Default: Not set
|
||||
# Note that opendal backends use several service-dependent options which may be specified here, see
|
||||
# https://opendal.apache.org/docs/rust/opendal/services/index.html
|
||||
|
||||
# Additional repository options for the hot part - depending on backend. These can be only set in the config file or
|
||||
# using env variables.
|
||||
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPTHOT_"
|
||||
[repository.options-hot]
|
||||
# see [repository.options]
|
||||
|
||||
# Additional repository options for the cold part - depending on backend. These can be only set in the config file or
|
||||
# using env variables.
|
||||
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPTCOLD_"
|
||||
[repository.options-cold]
|
||||
# see [repository.options]
|
||||
|
||||
# Repository hooks: The given commands are called for commands accessing the repository.
|
||||
[repository.hooks]
|
||||
run-before = ["echo before"] # Default: []
|
||||
run-after = ["echo after"] # Run after if successful, default: []
|
||||
run-failed = ["echo failed"] # Default: []
|
||||
run-finally = ["echo finally"] # Always run after, default: []
|
||||
|
||||
# Snapshot-filter options: These options apply to all commands that use snapshot filters
|
||||
[snapshot-filter]
|
||||
filter-host = ["host2", "host2"] # Default: no host filter
|
||||
filter-label = ["label1", "label2"] # Default: no label filter
|
||||
filter-tags = ["tag1,tag2", "tag3"] # Default: no tags filger
|
||||
filter-paths = ["path1", "path2,path3"] # Default: no paths filter
|
||||
filter-hosts = ["host1", "host2"] # Default: []
|
||||
filter-labels = ["label1", "label2"] # Default: []
|
||||
filter-tags = ["tag1,tag2", "tag3"] # Default: []
|
||||
filter-tags-exact = ["tag1,tag2", "tag2"] # Default: []
|
||||
filter-paths = ["path1", "path2,path3"] # Default: []
|
||||
filter-paths-exact = ["path1", "path2,path3"] # Default: []
|
||||
filter-after = "2024-01-01" # Default: not set
|
||||
filter-before = "2024-02-05 12:15" # Default: not set
|
||||
filter-size = "200MiB" # Default: not set
|
||||
filter-size-added = "1 MB..10MB" # Default: not set
|
||||
filter-fn = '|sn| {sn.host == "host1" || sn.description.contains("test")}' # Default: no filter function
|
||||
|
||||
# Backup options: These options are used for all sources when calling the backup command.
|
||||
# They can be overwritten by source-specific options (see below) or command line options.
|
||||
[backup]
|
||||
label = "label" # Default: not set
|
||||
tag = ["tag1", "tag2"]
|
||||
tags = ["tag1", "tag2"]
|
||||
description = "my description" # Default: not set
|
||||
description-from = "/path/to/description.txt" # Default: not set
|
||||
delete-never = false
|
||||
@ -70,51 +115,47 @@ stdin-filename = "stdin" # Only for stdin source
|
||||
as-path = "/my/path" # Default: not set; Note: This only works if source contains of a single path.
|
||||
with-atime = false
|
||||
ignore-devid = false
|
||||
glob = []
|
||||
iglob = []
|
||||
glob-file = []
|
||||
iglob-file = []
|
||||
globs = []
|
||||
iglobs = []
|
||||
glob-files = []
|
||||
iglob-files = []
|
||||
git-ignore = false
|
||||
no-require-git = false
|
||||
exclude-if-present = [".nobackup", "CACHEDIR.TAG"] # Default: not set
|
||||
custom-ignorefiles = [".rusticignore", ".backupignore"] # Default: not set
|
||||
one-file-system = false
|
||||
exclude-larger-than = "100MB" # Default: not set
|
||||
json = false
|
||||
init = false
|
||||
no-scan = false
|
||||
quiet = false
|
||||
skip-identical-parent = false
|
||||
|
||||
# Backup hooks: The given commands are called for the `backup` command
|
||||
[backup.hooks]
|
||||
run-before = ["echo before"] # Default: []
|
||||
run-after = ["echo after"] # Run after if successful, default: []
|
||||
run-failed = ["echo failed"] # Default: []
|
||||
run-finally = ["echo finally"] # Always run after, default: []
|
||||
|
||||
# Backup options for specific sources - all above options are also available here and replace them for the given source
|
||||
[[backup.sources]]
|
||||
source = "/path/to/source1"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/path/to/source1"]
|
||||
label = "label" # Default: not set
|
||||
tag = ["tag1", "tag2"]
|
||||
description = "my description" # Default: not set
|
||||
description-from = "/path/to/description.txt" # Default: not set
|
||||
delete-never = false
|
||||
delete-after = "5d" # Default: not set
|
||||
host = "manually_set_host" # Default: host name
|
||||
group-by = "host,label,paths" # Can be any combination of host,label,paths,tags
|
||||
parent = "123abc" # Default: not set
|
||||
force = false
|
||||
ignore-ctime = false
|
||||
ignore-inode = false
|
||||
stdin-filename = "stdin" # Only for stdin source
|
||||
as-path = "/my/path" # Default: not set; Note: This only works if source contains of a single path.
|
||||
with-atime = false
|
||||
ignore-devid = false
|
||||
glob = []
|
||||
iglob = []
|
||||
glob-file = []
|
||||
iglob-file = []
|
||||
git-ignore = false
|
||||
no-require-git = false
|
||||
exclude-if-present = [".nobackup", "CACHEDIR.TAG"] # Default: not set
|
||||
one-file-system = false
|
||||
exclude-larger-than = "100MB" # Default: not set
|
||||
json = false
|
||||
init = false
|
||||
# .. and so on. see [backup]
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/path/to/source2 /second/path" # multiple local paths are allowd within one source
|
||||
# Source-specific hooks: The given commands when backing up the defined source
|
||||
[backup.snapshots.hooks]
|
||||
run-before = ["echo before"] # Default: []
|
||||
run-after = ["echo after"] # Run after if successful, default: []
|
||||
run-failed = ["echo failed"] # Default: []
|
||||
run-finally = ["echo finally"] # Always run after, default: []
|
||||
|
||||
[[backup.snapshots]]
|
||||
sources = [
|
||||
"/path/to/source2",
|
||||
"/second/path",
|
||||
] # multiple local paths are given as array
|
||||
# ...
|
||||
|
||||
# forget options
|
||||
@ -122,17 +163,23 @@ source = "/path/to/source2 /second/path" # multiple local paths are allowd withi
|
||||
prune = false
|
||||
group-by = "host,label,paths" # Can be any combination of host,label,paths,tags
|
||||
# The following filter options can be also defined here and then overwrite the options for the forget command
|
||||
filter-host = ["host2", "host2"] # Default: no host filter
|
||||
filter-label = ["label1", "label2"] # Default: no label filter
|
||||
filter-tags = ["tag1,tag2", "tag3"] # Default: no tags filger
|
||||
filter-paths = ["path1", "path2,path3"] # Default: no paths filter
|
||||
filter-hosts = ["host1", "host2"] # Default: []
|
||||
filter-labels = ["label1", "label2"] # Default: []
|
||||
filter-tags = ["tag1,tag2", "tag3"] # Default: []
|
||||
filter-tags-exact = ["tag1,tag2", "tag2"] # Default: []
|
||||
filter-paths = ["path1", "path2,path3"] # Default: []
|
||||
filter-paths-exact = ["path1", "path2,path3"] # Default: []
|
||||
filter-after = "2024-01-01" # Default: not set
|
||||
filter-before = "2024-02-05 12:15" # Default: not set
|
||||
filter-size = "200MiB" # Default: not set
|
||||
filter-size-added = "1 MB..10MB" # Default: not set
|
||||
filter-fn = '|sn| {sn.host == "host1" || sn.description.contains("test")}' # Default: no filter function
|
||||
# The retention options follow. All of these are not set by default.
|
||||
keep-tags = ["tag1", "tag2,tag3"]
|
||||
keep-tags = ["tag1", "tag2,tag3"] # Default: not set
|
||||
keep-ids = [
|
||||
"123abc",
|
||||
"11122233",
|
||||
] # Keep all snapshots whose ID starts with any of these strings
|
||||
] # Keep all snapshots whose ID starts with any of these strings, default: not set
|
||||
keep-last = 0
|
||||
keep-daily = 3
|
||||
keep-weekly = 0
|
||||
@ -148,22 +195,21 @@ keep-withing-quarter-yearly = "0 year"
|
||||
keep-withing-half-yearly = "1 year"
|
||||
keep-within-yearly = "10 years"
|
||||
|
||||
# Multiple targets are available for the copy command. Each specify a repository with exactly identical options as in
|
||||
# the [repository] section.
|
||||
[[copy.targets]]
|
||||
repository = "/repo/rustic" # Must be set
|
||||
repo-hot = "/my/hot/repo" # Default: not set
|
||||
# one of the three password options must be set
|
||||
password = "mySecretPassword"
|
||||
password-file = "/my/password.txt"
|
||||
password-command = "my_command.sh"
|
||||
no-cache = false
|
||||
cache-dir = "/my/rustic/cachedir" # Default: Applications default cache dir, e.g. ~/.cache/rustic
|
||||
# use either warm-up (warm-up by file access) or warm-up-command to specify warming up
|
||||
warm-up = false
|
||||
warm-up-command = "warmup.sh %id" # Default: not set
|
||||
warm-up-wait = "10min" # Default: not set
|
||||
[copy]
|
||||
targets = ["profile1", "profile2"] # Default: []
|
||||
|
||||
[[copy.targets]]
|
||||
repository = "/repo/rustic2" # Must be set
|
||||
# ...
|
||||
[webdav]
|
||||
address = "localhost:8000"
|
||||
path-template = "[{hostname}]/[{label}]/{time}" # The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]. Only relevant if no snapshot-path is given.
|
||||
time-template = "%Y-%m-%d_%H-%M-%S" # only relevant if no snapshot-path is given
|
||||
symlinks = false
|
||||
file-access = "read" # Default: "forbidden" for hot/cold repos, else "read"
|
||||
snapshot-path = "latest:/dir" # Default: not set - if not set, generate a virtual tree with all snapshots using path-template
|
||||
|
||||
[mount]
|
||||
path-template = "[{hostname}]/[{label}]/{time}" # The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]. Only relevant if no snapshot-path is given.
|
||||
time-template = "%Y-%m-%d_%H-%M-%S" # only relevant if no snapshot-path is given
|
||||
no-allow-other = true
|
||||
file-access = "read" # Default: "forbidden" for hot/cold repos, else "read"
|
||||
mountpoint = "~/mnt"
|
||||
snapshot-path = "latest:/dir" # Default: not set - if not set, generate a virtual tree with all snapshots using path-template
|
||||
|
||||
35
config/hooks.toml
Normal file
35
config/hooks.toml
Normal file
@ -0,0 +1,35 @@
|
||||
# Hooks configuration
|
||||
#
|
||||
# Hooks are commands that are run during certain events in the application lifecycle.
|
||||
# They can be used to run custom scripts or commands before or after certain actions.
|
||||
# The hooks are run in the order they are defined in the configuration file.
|
||||
# The hooks are divided into 4 categories: global, repository, backup,
|
||||
# and specific backup sources.
|
||||
#
|
||||
# You can also read a more detailed explanation of the hooks in the documentation:
|
||||
# https://rustic.cli.rs/docs/commands/misc/hooks.html
|
||||
#
|
||||
# Please make sure to check the in-repository documentation for the config files
|
||||
# available at: https://github.com/rustic-rs/rustic/blob/main/config/README.md
|
||||
#
|
||||
[global.hooks]
|
||||
run-before = []
|
||||
run-after = []
|
||||
run-failed = []
|
||||
run-finally = []
|
||||
|
||||
[repository.hooks]
|
||||
run-before = []
|
||||
run-after = []
|
||||
run-failed = []
|
||||
run-finally = []
|
||||
|
||||
[backup.hooks]
|
||||
run-before = []
|
||||
run-after = []
|
||||
run-failed = []
|
||||
run-finally = []
|
||||
|
||||
[[backup.snapshots]]
|
||||
sources = []
|
||||
hooks = { run-before = [], run-after = [], run-failed = [], run-finally = [] }
|
||||
@ -17,15 +17,15 @@ keep-yearly = 10
|
||||
|
||||
[backup]
|
||||
exclude-if-present = [".nobackup", "CACHEDIR.TAG"]
|
||||
glob-file = ["/root/rustic-local.glob"]
|
||||
glob-files = ["/root/rustic-local.glob"]
|
||||
one-file-system = true
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/home"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/home"]
|
||||
git-ignore = true
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/etc"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/etc"]
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/root"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/root"]
|
||||
|
||||
@ -17,7 +17,7 @@ password = "mySecretPassword"
|
||||
|
||||
# snapshot-filter options: These options apply to all commands that use snapshot filters
|
||||
[snapshot-filter]
|
||||
filter-host = ["myhost"]
|
||||
filter-hosts = ["myhost"]
|
||||
|
||||
# backup options: These options are used for all sources when calling the backup command.
|
||||
# They can be overwritten by source-specific options (see below) or command line options.
|
||||
@ -29,16 +29,16 @@ git-ignore = true
|
||||
#
|
||||
# Note that if you call "rustic backup" without any source, all sources from this config
|
||||
# file will be processed.
|
||||
[[backup.sources]]
|
||||
source = "/data/dir"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/data/dir"]
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/home"
|
||||
glob = ["!/home/*/Downloads/*"]
|
||||
[[backup.snapshots]]
|
||||
sources = ["/home"]
|
||||
globs = ["!/home/*/Downloads/*"]
|
||||
|
||||
# forget options
|
||||
[forget]
|
||||
filter-host = [
|
||||
filter-hosts = [
|
||||
"forgethost",
|
||||
] # <- this overwrites the snapshot-filter option defined above
|
||||
keep-tags = ["mytag"]
|
||||
|
||||
15
config/services/b2.toml
Normal file
15
config/services/b2.toml
Normal file
@ -0,0 +1,15 @@
|
||||
# rustic config file to use B2 storage via Apache OpenDAL
|
||||
[repository]
|
||||
repository = "opendal:b2" # just specify the opendal service here
|
||||
password = "<rustic_passwd>"
|
||||
# or
|
||||
# password-file = "/home/<username>/etc/secure/rustic_passwd"
|
||||
|
||||
# B2 specific options
|
||||
[repository.options]
|
||||
# Here, we give the required b2 options, see https://opendal.apache.org/docs/rust/opendal/services/struct.B2.html
|
||||
application_key_id = "my_id" # B2 application key ID
|
||||
application_key = "my_key" # B2 application key secret. Can be also set using OPENDAL_APPLICATION_KEY
|
||||
bucket = "bucket_name" # B2 bucket name
|
||||
bucket_id = "bucket_id" # B2 bucket ID
|
||||
# root = "/" # Set a repository root directory if not using the root directory of the bucket
|
||||
@ -1,9 +1,9 @@
|
||||
# rustic config file to backup /home, /etc and /root to a hot/cold repository hosted by OVH
|
||||
# using OVH cloud archive and OVH object storage
|
||||
#
|
||||
# backup usage: "rustic -P ovh-hot-cold backup
|
||||
# cleanup: "rustic -P ovh-hot-cold forget --prune
|
||||
#
|
||||
# backup usage: "rustic --use-profile ovh-hot-cold backup
|
||||
# cleanup: "rustic --use-profile ovh-hot-cold forget --prune
|
||||
|
||||
[repository]
|
||||
repository = "rclone:ovh:backup-home"
|
||||
repo-hot = "rclone:ovh:backup-home-hot"
|
||||
@ -20,15 +20,15 @@ keep-yearly = 10
|
||||
|
||||
[backup]
|
||||
exclude-if-present = [".nobackup", "CACHEDIR.TAG"]
|
||||
glob-file = ["/root/rustic-ovh.glob"]
|
||||
glob-files = ["/root/rustic-ovh.glob"]
|
||||
one-file-system = true
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/home"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/home"]
|
||||
git-ignore = true
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/etc"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/etc"]
|
||||
|
||||
[[backup.sources]]
|
||||
source = "/root"
|
||||
[[backup.snapshots]]
|
||||
sources = ["/root"]
|
||||
13
config/services/s3_aws.toml
Normal file
13
config/services/s3_aws.toml
Normal file
@ -0,0 +1,13 @@
|
||||
# rustic config file to use s3 storage
|
||||
# Note that this internally uses opendal S3 service, see https://opendal.apache.org/docs/rust/opendal/services/struct.S3.html
|
||||
# where endpoint, bucket and root are extracted from the repository URL.
|
||||
[repository]
|
||||
repository = "opendal:s3"
|
||||
password = "password"
|
||||
|
||||
# Other options can be given here - note that opendal also support reading config from env files or AWS config dirs, see the opendal S3 docu
|
||||
[repository.options]
|
||||
access_key_id = "xxx" # this can be ommited, when AWS config is used
|
||||
secret_access_key = "xxx" # this can be ommited, when AWS config is used
|
||||
bucket = "bucket_name"
|
||||
root = "/path/to/repo"
|
||||
11
config/services/s3_idrive.toml
Normal file
11
config/services/s3_idrive.toml
Normal file
@ -0,0 +1,11 @@
|
||||
[repository]
|
||||
repository = "opendal:s3"
|
||||
password = "password"
|
||||
|
||||
[repository.options]
|
||||
root = "/"
|
||||
bucket = "bucket_name"
|
||||
endpoint = "https://p7v1.ldn.idrivee2-40.com"
|
||||
region = "auto" # Explicit region is better, else requests are delayed to determine correct region.
|
||||
access_key_id = "xxx"
|
||||
secret_access_key = "xxx"
|
||||
13
config/services/sftp.toml
Normal file
13
config/services/sftp.toml
Normal file
@ -0,0 +1,13 @@
|
||||
# rustic config file to use sftp storage
|
||||
# Note:
|
||||
# - currently sftp only works on unix
|
||||
# - Using sftp with password is not supported yet, use key authentication, e.g. use
|
||||
# ssh-copy-id user@host
|
||||
[repository]
|
||||
repository = "opendal:sftp"
|
||||
password = "mypassword"
|
||||
|
||||
[repository.options]
|
||||
user = "myuser"
|
||||
endpoint = "host:port"
|
||||
root = "path/to/repo"
|
||||
8
config/services/sftp_hetzner_sbox.toml
Normal file
8
config/services/sftp_hetzner_sbox.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[repository]
|
||||
password = "XXXXXX"
|
||||
repository = "opendal:sftp"
|
||||
|
||||
[repository.options]
|
||||
endpoint = "ssh://XXXXX.your-storagebox.de:23"
|
||||
user = "XXXXX"
|
||||
key = "/root/.ssh/id_XXXXX_ed25519"
|
||||
11
config/services/webdav_owncloud_nextcloud.toml
Normal file
11
config/services/webdav_owncloud_nextcloud.toml
Normal file
@ -0,0 +1,11 @@
|
||||
[repository]
|
||||
repository = "opendal:webdav"
|
||||
password = "my-backup-password"
|
||||
|
||||
[repository.options]
|
||||
endpoint = "https://my-owncloud-or-nextcloud-server.com"
|
||||
# root = "remote.php/webdav/my-folder" # for owncloud
|
||||
# root = "remote.php/dav/files/<username>" # for nextcloud
|
||||
username = "user"
|
||||
# In `Settings -> Security -> App passwords / tokens` you should create a token to be used here.
|
||||
password = "token"
|
||||
@ -1,73 +0,0 @@
|
||||
# Ecosystem
|
||||
|
||||
**Note**: This is a work in progress. The crates are not yet published. The
|
||||
descriptions will be updated as soon as the crates are published. We needed to
|
||||
reserve some crates, because we discovered that another project recently chose
|
||||
the same name as `rustic`.
|
||||
|
||||
## Crates
|
||||
|
||||
### rustic_core - [Link](https://crates.io/crates/rustic_core)
|
||||
|
||||
Core functionality for the `rustic` ecosystem. Can be found
|
||||
[here](https://github.com/rustic-rs/rustic_core).
|
||||
|
||||
### rustic_testing (reserved) - [Link](https://crates.io/crates/rustic_testing)
|
||||
|
||||
Testing functionality for the `rustic` ecosystem. Can be found in
|
||||
[crates/rustic_testing](./rustic_testing/).
|
||||
|
||||
### rustic_ui (reserved) - [Link](https://crates.io/crates/rustic_ui)
|
||||
|
||||
General UI functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_gui (reserved) - [Link](https://crates.io/crates/rustic_gui)
|
||||
|
||||
Graphical UI for `rustic`.
|
||||
|
||||
### rustic_tui (reserved) - [Link](https://crates.io/crates/rustic_tui)
|
||||
|
||||
Terminal UI for `rustic`.
|
||||
|
||||
### rustic_cli (reserved) - [Link](https://crates.io/crates/rustic_cli)
|
||||
|
||||
Common used CLI functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_web (reserved) - [Link](https://crates.io/crates/rustic_web)
|
||||
|
||||
Possible WASM/WASI functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_store (reserved) - [Link](https://crates.io/crates/rustic_store)
|
||||
|
||||
Possible store functionality to support a plugin system for the `rustic`
|
||||
ecosystem.
|
||||
|
||||
### rustic_plugins (reserved) - [Link](https://crates.io/crates/rustic_plugins)
|
||||
|
||||
Plugin functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_scheduler (reserved) - [Link](https://crates.io/crates/rustic_scheduler)
|
||||
|
||||
Scheduling functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_daemon (reserved) - [Link](https://crates.io/crates/rustic_daemon)
|
||||
|
||||
A daemon for `rustic` and running it as a service.
|
||||
|
||||
### rustic_backend (reserved) - [Link](https://crates.io/crates/rustic_backend)
|
||||
|
||||
A possible backend implementation for `rustic` to support multi-system backup
|
||||
management.
|
||||
|
||||
### rustic_server (reserved) - [Link](https://crates.io/crates/rustic_server)
|
||||
|
||||
A possible server implementation for `rustic` to support client-/server
|
||||
communication (for example for a web GUI or a TUI).
|
||||
|
||||
### rustic_bench (reserved) - [Link](https://crates.io/crates/rustic_bench)
|
||||
|
||||
Benchmarking functionality for the `rustic` ecosystem.
|
||||
|
||||
### rustic_auth (reserved) - [Link](https://crates.io/crates/rustic_auth)
|
||||
|
||||
Authentication functionality for the `rustic` ecosystem.
|
||||
@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "rustic_testing"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
aho-corasick = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@ -1,71 +0,0 @@
|
||||
use aho_corasick::{AhoCorasick, PatternID};
|
||||
use std::{error::Error, ffi::OsStr};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
pub type TestResult<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
pub fn get_matches<I, P>(patterns: I, output: String) -> TestResult<Vec<(PatternID, usize)>>
|
||||
where
|
||||
I: IntoIterator<Item = P>,
|
||||
P: AsRef<[u8]>,
|
||||
{
|
||||
let ac = AhoCorasick::new(patterns)?;
|
||||
let mut matches = vec![];
|
||||
for mat in ac.find_iter(output.as_str()) {
|
||||
add_match_to_vector(&mut matches, mat);
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
pub fn add_match_to_vector(matches: &mut Vec<(PatternID, usize)>, mat: aho_corasick::Match) {
|
||||
matches.push((mat.pattern(), mat.end() - mat.start()))
|
||||
}
|
||||
|
||||
pub fn get_temp_file() -> TestResult<NamedTempFile> {
|
||||
Ok(NamedTempFile::new()?)
|
||||
}
|
||||
|
||||
pub fn files_differ(
|
||||
path_left: impl AsRef<OsStr>,
|
||||
path_right: impl AsRef<OsStr>,
|
||||
) -> TestResult<bool> {
|
||||
// diff the directories
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let proc = std::process::Command::new("diff")
|
||||
.arg(path_left)
|
||||
.arg(path_right)
|
||||
.output()?;
|
||||
|
||||
if proc.stdout.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let proc = std::process::Command::new("fc.exe")
|
||||
.arg("/L")
|
||||
.arg(path_left)
|
||||
.arg(path_right)
|
||||
.output()?;
|
||||
|
||||
let output = String::from_utf8(proc.stdout)?;
|
||||
|
||||
dbg!(&output);
|
||||
|
||||
let patterns = &["FC: no differences encountered"];
|
||||
let ac = AhoCorasick::new(patterns)?;
|
||||
let mut matches = vec![];
|
||||
|
||||
for mat in ac.find_iter(output.as_str()) {
|
||||
matches.push((mat.pattern(), mat.end() - mat.start()));
|
||||
}
|
||||
|
||||
if matches == vec![(PatternID::must(0), 30)] {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
149
deny.toml
149
deny.toml
@ -11,6 +11,9 @@
|
||||
|
||||
# Root options
|
||||
|
||||
# The graph table configures how the dependency graph is constructed and thus
|
||||
# which crates the checks are performed against
|
||||
[graph]
|
||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
||||
# only the specified targets will be checked when running `cargo deny check`.
|
||||
# This means, if a particular package is only ever used as a target specific
|
||||
@ -21,10 +24,9 @@
|
||||
# list here is effectively saying which targets you are building for.
|
||||
targets = [
|
||||
|
||||
|
||||
# The triple can be any string, but only the target triples built in to
|
||||
# rustc (as of 1.40) can be checked against actual config expressions
|
||||
# { triple = "x86_64-unknown-linux-musl" },
|
||||
# "x86_64-unknown-linux-musl",
|
||||
# You can also specify which target_features you promise are enabled for a
|
||||
# particular target. target_features are currently not validated against
|
||||
# the actual valid features supported by the target architecture.
|
||||
@ -48,6 +50,9 @@ no-default-features = false
|
||||
# If set, these feature will be enabled when collecting metadata. If `--features`
|
||||
# is specified on the cmd line they will take precedence over this option.
|
||||
# features = []
|
||||
|
||||
# The output table provides options for how/if diagnostics are outputted
|
||||
[output]
|
||||
# When outputting inclusion graphs in diagnostics that include features, this
|
||||
# option can be used to specify the depth at which feature edges will be added.
|
||||
# This option is included since the graphs can be quite large and the addition
|
||||
@ -59,37 +64,24 @@ feature-depth = 1
|
||||
# More documentation for the advisories section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
||||
[advisories]
|
||||
# The path where the advisory database is cloned/fetched into
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
# The path where the advisory databases are cloned/fetched into
|
||||
db-path = "$CARGO_HOME/advisory-dbs"
|
||||
# The url(s) of the advisory databases to use
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# The lint level for security vulnerabilities
|
||||
vulnerability = "deny"
|
||||
# The lint level for unmaintained crates
|
||||
unmaintained = "warn"
|
||||
# The lint level for crates that have been yanked from their source registry
|
||||
yanked = "warn"
|
||||
# The lint level for crates with security notices. Note that as of
|
||||
# 2019-12-17 there are no security notice advisories in
|
||||
# https://github.com/rustsec/advisory-db
|
||||
notice = "warn"
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
|
||||
|
||||
# "RUSTSEC-0000-0000",
|
||||
# FIXME!: See https://github.com/RustCrypto/RSA/issues/19#issuecomment-1822995643.
|
||||
# There is no workaround available yet.
|
||||
"RUSTSEC-2023-0071",
|
||||
# FIXME!: proc-macro-error's maintainer seems to be unreachable, we use `merge` which is using this.
|
||||
# `merge` is self-hosted on another platform, we should check if and how to replace it/open upstream
|
||||
# issue for updating the dependency.
|
||||
"RUSTSEC-2024-0370",
|
||||
# { id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
|
||||
# "a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
|
||||
# { crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
|
||||
]
|
||||
# Threshold for security vulnerabilities, any vulnerability with a CVSS score
|
||||
# lower than the range specified will be ignored. Note that ignored advisories
|
||||
# will still output a note when they are encountered.
|
||||
# * None - CVSS Score 0.0
|
||||
# * Low - CVSS Score 0.1 - 3.9
|
||||
# * Medium - CVSS Score 4.0 - 6.9
|
||||
# * High - CVSS Score 7.0 - 8.9
|
||||
# * Critical - CVSS Score 9.0 - 10.0
|
||||
# severity-threshold =
|
||||
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
|
||||
@ -100,46 +92,22 @@ ignore = [
|
||||
# More documentation for the licenses section can be found here:
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
||||
[licenses]
|
||||
# The lint level for crates which do not have a detectable license
|
||||
unlicensed = "warn"
|
||||
# List of explicitly allowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||
allow = [
|
||||
|
||||
|
||||
# "MIT",
|
||||
# "Apache-2.0",
|
||||
# "Apache-2.0 WITH LLVM-exception",
|
||||
# "ISC",
|
||||
# "BSD-3-Clause",
|
||||
# "CC0-1.0",
|
||||
# "Unicode-DFS-2016",
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"ISC",
|
||||
"Unicode-DFS-2016",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"MPL-2.0",
|
||||
"OpenSSL",
|
||||
"CC0-1.0",
|
||||
"Zlib",
|
||||
]
|
||||
# List of explicitly disallowed licenses
|
||||
# See https://spdx.org/licenses/ for list of possible licenses
|
||||
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
|
||||
deny = [
|
||||
|
||||
|
||||
# "Nokia",
|
||||
]
|
||||
# Lint level for licenses considered copyleft
|
||||
copyleft = "warn"
|
||||
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
|
||||
# * both - The license will be approved if it is both OSI-approved *AND* FSF
|
||||
# * either - The license will be approved if it is either OSI-approved *OR* FSF
|
||||
# * osi - The license will be approved if it is OSI approved
|
||||
# * fsf - The license will be approved if it is FSF Free
|
||||
# * osi-only - The license will be approved if it is OSI-approved *AND NOT* FSF
|
||||
# * fsf-only - The license will be approved if it is FSF *AND NOT* OSI-approved
|
||||
# * neither - This predicate is ignored and the default lint level is used
|
||||
allow-osi-fsf-free = "either"
|
||||
# Lint level used when no other predicates are matched
|
||||
# 1. License isn't in the allow or deny lists
|
||||
# 2. License isn't copyleft
|
||||
# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither"
|
||||
default = "deny"
|
||||
# The confidence threshold for detecting a license from license text.
|
||||
# The higher the value, the more closely the license text must be to the
|
||||
# canonical license text of a valid SPDX license file.
|
||||
@ -149,31 +117,28 @@ confidence-threshold = 0.8
|
||||
# aren't accepted for every possible crate as with the normal allow list
|
||||
exceptions = [
|
||||
|
||||
|
||||
# Each entry is the crate and version constraint, and its specific allow
|
||||
# list
|
||||
# { allow = ["Zlib"], name = "adler32", version = "*" },
|
||||
# { allow = ["Zlib"], crate = "adler32" },
|
||||
]
|
||||
|
||||
# Some crates don't have (easily) machine readable licensing information,
|
||||
# adding a clarification entry for it allows you to manually specify the
|
||||
# licensing information
|
||||
# [[licenses.clarify]]
|
||||
# The name of the crate the clarification applies to
|
||||
# name = "ring"
|
||||
# The optional version constraint for the crate
|
||||
# version = "*"
|
||||
[[licenses.clarify]]
|
||||
# The package spec the clarification applies to
|
||||
crate = "ring"
|
||||
# The SPDX expression for the license requirements of the crate
|
||||
# expression = "MIT AND ISC AND OpenSSL"
|
||||
expression = "MIT AND ISC AND OpenSSL"
|
||||
# One or more files in the crate's source used as the "source of truth" for
|
||||
# the license expression. If the contents match, the clarification will be used
|
||||
# when running the license check, otherwise the clarification will be ignored
|
||||
# and the crate will be checked normally, which may produce warnings or errors
|
||||
# depending on the rest of your configuration
|
||||
# license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
# { path = "LICENSE", hash = 0xbd0eed23 }
|
||||
# ]
|
||||
license-files = [
|
||||
# Each entry is a crate relative path, and the (opaque) hash of its contents
|
||||
{ path = "LICENSE", hash = 0xbd0eed23 },
|
||||
]
|
||||
|
||||
[licenses.private]
|
||||
# If true, ignores workspace crates that aren't published, or are only
|
||||
@ -186,7 +151,6 @@ ignore = false
|
||||
# not have its license(s) checked
|
||||
registries = [
|
||||
|
||||
|
||||
# "https://sekretz.com/registry
|
||||
]
|
||||
|
||||
@ -195,7 +159,7 @@ registries = [
|
||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
multiple-versions = "allow"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
@ -215,27 +179,24 @@ external-default-features = "allow"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
|
||||
|
||||
# { name = "ansi_term", version = "=0.11.0" },
|
||||
# "ansi_term@0.11.0",
|
||||
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
|
||||
]
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
|
||||
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
# { name = "ansi_term", version = "=0.11.0" },
|
||||
#
|
||||
# "ansi_term@0.11.0",
|
||||
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
|
||||
# Wrapper crates can optionally be specified to allow the crate when it
|
||||
# is a direct dependency of the otherwise banned crate
|
||||
# { name = "ansi_term", version = "=0.11.0", wrappers = [] },
|
||||
# { crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
|
||||
]
|
||||
|
||||
# List of features to allow/deny
|
||||
# Each entry the name of a crate and a version range. If version is
|
||||
# not specified, all versions will be matched.
|
||||
# [[bans.features]]
|
||||
# name = "reqwest"
|
||||
# crate = "reqwest"
|
||||
# Features to not allow
|
||||
# deny = ["json"]
|
||||
# Features to allow
|
||||
@ -257,8 +218,8 @@ deny = [
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
|
||||
|
||||
# { name = "ansi_term", version = "=0.11.0" },
|
||||
# "ansi_term@0.11.0",
|
||||
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
||||
@ -266,8 +227,8 @@ skip = [
|
||||
# by default infinite.
|
||||
skip-tree = [
|
||||
|
||||
|
||||
# { name = "ansi_term", version = "=0.11.0", depth = 20 },
|
||||
# "ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
|
||||
# { crate = "ansi_term@0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
# This section is considered when running `cargo deny check sources`.
|
||||
@ -287,9 +248,9 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = []
|
||||
|
||||
[sources.allow-org]
|
||||
# 1 or more github.com organizations to allow git sources for
|
||||
# github.com organizations to allow git sources for
|
||||
github = ["rustic-rs"]
|
||||
# 1 or more gitlab.com organizations to allow git sources for
|
||||
gitlab = [""]
|
||||
# 1 or more bitbucket.org organizations to allow git sources for
|
||||
bitbucket = [""]
|
||||
# gitlab.com organizations to allow git sources for
|
||||
gitlab = []
|
||||
# bitbucket.org organizations to allow git sources for
|
||||
bitbucket = []
|
||||
|
||||
@ -19,11 +19,12 @@
|
||||
"**/*.{json}"
|
||||
],
|
||||
"excludes": [
|
||||
"target/**/*"
|
||||
"target/**/*",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/markdown-0.16.3.wasm",
|
||||
"https://plugins.dprint.dev/toml-0.5.4.wasm",
|
||||
"https://plugins.dprint.dev/json-0.19.1.wasm"
|
||||
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
||||
"https://plugins.dprint.dev/toml-0.6.3.wasm",
|
||||
"https://plugins.dprint.dev/json-0.19.3.wasm"
|
||||
]
|
||||
}
|
||||
|
||||
10
platform-settings.toml
Normal file
10
platform-settings.toml
Normal file
@ -0,0 +1,10 @@
|
||||
[platforms.defaults]
|
||||
release-features = [
|
||||
"release",
|
||||
]
|
||||
|
||||
# Check if 'build-dependencies.just' needs to be updated
|
||||
[platforms.x86_64-unknown-linux-gnu]
|
||||
additional-features = [
|
||||
"mount",
|
||||
]
|
||||
10
release-plz.toml
Normal file
10
release-plz.toml
Normal file
@ -0,0 +1,10 @@
|
||||
# configuration spec can be found here https://release-plz.ieni.dev/docs/config
|
||||
|
||||
[workspace]
|
||||
git_release_enable = false # we currently use our own release process
|
||||
pr_draft = true
|
||||
# dependencies_update = true # We don't want to update dependencies automatically, as currently our dependencies tree is broken somewhere
|
||||
# changelog_config = "cliff.toml" # Don't use this for now, as it will override the default changelog config
|
||||
|
||||
[changelog]
|
||||
protect_breaking_commits = true
|
||||
@ -1,11 +1,11 @@
|
||||
//! Rustic Abscissa Application
|
||||
use std::env;
|
||||
use std::{env, process};
|
||||
|
||||
use abscissa_core::{
|
||||
application::{self, AppCell},
|
||||
application::{self, fatal_error, AppCell},
|
||||
config::{self, CfgCell},
|
||||
terminal::component::Terminal,
|
||||
Application, Component, FrameworkError, StandardPaths,
|
||||
Application, Component, FrameworkError, FrameworkErrorKind, Shutdown, StandardPaths,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
@ -16,6 +16,14 @@ use crate::{commands::EntryPoint, config::RusticConfig};
|
||||
/// Application state
|
||||
pub static RUSTIC_APP: AppCell<RusticApp> = AppCell::new();
|
||||
|
||||
// Constants
|
||||
pub mod constants {
|
||||
pub const RUSTIC_DOCS_URL: &str = "https://rustic.cli.rs/docs";
|
||||
pub const RUSTIC_DEV_DOCS_URL: &str = "https://rustic.cli.rs/dev-docs";
|
||||
pub const RUSTIC_CONFIG_DOCS_URL: &str =
|
||||
"https://github.com/rustic-rs/rustic/blob/main/config/README.md";
|
||||
}
|
||||
|
||||
/// Rustic Application
|
||||
#[derive(Debug)]
|
||||
pub struct RusticApp {
|
||||
@ -95,8 +103,38 @@ impl Application for RusticApp {
|
||||
env::set_var(env, value);
|
||||
}
|
||||
|
||||
let global_hooks = config.global.hooks.clone();
|
||||
self.config.set_once(config);
|
||||
|
||||
global_hooks.run_before().map_err(|err| -> FrameworkError {
|
||||
FrameworkErrorKind::ProcessError.context(err).into()
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shut down this application gracefully
|
||||
fn shutdown(&self, shutdown: Shutdown) -> ! {
|
||||
let exit_code = match shutdown {
|
||||
Shutdown::Crash => 1,
|
||||
_ => 0,
|
||||
};
|
||||
self.shutdown_with_exitcode(shutdown, exit_code)
|
||||
}
|
||||
|
||||
/// Shut down this application gracefully, exiting with given exit code.
|
||||
fn shutdown_with_exitcode(&self, shutdown: Shutdown, exit_code: i32) -> ! {
|
||||
let hooks = &RUSTIC_APP.config().global.hooks;
|
||||
match shutdown {
|
||||
Shutdown::Crash => _ = hooks.run_failed(),
|
||||
_ => _ = hooks.run_after(),
|
||||
};
|
||||
_ = hooks.run_finally();
|
||||
let result = self.state().components().shutdown(self, shutdown);
|
||||
if let Err(e) = result {
|
||||
fatal_error(self, &e)
|
||||
}
|
||||
|
||||
process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
244
src/commands.rs
244
src/commands.rs
@ -7,13 +7,17 @@ pub(crate) mod completions;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod copy;
|
||||
pub(crate) mod diff;
|
||||
pub(crate) mod docs;
|
||||
pub(crate) mod dump;
|
||||
pub(crate) mod find;
|
||||
pub(crate) mod forget;
|
||||
pub(crate) mod init;
|
||||
pub(crate) mod key;
|
||||
pub(crate) mod list;
|
||||
pub(crate) mod ls;
|
||||
pub(crate) mod merge;
|
||||
#[cfg(feature = "mount")]
|
||||
pub(crate) mod mount;
|
||||
pub(crate) mod prune;
|
||||
pub(crate) mod repair;
|
||||
pub(crate) mod repoinfo;
|
||||
@ -22,114 +26,146 @@ pub(crate) mod self_update;
|
||||
pub(crate) mod show_config;
|
||||
pub(crate) mod snapshots;
|
||||
pub(crate) mod tag;
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) mod tui;
|
||||
#[cfg(feature = "webdav")]
|
||||
pub(crate) mod webdav;
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "mount")]
|
||||
use crate::commands::mount::MountCmd;
|
||||
#[cfg(feature = "webdav")]
|
||||
use crate::commands::webdav::WebDavCmd;
|
||||
use crate::{
|
||||
commands::{
|
||||
backup::BackupCmd, cat::CatCmd, check::CheckCmd, completions::CompletionsCmd,
|
||||
config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, dump::DumpCmd, forget::ForgetCmd,
|
||||
init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd, prune::PruneCmd,
|
||||
repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd, self_update::SelfUpdateCmd,
|
||||
show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd,
|
||||
config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, docs::DocsCmd, dump::DumpCmd,
|
||||
forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd,
|
||||
prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd,
|
||||
self_update::SelfUpdateCmd, show_config::ShowConfigCmd, snapshots::SnapshotCmd,
|
||||
tag::TagCmd,
|
||||
},
|
||||
config::{progress_options::ProgressOptions, RusticConfig},
|
||||
{Application, RUSTIC_APP},
|
||||
config::RusticConfig,
|
||||
Application, RUSTIC_APP,
|
||||
};
|
||||
|
||||
use abscissa_core::{
|
||||
config::Override, terminal::ColorChoice, Command, Configurable, FrameworkError,
|
||||
FrameworkErrorKind, Runnable, Shutdown,
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use dialoguer::Password;
|
||||
use anyhow::Result;
|
||||
use clap::builder::{
|
||||
styling::{AnsiColor, Effects},
|
||||
Styles,
|
||||
};
|
||||
use convert_case::{Case, Casing};
|
||||
use human_panic::setup_panic;
|
||||
use log::{log, Level};
|
||||
use rustic_core::{OpenStatus, Repository};
|
||||
use simplelog::{CombinedLogger, LevelFilter, TermLogger, TerminalMode, WriteLogger};
|
||||
|
||||
pub(super) mod constants {
|
||||
pub(super) const MAX_PASSWORD_RETRIES: usize = 5;
|
||||
}
|
||||
use self::find::FindCmd;
|
||||
|
||||
/// Rustic Subcommands
|
||||
/// Subcommands need to be listed in an enum.
|
||||
#[derive(clap::Parser, Command, Debug, Runnable)]
|
||||
enum RusticCmd {
|
||||
/// Backup to the repository
|
||||
Backup(BackupCmd),
|
||||
Backup(Box<BackupCmd>),
|
||||
|
||||
/// Show raw data of repository files and blobs
|
||||
Cat(CatCmd),
|
||||
/// Show raw data of files and blobs in a repository
|
||||
Cat(Box<CatCmd>),
|
||||
|
||||
/// Change the repository configuration
|
||||
Config(ConfigCmd),
|
||||
Config(Box<ConfigCmd>),
|
||||
|
||||
/// Generate shell completions
|
||||
Completions(CompletionsCmd),
|
||||
Completions(Box<CompletionsCmd>),
|
||||
|
||||
/// Check the repository
|
||||
Check(CheckCmd),
|
||||
Check(Box<CheckCmd>),
|
||||
|
||||
/// Copy snapshots to other repositories. Note: The target repositories must be given in the config file!
|
||||
Copy(CopyCmd),
|
||||
/// Copy snapshots to other repositories
|
||||
Copy(Box<CopyCmd>),
|
||||
|
||||
/// Compare two snapshots/paths
|
||||
/// Note that the exclude options only apply for comparison with a local path
|
||||
Diff(DiffCmd),
|
||||
/// Compare two snapshots or paths
|
||||
Diff(Box<DiffCmd>),
|
||||
|
||||
/// dump the contents of a file in a snapshot to stdout
|
||||
Dump(DumpCmd),
|
||||
/// Open the documentation
|
||||
Docs(Box<DocsCmd>),
|
||||
|
||||
/// Dump the contents of a file within a snapshot to stdout
|
||||
Dump(Box<DumpCmd>),
|
||||
|
||||
/// Find patterns in given snapshots
|
||||
Find(Box<FindCmd>),
|
||||
|
||||
/// Remove snapshots from the repository
|
||||
Forget(ForgetCmd),
|
||||
Forget(Box<ForgetCmd>),
|
||||
|
||||
/// Initialize a new repository
|
||||
Init(InitCmd),
|
||||
Init(Box<InitCmd>),
|
||||
|
||||
/// Manage keys
|
||||
Key(KeyCmd),
|
||||
/// Manage keys for a repository
|
||||
Key(Box<KeyCmd>),
|
||||
|
||||
/// List repository files
|
||||
List(ListCmd),
|
||||
/// List repository files by file type
|
||||
List(Box<ListCmd>),
|
||||
|
||||
#[cfg(feature = "mount")]
|
||||
/// Mount a repository as read-only filesystem
|
||||
Mount(Box<MountCmd>),
|
||||
|
||||
/// List file contents of a snapshot
|
||||
Ls(LsCmd),
|
||||
Ls(Box<LsCmd>),
|
||||
|
||||
/// Merge snapshots
|
||||
Merge(MergeCmd),
|
||||
Merge(Box<MergeCmd>),
|
||||
|
||||
/// Show a detailed overview of the snapshots within the repository
|
||||
Snapshots(SnapshotCmd),
|
||||
Snapshots(Box<SnapshotCmd>),
|
||||
|
||||
/// Show the configuration which has been read from the config file(s)
|
||||
ShowConfig(ShowConfigCmd),
|
||||
ShowConfig(Box<ShowConfigCmd>),
|
||||
|
||||
/// Update to the latest rustic release
|
||||
/// Update to the latest stable rustic release
|
||||
#[cfg_attr(not(feature = "self-update"), clap(hide = true))]
|
||||
SelfUpdate(SelfUpdateCmd),
|
||||
SelfUpdate(Box<SelfUpdateCmd>),
|
||||
|
||||
/// Remove unused data or repack repository pack files
|
||||
Prune(PruneCmd),
|
||||
Prune(Box<PruneCmd>),
|
||||
|
||||
/// Restore a snapshot/path
|
||||
Restore(RestoreCmd),
|
||||
/// Restore (a path within) a snapshot
|
||||
Restore(Box<RestoreCmd>),
|
||||
|
||||
/// Repair a snapshot/path
|
||||
Repair(RepairCmd),
|
||||
/// Repair a snapshot or the repository index
|
||||
Repair(Box<RepairCmd>),
|
||||
|
||||
/// Show general information about the repository
|
||||
Repoinfo(RepoInfoCmd),
|
||||
Repoinfo(Box<RepoInfoCmd>),
|
||||
|
||||
/// Change tags of snapshots
|
||||
Tag(TagCmd),
|
||||
Tag(Box<TagCmd>),
|
||||
|
||||
/// Start a webdav server which allows to access the repository
|
||||
#[cfg(feature = "webdav")]
|
||||
Webdav(Box<WebDavCmd>),
|
||||
}
|
||||
|
||||
fn styles() -> Styles {
|
||||
Styles::styled()
|
||||
.header(AnsiColor::Red.on_default() | Effects::BOLD)
|
||||
.usage(AnsiColor::Red.on_default() | Effects::BOLD)
|
||||
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
|
||||
.placeholder(AnsiColor::Green.on_default())
|
||||
}
|
||||
|
||||
/// Entry point for the application. It needs to be a struct to allow using subcommands!
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
#[command(author, about, name="rustic", version = option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")))]
|
||||
#[command(author, about, name="rustic", styles=styles(), version = option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")))]
|
||||
pub struct EntryPoint {
|
||||
#[command(flatten)]
|
||||
pub config: RusticConfig,
|
||||
@ -140,6 +176,9 @@ pub struct EntryPoint {
|
||||
|
||||
impl Runnable for EntryPoint {
|
||||
fn run(&self) {
|
||||
// Set up panic hook for better error messages and logs
|
||||
setup_panic!();
|
||||
|
||||
self.commands.run();
|
||||
RUSTIC_APP.shutdown(Shutdown::Graceful)
|
||||
}
|
||||
@ -162,14 +201,31 @@ impl Configurable<RusticConfig> for EntryPoint {
|
||||
// That's why it says `_config`, because it's not read at all and therefore not needed.
|
||||
let mut config = self.config.clone();
|
||||
|
||||
// collect "RUSTIC_REPO_OPT*" and "OPENDAL_*" env variables
|
||||
for (var, value) in std::env::vars() {
|
||||
if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPT_") {
|
||||
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
|
||||
_ = config.repository.be.options.insert(var, value);
|
||||
} else if let Some(var) = var.strip_prefix("OPENDAL_") {
|
||||
let var = var.from_case(Case::UpperSnake).to_case(Case::Snake);
|
||||
_ = config.repository.be.options.insert(var, value);
|
||||
} else if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPTHOT_") {
|
||||
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
|
||||
_ = config.repository.be.options_hot.insert(var, value);
|
||||
} else if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPTCOLD_") {
|
||||
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
|
||||
_ = config.repository.be.options_cold.insert(var, value);
|
||||
}
|
||||
}
|
||||
|
||||
// collect logs during merging as we start the logger *after* merging
|
||||
let mut merge_logs = Vec::new();
|
||||
|
||||
// get global options from command line / env and config file
|
||||
if config.global.use_profile.is_empty() {
|
||||
if config.global.use_profiles.is_empty() {
|
||||
config.merge_profile("rustic", &mut merge_logs, Level::Info)?;
|
||||
} else {
|
||||
for profile in &config.global.use_profile.clone() {
|
||||
for profile in &config.global.use_profiles.clone() {
|
||||
config.merge_profile(profile, &mut merge_logs, Level::Warn)?;
|
||||
}
|
||||
}
|
||||
@ -180,33 +236,44 @@ impl Configurable<RusticConfig> for EntryPoint {
|
||||
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
|
||||
None => LevelFilter::Info,
|
||||
};
|
||||
let term_config = simplelog::ConfigBuilder::new()
|
||||
.set_time_level(LevelFilter::Off)
|
||||
.build();
|
||||
match &config.global.log_file {
|
||||
None => TermLogger::init(
|
||||
level_filter,
|
||||
simplelog::ConfigBuilder::new()
|
||||
.set_time_level(LevelFilter::Off)
|
||||
.build(),
|
||||
term_config,
|
||||
TerminalMode::Stderr,
|
||||
ColorChoice::Auto,
|
||||
)
|
||||
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
|
||||
|
||||
Some(file) => CombinedLogger::init(vec![
|
||||
TermLogger::new(
|
||||
Some(file) => {
|
||||
let file_config = simplelog::ConfigBuilder::new()
|
||||
.set_time_format_rfc3339()
|
||||
.build();
|
||||
let file = File::options()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(file)
|
||||
.map_err(|e| {
|
||||
FrameworkErrorKind::PathError {
|
||||
name: Some(file.clone()),
|
||||
}
|
||||
.context(e)
|
||||
})?;
|
||||
let term_logger = TermLogger::new(
|
||||
level_filter.min(LevelFilter::Warn),
|
||||
simplelog::ConfigBuilder::new()
|
||||
.set_time_level(LevelFilter::Off)
|
||||
.build(),
|
||||
term_config,
|
||||
TerminalMode::Stderr,
|
||||
ColorChoice::Auto,
|
||||
),
|
||||
WriteLogger::new(
|
||||
level_filter,
|
||||
simplelog::Config::default(),
|
||||
File::options().create(true).append(true).open(file)?,
|
||||
),
|
||||
])
|
||||
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
|
||||
);
|
||||
CombinedLogger::init(vec![
|
||||
term_logger,
|
||||
WriteLogger::new(level_filter, file_config, file),
|
||||
])
|
||||
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// display logs from merging
|
||||
@ -216,6 +283,11 @@ impl Configurable<RusticConfig> for EntryPoint {
|
||||
|
||||
match &self.commands {
|
||||
RusticCmd::Forget(cmd) => cmd.override_config(config),
|
||||
RusticCmd::Copy(cmd) => cmd.override_config(config),
|
||||
#[cfg(feature = "webdav")]
|
||||
RusticCmd::Webdav(cmd) => cmd.override_config(config),
|
||||
#[cfg(feature = "mount")]
|
||||
RusticCmd::Mount(cmd) => cmd.override_config(config),
|
||||
|
||||
// subcommands that don't need special overrides use a catch all
|
||||
_ => Ok(config),
|
||||
@ -223,50 +295,6 @@ impl Configurable<RusticConfig> for EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the repository with the given config
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - The config file
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// * [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`] - If reading the password failed
|
||||
/// * [`RepositoryErrorKind::OpeningPasswordFileFailed`] - If opening the password file failed
|
||||
/// * [`RepositoryErrorKind::PasswordCommandParsingFailed`] - If parsing the password command failed
|
||||
/// * [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`] - If reading the password from the command failed
|
||||
/// * [`RepositoryErrorKind::FromSplitError`] - If splitting the password command failed
|
||||
///
|
||||
/// [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`]: crate::error::RepositoryErrorKind::ReadingPasswordFromReaderFailed
|
||||
/// [`RepositoryErrorKind::OpeningPasswordFileFailed`]: crate::error::RepositoryErrorKind::OpeningPasswordFileFailed
|
||||
/// [`RepositoryErrorKind::PasswordCommandParsingFailed`]: crate::error::RepositoryErrorKind::PasswordCommandParsingFailed
|
||||
/// [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`]: crate::error::RepositoryErrorKind::ReadingPasswordFromCommandFailed
|
||||
/// [`RepositoryErrorKind::FromSplitError`]: crate::error::RepositoryErrorKind::FromSplitError
|
||||
fn open_repository(config: &Arc<RusticConfig>) -> Result<Repository<ProgressOptions, OpenStatus>> {
|
||||
let po = config.global.progress_options;
|
||||
let repo = Repository::new_with_progress(&config.repository, po)?;
|
||||
match repo.password()? {
|
||||
// if password is given, directly return the result of find_key_in_backend and don't retry
|
||||
Some(pass) => {
|
||||
return Ok(repo.open_with_password(&pass)?);
|
||||
}
|
||||
None => {
|
||||
for _ in 0..constants::MAX_PASSWORD_RETRIES {
|
||||
let pass = Password::new()
|
||||
.with_prompt("enter repository password")
|
||||
.allow_empty_password(true)
|
||||
.interact()?;
|
||||
match repo.clone().open_with_password(&pass) {
|
||||
Ok(repo) => return Ok(repo),
|
||||
// TODO: fail if error != Password incorrect
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("incorrect password"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::commands::EntryPoint;
|
||||
|
||||
@ -3,48 +3,58 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{
|
||||
commands::open_repository,
|
||||
helpers::bytes_size_to_string,
|
||||
{status_err, Application, RUSTIC_APP},
|
||||
commands::{init::init, snapshots::fill_table},
|
||||
config::hooks::Hooks,
|
||||
helpers::{bold_cell, bytes_size_to_string, table},
|
||||
repository::CliRepo,
|
||||
status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use log::{debug, info, warn};
|
||||
|
||||
use merge::Merge;
|
||||
use serde::Deserialize;
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use clap::ValueHint;
|
||||
use comfy_table::Cell;
|
||||
use conflate::Merge;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
|
||||
use rustic_core::{
|
||||
BackupOptions, ConfigOptions, KeyOptions, LocalSourceFilterOptions, LocalSourceSaveOptions,
|
||||
ParentOptions, PathList, Repository, SnapshotOptions,
|
||||
BackupOptions, CommandInput, ConfigOptions, IndexedIds, KeyOptions, LocalSourceFilterOptions,
|
||||
LocalSourceSaveOptions, ParentOptions, PathList, ProgressBars, Repository, SnapshotOptions,
|
||||
};
|
||||
|
||||
use super::init::init;
|
||||
|
||||
/// `backup` subcommand
|
||||
#[derive(Clone, Command, Default, Debug, clap::Parser, Deserialize, Merge)]
|
||||
#[serde_as]
|
||||
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
// Note: using cli_sources, sources and source within this struct is a hack to support serde(deny_unknown_fields)
|
||||
// Note: using cli_sources, sources and snapshots within this struct is a hack to support serde(deny_unknown_fields)
|
||||
// for deserializing the backup options from TOML
|
||||
// Unfortunately we cannot work with nested flattened structures, see
|
||||
// https://github.com/serde-rs/serde/issues/1547
|
||||
// A drawback is that a wrongly set "source(s) = ..." won't get correct error handling and need to be manually checked, see below.
|
||||
// A drawback is that a wrongly set "snapshots = ..." won't get correct error handling and need to be manually checked, see below.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct BackupCmd {
|
||||
/// Backup source (can be specified multiple times), use - for stdin. If no source is given, uses all
|
||||
/// sources defined in the config file
|
||||
#[clap(value_name = "SOURCE")]
|
||||
#[clap(value_name = "SOURCE", value_hint = ValueHint::AnyPath)]
|
||||
#[merge(skip)]
|
||||
#[serde(skip)]
|
||||
cli_sources: Vec<String>,
|
||||
|
||||
/// Set filename to be used when backing up from stdin
|
||||
#[clap(long, value_name = "FILENAME", default_value = "stdin")]
|
||||
#[clap(long, value_name = "FILENAME", default_value = "stdin", value_hint = ValueHint::FilePath)]
|
||||
#[merge(skip)]
|
||||
stdin_filename: String,
|
||||
|
||||
/// Start the given command and use its output as stdin
|
||||
#[clap(long, value_name = "COMMAND")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
stdin_command: Option<CommandInput>,
|
||||
|
||||
/// Manually set backup path in snapshot
|
||||
#[clap(long, value_name = "PATH")]
|
||||
#[clap(long, value_name = "PATH", value_hint = ValueHint::DirPath)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
as_path: Option<PathBuf>,
|
||||
|
||||
/// Ignore save options
|
||||
@ -52,19 +62,29 @@ pub struct BackupCmd {
|
||||
#[serde(flatten)]
|
||||
ignore_save_opts: LocalSourceSaveOptions,
|
||||
|
||||
/// Don't scan the backup source for its size - this disables ETA estimation for backup.
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
pub no_scan: bool,
|
||||
|
||||
/// Output generated snapshot in json format
|
||||
#[clap(long)]
|
||||
#[merge(strategy = merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
json: bool,
|
||||
|
||||
/// Don't show any output
|
||||
/// Show detailed information about generated snapshot
|
||||
#[clap(long, conflicts_with = "json")]
|
||||
#[merge(strategy = merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
long: bool,
|
||||
|
||||
/// Don't show any output
|
||||
#[clap(long, conflicts_with_all = ["json", "long"])]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
quiet: bool,
|
||||
|
||||
/// Initialize repository, if it doesn't exist yet
|
||||
#[clap(long)]
|
||||
#[merge(strategy = merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
init: bool,
|
||||
|
||||
/// Parent processing options
|
||||
@ -94,33 +114,51 @@ pub struct BackupCmd {
|
||||
#[merge(skip)]
|
||||
config_opts: ConfigOptions,
|
||||
|
||||
/// Backup sources
|
||||
/// Hooks to use
|
||||
#[clap(skip)]
|
||||
#[merge(strategy = merge_sources)]
|
||||
sources: Vec<BackupCmd>,
|
||||
hooks: Hooks,
|
||||
|
||||
/// Backup snapshots to generate
|
||||
#[clap(skip)]
|
||||
#[merge(strategy = merge_snapshots)]
|
||||
snapshots: Vec<BackupCmd>,
|
||||
|
||||
/// Backup source, used within config file
|
||||
#[clap(skip)]
|
||||
#[merge(skip)]
|
||||
source: String,
|
||||
sources: Vec<String>,
|
||||
}
|
||||
|
||||
/// Merge backup sources
|
||||
/// Merge backup snapshots to generate
|
||||
///
|
||||
/// If a source is already defined on left, use that. Else add it.
|
||||
/// If a snapshot is already defined on left, use that. Else add it.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `left` - Vector of backup sources
|
||||
pub(crate) fn merge_sources(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
|
||||
pub(crate) fn merge_snapshots(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
|
||||
left.append(&mut right);
|
||||
left.sort_by(|opt1, opt2| opt1.source.cmp(&opt2.source));
|
||||
left.dedup_by(|opt1, opt2| opt1.source == opt2.source);
|
||||
left.sort_by(|opt1, opt2| opt1.sources.cmp(&opt2.sources));
|
||||
left.dedup_by(|opt1, opt2| opt1.sources == opt2.sources);
|
||||
}
|
||||
|
||||
impl Runnable for BackupCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
// manually check for a "source" field, check is not done by serde, see above.
|
||||
if !config.backup.sources.is_empty() {
|
||||
status_err!("key \"sources\" is not valid in the [backup] section!");
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
}
|
||||
|
||||
let snapshot_opts = &config.backup.snapshots;
|
||||
// manually check for a "sources" field, check is not done by serde, see above.
|
||||
if snapshot_opts.iter().any(|opt| !opt.snapshots.is_empty()) {
|
||||
status_err!("key \"snapshots\" is not valid in a [[backup.snapshots]] section!");
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
}
|
||||
if let Err(err) = config.repository.run(|repo| self.inner_run(repo)) {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -128,11 +166,10 @@ impl Runnable for BackupCmd {
|
||||
}
|
||||
|
||||
impl BackupCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let snapshot_opts = &config.backup.snapshots;
|
||||
|
||||
let po = config.global.progress_options;
|
||||
let repo = Repository::new_with_progress(&config.repository, po)?;
|
||||
// Initialize repository if --init is set and it is not yet initialized
|
||||
let repo = if self.init && repo.config_id()?.is_none() {
|
||||
if config.global.dry_run {
|
||||
@ -141,31 +178,22 @@ impl BackupCmd {
|
||||
repo.name
|
||||
);
|
||||
}
|
||||
init(repo, &self.key_opts, &self.config_opts)?
|
||||
init(repo.0, &self.key_opts, &self.config_opts)?
|
||||
} else {
|
||||
open_repository(&config)?
|
||||
repo.open()?
|
||||
}
|
||||
.to_indexed_ids()?;
|
||||
|
||||
// manually check for a "source" field, check is not done by serde, see above.
|
||||
if !config.backup.source.is_empty() {
|
||||
bail!("key \"source\" is not valid in the [backup] section!");
|
||||
}
|
||||
|
||||
let config_opts = &config.backup.sources;
|
||||
|
||||
// manually check for a "sources" field, check is not done by serde, see above.
|
||||
if config_opts.iter().any(|opt| !opt.sources.is_empty()) {
|
||||
bail!("key \"sources\" is not valid in a [[backup.sources]] section!");
|
||||
}
|
||||
|
||||
let config_sources: Vec<_> = config_opts
|
||||
let config_snapshot_sources: Vec<_> = snapshot_opts
|
||||
.iter()
|
||||
.map(|opt| -> Result<_> {
|
||||
Ok(PathList::from_string(&opt.source)?
|
||||
Ok(PathList::from_iter(&opt.sources)
|
||||
.sanitize()
|
||||
.with_context(|| {
|
||||
format!("error sanitizing source=\"{}\" in config file", opt.source)
|
||||
format!(
|
||||
"error sanitizing sources=\"{:?}\" in config file",
|
||||
opt.sources
|
||||
)
|
||||
})?
|
||||
.merge())
|
||||
})
|
||||
@ -178,86 +206,130 @@ impl BackupCmd {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sources = match (self.cli_sources.is_empty(), config_opts.is_empty()) {
|
||||
let snapshot_sources = match (self.cli_sources.is_empty(), snapshot_opts.is_empty()) {
|
||||
(false, _) => {
|
||||
let item = PathList::from_strings(&self.cli_sources).sanitize()?;
|
||||
let item = PathList::from_iter(&self.cli_sources).sanitize()?;
|
||||
vec![item]
|
||||
}
|
||||
(true, false) => {
|
||||
info!("using all backup sources from config file.");
|
||||
config_sources.clone()
|
||||
config_snapshot_sources.clone()
|
||||
}
|
||||
(true, true) => {
|
||||
bail!("no backup source given.");
|
||||
}
|
||||
};
|
||||
|
||||
for source in sources {
|
||||
let mut opts = self.clone();
|
||||
|
||||
// merge Options from config file, if given
|
||||
if let Some(idx) = config_sources.iter().position(|s| s == &source) {
|
||||
info!("merging source={source} section from config file");
|
||||
opts.merge(config_opts[idx].clone());
|
||||
}
|
||||
if let Some(path) = &opts.as_path {
|
||||
// as_path only works in combination with a single target
|
||||
if source.len() > 1 {
|
||||
bail!("as-path only works with a single target!");
|
||||
}
|
||||
// merge Options from config file using as_path, if given
|
||||
if let Some(path) = path.as_os_str().to_str() {
|
||||
if let Some(idx) = config_opts.iter().position(|opt| opt.source == path) {
|
||||
info!("merging source=\"{path}\" section from config file");
|
||||
opts.merge(config_opts[idx].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// merge "backup" section from config file, if given
|
||||
opts.merge(config.backup.clone());
|
||||
|
||||
let backup_opts = BackupOptions::default()
|
||||
.stdin_filename(opts.stdin_filename)
|
||||
.as_path(opts.as_path)
|
||||
.parent_opts(opts.parent_opts)
|
||||
.ignore_save_opts(opts.ignore_save_opts)
|
||||
.ignore_filter_opts(opts.ignore_filter_opts)
|
||||
.dry_run(config.global.dry_run);
|
||||
let snap = repo.backup(&backup_opts, source.clone(), opts.snap_opts.to_snapshot()?)?;
|
||||
|
||||
if opts.json {
|
||||
let mut stdout = std::io::stdout();
|
||||
serde_json::to_writer_pretty(&mut stdout, &snap)?;
|
||||
} else if !opts.quiet {
|
||||
let summary = snap.summary.unwrap();
|
||||
println!(
|
||||
"Files: {} new, {} changed, {} unchanged",
|
||||
summary.files_new, summary.files_changed, summary.files_unmodified
|
||||
);
|
||||
println!(
|
||||
"Dirs: {} new, {} changed, {} unchanged",
|
||||
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
|
||||
);
|
||||
debug!("Data Blobs: {} new", summary.data_blobs);
|
||||
debug!("Tree Blobs: {} new", summary.tree_blobs);
|
||||
println!(
|
||||
"Added to the repo: {} (raw: {})",
|
||||
bytes_size_to_string(summary.data_added_packed),
|
||||
bytes_size_to_string(summary.data_added)
|
||||
);
|
||||
|
||||
println!(
|
||||
"processed {} files, {}",
|
||||
summary.total_files_processed,
|
||||
bytes_size_to_string(summary.total_bytes_processed)
|
||||
);
|
||||
println!("snapshot {} successfully saved.", snap.id);
|
||||
}
|
||||
|
||||
info!("backup of {source} done.");
|
||||
if snapshot_sources.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hooks = config.backup.hooks.with_context("backup");
|
||||
hooks.use_with(|| -> Result<_> {
|
||||
let mut is_err = false;
|
||||
for sources in snapshot_sources {
|
||||
let mut opts = self.clone();
|
||||
|
||||
// merge Options from config file, if given
|
||||
if let Some(idx) = config_snapshot_sources.iter().position(|s| s == &sources) {
|
||||
info!("merging sources={sources} section from config file");
|
||||
opts.merge(snapshot_opts[idx].clone());
|
||||
}
|
||||
if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) {
|
||||
error!("error backing up {sources}: {err}");
|
||||
is_err = true;
|
||||
}
|
||||
}
|
||||
if is_err {
|
||||
Err(anyhow!("Not all snapshots were generated successfully!"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn backup_snapshot<P: ProgressBars, S: IndexedIds>(
|
||||
mut self,
|
||||
source: PathList,
|
||||
repo: &Repository<P, S>,
|
||||
) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let snapshot_opts = &config.backup.snapshots;
|
||||
if let Some(path) = &self.as_path {
|
||||
// as_path only works in combination with a single target
|
||||
if source.len() > 1 {
|
||||
bail!("as-path only works with a single source!");
|
||||
}
|
||||
// merge Options from config file using as_path, if given
|
||||
if let Some(path) = path.as_os_str().to_str() {
|
||||
if let Some(idx) = snapshot_opts
|
||||
.iter()
|
||||
.position(|opt| opt.sources == vec![path])
|
||||
{
|
||||
info!("merging snapshot=\"{path}\" section from config file");
|
||||
self.merge(snapshot_opts[idx].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// use the correct source-specific hooks
|
||||
let hooks = self.hooks.with_context(&format!("backup {source}"));
|
||||
|
||||
// merge "backup" section from config file, if given
|
||||
self.merge(config.backup.clone());
|
||||
|
||||
let backup_opts = BackupOptions::default()
|
||||
.stdin_filename(self.stdin_filename)
|
||||
.stdin_command(self.stdin_command)
|
||||
.as_path(self.as_path)
|
||||
.parent_opts(self.parent_opts)
|
||||
.ignore_save_opts(self.ignore_save_opts)
|
||||
.ignore_filter_opts(self.ignore_filter_opts)
|
||||
.no_scan(self.no_scan)
|
||||
.dry_run(config.global.dry_run);
|
||||
|
||||
let snap = hooks.use_with(|| -> Result<_> {
|
||||
Ok(repo.backup(&backup_opts, &source, self.snap_opts.to_snapshot()?)?)
|
||||
})?;
|
||||
|
||||
if self.json {
|
||||
let mut stdout = std::io::stdout();
|
||||
serde_json::to_writer_pretty(&mut stdout, &snap)?;
|
||||
} else if self.long {
|
||||
let mut table = table();
|
||||
|
||||
let add_entry = |title: &str, value: String| {
|
||||
_ = table.add_row([bold_cell(title), Cell::new(value)]);
|
||||
};
|
||||
fill_table(&snap, add_entry);
|
||||
|
||||
println!("{table}");
|
||||
} else if !self.quiet {
|
||||
let summary = snap.summary.unwrap();
|
||||
println!(
|
||||
"Files: {} new, {} changed, {} unchanged",
|
||||
summary.files_new, summary.files_changed, summary.files_unmodified
|
||||
);
|
||||
println!(
|
||||
"Dirs: {} new, {} changed, {} unchanged",
|
||||
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
|
||||
);
|
||||
debug!("Data Blobs: {} new", summary.data_blobs);
|
||||
debug!("Tree Blobs: {} new", summary.tree_blobs);
|
||||
println!(
|
||||
"Added to the repo: {} (raw: {})",
|
||||
bytes_size_to_string(summary.data_added_packed),
|
||||
bytes_size_to_string(summary.data_added)
|
||||
);
|
||||
|
||||
println!(
|
||||
"processed {} files, {}",
|
||||
summary.total_files_processed,
|
||||
bytes_size_to_string(summary.total_bytes_processed)
|
||||
);
|
||||
println!("snapshot {} successfully saved.", snap.id);
|
||||
}
|
||||
|
||||
info!("backup of {source} done.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! `cat` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
|
||||
@ -59,19 +59,25 @@ impl Runnable for CatCmd {
|
||||
impl CatCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let data = match &self.cmd {
|
||||
CatSubCmd::Config => repo.cat_file(FileType::Config, "")?,
|
||||
CatSubCmd::Index(opt) => repo.cat_file(FileType::Index, &opt.id)?,
|
||||
CatSubCmd::Snapshot(opt) => repo.cat_file(FileType::Snapshot, &opt.id)?,
|
||||
// special treatment for 'cat'ing blobs: read the index and use it to locate the blob
|
||||
CatSubCmd::TreeBlob(opt) => repo.to_indexed()?.cat_blob(BlobType::Tree, &opt.id)?,
|
||||
CatSubCmd::DataBlob(opt) => repo.to_indexed()?.cat_blob(BlobType::Data, &opt.id)?,
|
||||
// special treatment for 'cat'ing a tree within a snapshot
|
||||
CatSubCmd::Tree(opt) => repo
|
||||
.to_indexed()?
|
||||
.cat_tree(&opt.snap, |sn| config.snapshot_filter.matches(sn))?,
|
||||
CatSubCmd::Config => config
|
||||
.repository
|
||||
.run_open(|repo| Ok(repo.cat_file(FileType::Config, "")?))?,
|
||||
CatSubCmd::Index(opt) => config
|
||||
.repository
|
||||
.run_open(|repo| Ok(repo.cat_file(FileType::Index, &opt.id)?))?,
|
||||
CatSubCmd::Snapshot(opt) => config
|
||||
.repository
|
||||
.run_open(|repo| Ok(repo.cat_file(FileType::Snapshot, &opt.id)?))?,
|
||||
CatSubCmd::TreeBlob(opt) => config
|
||||
.repository
|
||||
.run_indexed(|repo| Ok(repo.cat_blob(BlobType::Tree, &opt.id)?))?,
|
||||
CatSubCmd::DataBlob(opt) => config
|
||||
.repository
|
||||
.run_indexed(|repo| Ok(repo.cat_blob(BlobType::Data, &opt.id)?))?,
|
||||
CatSubCmd::Tree(opt) => config.repository.run_indexed(|repo| {
|
||||
Ok(repo.cat_tree(&opt.snap, |sn| config.snapshot_filter.matches(sn))?)
|
||||
})?,
|
||||
};
|
||||
println!("{}", String::from_utf8(data.to_vec())?);
|
||||
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
//! `check` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use rustic_core::CheckOptions;
|
||||
use rustic_core::{CheckOptions, SnapshotGroupCriterion};
|
||||
|
||||
/// `check` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
pub(crate) struct CheckCmd {
|
||||
/// Snapshots to check. If none is given, use filter options to filter from all snapshots
|
||||
#[clap(value_name = "ID")]
|
||||
ids: Vec<String>,
|
||||
|
||||
/// Check options
|
||||
#[clap(flatten)]
|
||||
opts: CheckOptions,
|
||||
@ -16,7 +20,11 @@ pub(crate) struct CheckCmd {
|
||||
|
||||
impl Runnable for CheckCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -24,10 +32,18 @@ impl Runnable for CheckCmd {
|
||||
}
|
||||
|
||||
impl CheckCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
repo.check(self.opts)?;
|
||||
|
||||
let groups = repo.get_snapshot_group(&self.ids, SnapshotGroupCriterion::new(), |sn| {
|
||||
config.snapshot_filter.matches(sn)
|
||||
})?;
|
||||
let trees = groups
|
||||
.into_iter()
|
||||
.flat_map(|(_, snaps)| snaps)
|
||||
.map(|snap| snap.tree)
|
||||
.collect();
|
||||
repo.check_with_trees(self.opts, trees)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,7 @@ mod tests {
|
||||
fn test_completions() {
|
||||
generate_completion(shells::Bash, &mut std::io::sink());
|
||||
generate_completion(shells::Fish, &mut std::io::sink());
|
||||
generate_completion(shells::PowerShell, &mut std::io::sink());
|
||||
generate_completion(shells::Zsh, &mut std::io::sink());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! `config` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
|
||||
@ -28,9 +28,10 @@ impl Runnable for ConfigCmd {
|
||||
impl ConfigCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let changed = repo.apply_config(&self.config_opts)?;
|
||||
let changed = config
|
||||
.repository
|
||||
.run_open(|repo| Ok(repo.apply_config(&self.config_opts)?))?;
|
||||
|
||||
if changed {
|
||||
println!("saved new config");
|
||||
|
||||
@ -1,44 +1,67 @@
|
||||
//! `copy` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository, helpers::table_with_titles, status_err, Application, RUSTIC_APP,
|
||||
commands::init::init_password,
|
||||
helpers::table_with_titles,
|
||||
repository::{CliIndexedRepo, CliRepo},
|
||||
status_err, Application, RusticConfig, RUSTIC_APP,
|
||||
};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
|
||||
use anyhow::{bail, Result};
|
||||
use log::{error, info};
|
||||
use conflate::Merge;
|
||||
use log::{error, info, log, Level};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use merge::Merge;
|
||||
use serde::Deserialize;
|
||||
|
||||
use rustic_core::{CopySnapshot, Id, KeyOptions, Repository, RepositoryOptions};
|
||||
use rustic_core::{repofile::SnapshotFile, CopySnapshot, Id, KeyOptions};
|
||||
|
||||
/// `copy` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
pub(crate) struct CopyCmd {
|
||||
#[derive(clap::Parser, Command, Default, Clone, Debug, Serialize, Deserialize, Merge)]
|
||||
pub struct CopyCmd {
|
||||
/// Snapshots to copy. If none is given, use filter options to filter from all snapshots.
|
||||
#[clap(value_name = "ID")]
|
||||
#[serde(skip)]
|
||||
#[merge(skip)]
|
||||
ids: Vec<String>,
|
||||
|
||||
/// Initialize non-existing target repositories
|
||||
#[clap(long)]
|
||||
#[serde(skip)]
|
||||
#[merge(skip)]
|
||||
init: bool,
|
||||
|
||||
/// Target repository (can be specified multiple times)
|
||||
#[clap(long = "target", value_name = "TARGET")]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
targets: Vec<String>,
|
||||
|
||||
/// Key options (when using --init)
|
||||
#[clap(flatten, next_help_heading = "Key options (when using --init)")]
|
||||
#[serde(skip)]
|
||||
#[merge(skip)]
|
||||
key_opts: KeyOptions,
|
||||
}
|
||||
|
||||
/// Target repository options
|
||||
#[derive(Default, Clone, Debug, Deserialize, Merge)]
|
||||
pub struct Targets {
|
||||
/// Target repositories
|
||||
#[merge(strategy = merge::vec::overwrite_empty)]
|
||||
targets: Vec<RepositoryOptions>,
|
||||
impl Override<RusticConfig> for CopyCmd {
|
||||
// Process the given command line options, overriding settings from
|
||||
// a configuration file using explicit flags taken from command-line
|
||||
// arguments.
|
||||
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
|
||||
let mut self_config = self.clone();
|
||||
// merge "copy" section from config file, if given
|
||||
self_config.merge(config.copy);
|
||||
config.copy = self_config;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runnable for CopyCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
let config = RUSTIC_APP.config();
|
||||
if config.copy.targets.is_empty() {
|
||||
status_err!("No target given. Please specify at least 1 target either in the profile or using --target!");
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
}
|
||||
if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -46,15 +69,8 @@ impl Runnable for CopyCmd {
|
||||
}
|
||||
|
||||
impl CopyCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
if config.copy.targets.is_empty() {
|
||||
status_err!("no [[copy.targets]] section in config file found!");
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
}
|
||||
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
let mut snapshots = if self.ids.is_empty() {
|
||||
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?
|
||||
} else {
|
||||
@ -63,71 +79,86 @@ impl CopyCmd {
|
||||
// sort for nicer output
|
||||
snapshots.sort_unstable();
|
||||
|
||||
let poly = repo.config().poly()?;
|
||||
for target_opt in &config.copy.targets {
|
||||
let repo_dest =
|
||||
Repository::new_with_progress(target_opt, config.global.progress_options)?;
|
||||
|
||||
let repo_dest = if self.init && repo_dest.config_id()?.is_none() {
|
||||
if config.global.dry_run {
|
||||
error!(
|
||||
"cannot initialize target {} in dry-run mode!",
|
||||
repo_dest.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut config_dest = repo.config().clone();
|
||||
config_dest.id = Id::random();
|
||||
let pass = repo_dest.password()?.unwrap();
|
||||
repo_dest.init_with_config(&pass, &self.key_opts, config_dest)?
|
||||
} else {
|
||||
repo_dest.open()?
|
||||
};
|
||||
|
||||
info!("copying to target {}...", repo_dest.name);
|
||||
if poly != repo_dest.config().poly()? {
|
||||
bail!("cannot copy to repository with different chunker parameter (re-chunking not implemented)!");
|
||||
for target in &config.copy.targets {
|
||||
let mut merge_logs = Vec::new();
|
||||
let mut target_config = RusticConfig::default();
|
||||
target_config.merge_profile(target, &mut merge_logs, Level::Error)?;
|
||||
// display logs from merging
|
||||
for (level, merge_log) in merge_logs {
|
||||
log!(level, "{}", merge_log);
|
||||
}
|
||||
|
||||
let snaps = repo_dest.relevant_copy_snapshots(
|
||||
|sn| !self.ids.is_empty() || config.snapshot_filter.matches(sn),
|
||||
&snapshots,
|
||||
)?;
|
||||
|
||||
let mut table =
|
||||
table_with_titles(["ID", "Time", "Host", "Label", "Tags", "Paths", "Status"]);
|
||||
for CopySnapshot { relevant, sn } in snaps.iter() {
|
||||
let tags = sn.tags.formatln();
|
||||
let paths = sn.paths.formatln();
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
_ = table.add_row([
|
||||
&sn.id.to_string(),
|
||||
&time,
|
||||
&sn.hostname,
|
||||
&sn.label,
|
||||
&tags,
|
||||
&paths,
|
||||
&(if *relevant { "to copy" } else { "existing" }).to_string(),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
|
||||
let count = snaps.iter().filter(|sn| sn.relevant).count();
|
||||
if count > 0 {
|
||||
if config.global.dry_run {
|
||||
info!("would have copied {count} snapshots.");
|
||||
} else {
|
||||
repo.copy(
|
||||
&repo_dest.to_indexed_ids()?,
|
||||
snaps
|
||||
.iter()
|
||||
.filter_map(|CopySnapshot { relevant, sn }| relevant.then_some(sn)),
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
info!("nothing to copy.");
|
||||
let target_opt = &target_config.repository;
|
||||
if let Err(err) =
|
||||
target_opt.run(|target_repo| self.copy(&repo, target_repo, &snapshots))
|
||||
{
|
||||
error!("error copying to target: {err}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy(
|
||||
&self,
|
||||
repo: &CliIndexedRepo,
|
||||
target_repo: CliRepo,
|
||||
snapshots: &[SnapshotFile],
|
||||
) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
info!("copying to target {}...", target_repo.name);
|
||||
let target_repo = if self.init && target_repo.config_id()?.is_none() {
|
||||
let mut config_dest = repo.config().clone();
|
||||
config_dest.id = Id::random().into();
|
||||
let pass = init_password(&target_repo)?;
|
||||
target_repo
|
||||
.0
|
||||
.init_with_config(&pass, &self.key_opts, config_dest)?
|
||||
} else {
|
||||
target_repo.open()?
|
||||
};
|
||||
|
||||
if repo.config().poly()? != target_repo.config().poly()? {
|
||||
bail!("cannot copy to repository with different chunker parameter (re-chunking not implemented)!");
|
||||
}
|
||||
|
||||
let snaps = target_repo.relevant_copy_snapshots(
|
||||
|sn| !self.ids.is_empty() || config.snapshot_filter.matches(sn),
|
||||
snapshots,
|
||||
)?;
|
||||
|
||||
let mut table =
|
||||
table_with_titles(["ID", "Time", "Host", "Label", "Tags", "Paths", "Status"]);
|
||||
for CopySnapshot { relevant, sn } in snaps.iter() {
|
||||
let tags = sn.tags.formatln();
|
||||
let paths = sn.paths.formatln();
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
_ = table.add_row([
|
||||
&sn.id.to_string(),
|
||||
&time,
|
||||
&sn.hostname,
|
||||
&sn.label,
|
||||
&tags,
|
||||
&paths,
|
||||
&(if *relevant { "to copy" } else { "existing" }).to_string(),
|
||||
]);
|
||||
}
|
||||
println!("{table}");
|
||||
|
||||
let count = snaps.iter().filter(|sn| sn.relevant).count();
|
||||
if count > 0 {
|
||||
if config.global.dry_run {
|
||||
info!("would have copied {count} snapshots.");
|
||||
} else {
|
||||
repo.copy(
|
||||
&target_repo.to_indexed_ids()?,
|
||||
snaps
|
||||
.iter()
|
||||
.filter_map(|CopySnapshot { relevant, sn }| relevant.then_some(sn)),
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
info!("nothing to copy.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
//! `diff` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use clap::ValueHint;
|
||||
use log::debug;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use rustic_core::{
|
||||
repofile::{BlobType, Node, NodeType},
|
||||
repofile::{Node, NodeType},
|
||||
IndexedFull, LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions,
|
||||
LsOptions, ReadSourceEntry, Repository, RusticResult,
|
||||
LsOptions, ReadSource, ReadSourceEntry, Repository, RusticResult,
|
||||
};
|
||||
|
||||
/// `diff` subcommand
|
||||
@ -22,7 +27,7 @@ pub(crate) struct DiffCmd {
|
||||
snap1: String,
|
||||
|
||||
/// New snapshot/path or local path [default for PATH2: PATH1]
|
||||
#[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2")]
|
||||
#[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
|
||||
snap2: String,
|
||||
|
||||
/// show differences in metadata
|
||||
@ -33,6 +38,10 @@ pub(crate) struct DiffCmd {
|
||||
#[clap(long)]
|
||||
no_content: bool,
|
||||
|
||||
/// only show differences for identical files, this can be used for a bitrot test on the local path
|
||||
#[clap(long, conflicts_with = "no_content")]
|
||||
only_identical: bool,
|
||||
|
||||
/// Ignore options
|
||||
#[clap(flatten)]
|
||||
ignore_opts: LocalSourceFilterOptions,
|
||||
@ -40,7 +49,11 @@ pub(crate) struct DiffCmd {
|
||||
|
||||
impl Runnable for DiffCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -48,11 +61,9 @@ impl Runnable for DiffCmd {
|
||||
}
|
||||
|
||||
impl DiffCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
|
||||
let (id1, path1) = arg_to_snap_path(&self.snap1, "");
|
||||
let (id2, path2) = arg_to_snap_path(&self.snap2, path1);
|
||||
|
||||
@ -92,6 +103,7 @@ impl DiffCmd {
|
||||
&self.ignore_opts,
|
||||
&[&path2],
|
||||
)?
|
||||
.entries()
|
||||
.map(|item| -> RusticResult<_> {
|
||||
let ReadSourceEntry { path, node, .. } = item?;
|
||||
let path = if is_dir {
|
||||
@ -104,13 +116,21 @@ impl DiffCmd {
|
||||
Ok((path, node))
|
||||
});
|
||||
|
||||
diff(
|
||||
repo.ls(&node1, &LsOptions::default())?,
|
||||
src,
|
||||
self.no_content,
|
||||
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
|
||||
self.metadata,
|
||||
)?;
|
||||
if self.only_identical {
|
||||
diff_identical(
|
||||
repo.ls(&node1, &LsOptions::default())?,
|
||||
src,
|
||||
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
|
||||
)?;
|
||||
} else {
|
||||
diff(
|
||||
repo.ls(&node1, &LsOptions::default())?,
|
||||
src,
|
||||
self.no_content,
|
||||
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
|
||||
self.metadata,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
(None, _) => {
|
||||
bail!("cannot use local path as first argument");
|
||||
@ -133,6 +153,7 @@ impl DiffCmd {
|
||||
/// A tuple of the snapshot id and the path
|
||||
fn arg_to_snap_path<'a>(arg: &'a str, default_path: &'a str) -> (Option<&'a str>, &'a str) {
|
||||
match arg.split_once(':') {
|
||||
Some(("local", path)) => (None, path),
|
||||
Some((id, path)) => (Some(id), path),
|
||||
None => {
|
||||
if arg.contains('/') {
|
||||
@ -174,7 +195,7 @@ fn identical_content_local<P, S: IndexedFull>(
|
||||
};
|
||||
|
||||
for id in node.content.iter().flatten() {
|
||||
let ie = repo.get_index_entry(BlobType::Data, id)?;
|
||||
let ie = repo.get_index_entry(id)?;
|
||||
let length = ie.data_length();
|
||||
if !id.blob_matches_reader(length as usize, &mut open_file) {
|
||||
return Ok(false);
|
||||
@ -183,6 +204,102 @@ fn identical_content_local<P, S: IndexedFull>(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Statistics about the differences listed with the [`DiffCmd`] command
|
||||
#[derive(Default)]
|
||||
struct DiffStatistics {
|
||||
files_added: usize,
|
||||
files_removed: usize,
|
||||
files_changed: usize,
|
||||
directories_added: usize,
|
||||
directories_removed: usize,
|
||||
others_added: usize,
|
||||
others_removed: usize,
|
||||
node_type_changed: usize,
|
||||
metadata_changed: usize,
|
||||
symlink_added: usize,
|
||||
symlink_removed: usize,
|
||||
symlink_changed: usize,
|
||||
}
|
||||
|
||||
impl DiffStatistics {
|
||||
fn removed_node(&mut self, node_type: &NodeType) {
|
||||
match node_type {
|
||||
NodeType::File => self.files_removed += 1,
|
||||
NodeType::Dir => self.directories_removed += 1,
|
||||
NodeType::Symlink { .. } => self.symlink_removed += 1,
|
||||
_ => self.others_removed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn added_node(&mut self, node_type: &NodeType) {
|
||||
match node_type {
|
||||
NodeType::File => self.files_added += 1,
|
||||
NodeType::Dir => self.directories_added += 1,
|
||||
NodeType::Symlink { .. } => self.symlink_added += 1,
|
||||
_ => self.others_added += 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_file(&mut self) {
|
||||
self.files_changed += 1;
|
||||
}
|
||||
|
||||
fn changed_node_type(&mut self) {
|
||||
self.node_type_changed += 1;
|
||||
}
|
||||
|
||||
fn changed_metadata(&mut self) {
|
||||
self.metadata_changed += 1;
|
||||
}
|
||||
|
||||
fn changed_symlink(&mut self) {
|
||||
self.symlink_changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for DiffStatistics {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!(
|
||||
"Files :\t{} new,\t{} removed,\t{} changed\n",
|
||||
self.files_added, self.files_removed, self.files_changed
|
||||
))?;
|
||||
// symlink
|
||||
if self.symlink_added != 0 || self.symlink_removed != 0 || self.symlink_changed != 0 {
|
||||
f.write_fmt(format_args!(
|
||||
"Symlinks:\t{} new,\t{} removed,\t{} changed\n",
|
||||
self.symlink_added, self.symlink_removed, self.symlink_changed
|
||||
))?;
|
||||
}
|
||||
f.write_fmt(format_args!(
|
||||
"Dirs :\t{} new,\t{} removed\n",
|
||||
self.directories_added, self.directories_removed
|
||||
))?;
|
||||
if self.others_added != 0 || self.others_removed != 0 {
|
||||
f.write_fmt(format_args!(
|
||||
"Others :\t{} new,\t{} removed\n",
|
||||
self.others_added, self.others_removed
|
||||
))?;
|
||||
}
|
||||
|
||||
// node type
|
||||
if self.node_type_changed != 0 {
|
||||
f.write_fmt(format_args!(
|
||||
"NodeType:\t{} changed\n",
|
||||
self.node_type_changed
|
||||
))?;
|
||||
}
|
||||
|
||||
// metadata
|
||||
if self.metadata_changed != 0 {
|
||||
f.write_fmt(format_args!(
|
||||
"Metadata:\t{} changed\n",
|
||||
self.metadata_changed
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two streams of nodes and print the differences
|
||||
///
|
||||
/// # Arguments
|
||||
@ -206,40 +323,59 @@ fn diff(
|
||||
let mut item1 = tree_streamer1.next().transpose()?;
|
||||
let mut item2 = tree_streamer2.next().transpose()?;
|
||||
|
||||
let mut diff_statistics = DiffStatistics::default();
|
||||
|
||||
loop {
|
||||
match (&item1, &item2) {
|
||||
(None, None) => break,
|
||||
(Some(i1), None) => {
|
||||
println!("- {:?}", i1.0);
|
||||
diff_statistics.removed_node(&i1.1.node_type);
|
||||
item1 = tree_streamer1.next().transpose()?;
|
||||
}
|
||||
(None, Some(i2)) => {
|
||||
println!("+ {:?}", i2.0);
|
||||
diff_statistics.added_node(&i2.1.node_type);
|
||||
item2 = tree_streamer2.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) if i1.0 < i2.0 => {
|
||||
println!("- {:?}", i1.0);
|
||||
diff_statistics.removed_node(&i1.1.node_type);
|
||||
item1 = tree_streamer1.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) if i1.0 > i2.0 => {
|
||||
println!("+ {:?}", i2.0);
|
||||
diff_statistics.added_node(&i2.1.node_type);
|
||||
item2 = tree_streamer2.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) => {
|
||||
let path = &i1.0;
|
||||
let node1 = &i1.1;
|
||||
let node2 = &i2.1;
|
||||
|
||||
let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. })
|
||||
&& matches!(&node2.node_type, NodeType::Symlink { .. });
|
||||
match &node1.node_type {
|
||||
tpe if tpe != &node2.node_type => println!("T {path:?}"), // type was changed
|
||||
// if node1.node_type != node2.node_type, they could be different symlinks,
|
||||
// for this reason we check:
|
||||
// that their type is different AND that they are not both symlinks
|
||||
tpe if tpe != &node2.node_type && !are_both_symlink => {
|
||||
// type was changed
|
||||
println!("T {path:?}");
|
||||
diff_statistics.changed_node_type();
|
||||
}
|
||||
NodeType::File if !no_content && !file_identical(path, node1, node2)? => {
|
||||
println!("M {path:?}");
|
||||
diff_statistics.changed_file();
|
||||
}
|
||||
NodeType::File if metadata && node1.meta != node2.meta => {
|
||||
println!("U {path:?}");
|
||||
diff_statistics.changed_metadata();
|
||||
}
|
||||
NodeType::Symlink { .. } => {
|
||||
if node1.node_type.to_link() != node1.node_type.to_link() {
|
||||
if node1.node_type.to_link() != node2.node_type.to_link() {
|
||||
println!("U {path:?}");
|
||||
diff_statistics.changed_symlink();
|
||||
}
|
||||
}
|
||||
_ => {} // no difference to show
|
||||
@ -249,6 +385,65 @@ fn diff(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{diff_statistics}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn diff_identical(
|
||||
mut tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
|
||||
mut tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
|
||||
file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
|
||||
) -> Result<()> {
|
||||
let mut item1 = tree_streamer1.next().transpose()?;
|
||||
let mut item2 = tree_streamer2.next().transpose()?;
|
||||
|
||||
let mut checked: usize = 0;
|
||||
|
||||
loop {
|
||||
match (&item1, &item2) {
|
||||
(None, None) => break,
|
||||
(Some(i1), None) => {
|
||||
let path = &i1.0;
|
||||
debug!("not checking {}: not present in target", path.display());
|
||||
item1 = tree_streamer1.next().transpose()?;
|
||||
}
|
||||
(None, Some(i2)) => {
|
||||
let path = &i2.0;
|
||||
debug!("not checking {}: not present in source", path.display());
|
||||
item2 = tree_streamer2.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) if i1.0 < i2.0 => {
|
||||
let path = &i1.0;
|
||||
debug!("not checking {}: not present in target", path.display());
|
||||
item1 = tree_streamer1.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) if i1.0 > i2.0 => {
|
||||
let path = &i2.0;
|
||||
debug!("not checking {}: not present in source", path.display());
|
||||
item2 = tree_streamer2.next().transpose()?;
|
||||
}
|
||||
(Some(i1), Some(i2)) => {
|
||||
let path = &i1.0;
|
||||
let node1 = &i1.1;
|
||||
let node2 = &i2.1;
|
||||
|
||||
if matches!(&node1.node_type, NodeType::File)
|
||||
&& matches!(&node2.node_type, NodeType::File)
|
||||
&& node1.meta == node2.meta
|
||||
{
|
||||
debug!("checking {}", path.display());
|
||||
checked += 1;
|
||||
if !file_identical(path, node1, node2)? {
|
||||
println!("M {path:?}");
|
||||
}
|
||||
} else {
|
||||
debug!("not checking {}: metadata changed", path.display());
|
||||
}
|
||||
item1 = tree_streamer1.next().transpose()?;
|
||||
item2 = tree_streamer2.next().transpose()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("checked {checked} files.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
61
src/commands/docs.rs
Normal file
61
src/commands/docs.rs
Normal file
@ -0,0 +1,61 @@
|
||||
//! `docs` subcommand
|
||||
|
||||
use abscissa_core::{status_err, Application, Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::{
|
||||
application::constants::{RUSTIC_CONFIG_DOCS_URL, RUSTIC_DEV_DOCS_URL, RUSTIC_DOCS_URL},
|
||||
RUSTIC_APP,
|
||||
};
|
||||
|
||||
#[derive(Command, Debug, Clone, Copy, Default, Subcommand, Runnable)]
|
||||
enum DocsTypeSubcommand {
|
||||
#[default]
|
||||
/// Show the user documentation
|
||||
User,
|
||||
/// Show the development documentation
|
||||
Dev,
|
||||
/// Show the configuration documentation
|
||||
Config,
|
||||
}
|
||||
|
||||
/// Opens the documentation in the default browser.
|
||||
#[derive(Clone, Command, Default, Debug, clap::Parser)]
|
||||
pub struct DocsCmd {
|
||||
#[clap(subcommand)]
|
||||
cmd: Option<DocsTypeSubcommand>,
|
||||
}
|
||||
|
||||
impl Runnable for DocsCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl DocsCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let user_string = match self.cmd {
|
||||
// Default to user docs if no subcommand is provided
|
||||
Some(DocsTypeSubcommand::User) | None => {
|
||||
open::that(RUSTIC_DOCS_URL)?;
|
||||
format!("Opening the user documentation at {RUSTIC_DOCS_URL}")
|
||||
}
|
||||
Some(DocsTypeSubcommand::Dev) => {
|
||||
open::that(RUSTIC_DEV_DOCS_URL)?;
|
||||
format!("Opening the development documentation at {RUSTIC_DEV_DOCS_URL}")
|
||||
}
|
||||
Some(DocsTypeSubcommand::Config) => {
|
||||
open::that(RUSTIC_CONFIG_DOCS_URL)?;
|
||||
format!("Opening the configuration documentation at {RUSTIC_CONFIG_DOCS_URL}")
|
||||
}
|
||||
};
|
||||
|
||||
println!("{user_string}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
//! `dump` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use log::warn;
|
||||
use rustic_core::{
|
||||
repofile::{Node, NodeType},
|
||||
vfs::OpenFile,
|
||||
LsOptions,
|
||||
};
|
||||
use tar::{Builder, EntryType, Header};
|
||||
|
||||
/// `dump` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
@ -11,11 +20,19 @@ pub(crate) struct DumpCmd {
|
||||
/// file from snapshot to dump
|
||||
#[clap(value_name = "SNAPSHOT[:PATH]")]
|
||||
snap: String,
|
||||
|
||||
/// Listing options
|
||||
#[clap(flatten)]
|
||||
ls_opts: LsOptions,
|
||||
}
|
||||
|
||||
impl Runnable for DumpCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -23,16 +40,116 @@ impl Runnable for DumpCmd {
|
||||
}
|
||||
|
||||
impl DumpCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
let node =
|
||||
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
repo.dump(&node, &mut stdout)?;
|
||||
if node.is_file() {
|
||||
repo.dump(&node, &mut stdout)?;
|
||||
} else {
|
||||
dump_tar(&repo, &node, &mut stdout, &self.ls_opts)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_tar(
|
||||
repo: &CliIndexedRepo,
|
||||
node: &Node,
|
||||
w: &mut impl Write,
|
||||
ls_opts: &LsOptions,
|
||||
) -> Result<()> {
|
||||
let mut ar = Builder::new(w);
|
||||
for item in repo.ls(node, ls_opts)? {
|
||||
let (path, node) = item?;
|
||||
let mut header = Header::new_gnu();
|
||||
|
||||
let entry_type = match &node.node_type {
|
||||
NodeType::File => EntryType::Regular,
|
||||
NodeType::Dir => EntryType::Directory,
|
||||
NodeType::Symlink { .. } => EntryType::Symlink,
|
||||
NodeType::Dev { .. } => EntryType::Block,
|
||||
NodeType::Chardev { .. } => EntryType::Char,
|
||||
NodeType::Fifo => EntryType::Fifo,
|
||||
NodeType::Socket => {
|
||||
warn!(
|
||||
"socket is not supported. Adding {} as empty file",
|
||||
path.display()
|
||||
);
|
||||
EntryType::Regular
|
||||
}
|
||||
};
|
||||
header.set_entry_type(entry_type);
|
||||
header.set_size(node.meta.size);
|
||||
if let Some(mode) = node.meta.mode {
|
||||
// TODO: this is some go-mapped mode, but lower bits are the standard unix mode bits -> is this ok?
|
||||
header.set_mode(mode);
|
||||
}
|
||||
if let Some(uid) = node.meta.uid {
|
||||
header.set_uid(uid.into());
|
||||
}
|
||||
if let Some(gid) = node.meta.gid {
|
||||
header.set_uid(gid.into());
|
||||
}
|
||||
if let Some(user) = &node.meta.user {
|
||||
header.set_username(user)?;
|
||||
}
|
||||
if let Some(group) = &node.meta.group {
|
||||
header.set_groupname(group)?;
|
||||
}
|
||||
if let Some(mtime) = node.meta.mtime {
|
||||
header.set_mtime(mtime.timestamp().try_into().unwrap_or_default());
|
||||
}
|
||||
|
||||
// handle special files
|
||||
if node.is_symlink() {
|
||||
header.set_link_name(node.node_type.to_link())?;
|
||||
}
|
||||
match node.node_type {
|
||||
NodeType::Dev { device } | NodeType::Chardev { device } => {
|
||||
header.set_device_minor(device as u32)?;
|
||||
header.set_device_major((device << 32) as u32)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if node.is_file() {
|
||||
// write file content if this is a regular file
|
||||
let open_file = OpenFileReader {
|
||||
repo,
|
||||
open_file: repo.open_file(&node)?,
|
||||
offset: 0,
|
||||
};
|
||||
ar.append_data(&mut header, path, open_file)?;
|
||||
} else {
|
||||
let data: &[u8] = &[];
|
||||
ar.append_data(&mut header, path, data)?;
|
||||
}
|
||||
}
|
||||
// finish writing
|
||||
_ = ar.into_inner()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct OpenFileReader<'a> {
|
||||
repo: &'a CliIndexedRepo,
|
||||
open_file: OpenFile,
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Read for OpenFileReader<'a> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let data = self
|
||||
.repo
|
||||
.read_file_at(&self.open_file, self.offset, buf.len())
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
|
||||
let n = data.len();
|
||||
buf[..n].copy_from_slice(&data);
|
||||
self.offset += n;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
155
src/commands/find.rs
Normal file
155
src/commands/find.rs
Normal file
@ -0,0 +1,155 @@
|
||||
//! `find` subcommand
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use clap::ValueHint;
|
||||
use globset::{Glob, GlobBuilder, GlobSetBuilder};
|
||||
use itertools::Itertools;
|
||||
|
||||
use rustic_core::{
|
||||
repofile::{Node, SnapshotFile},
|
||||
FindMatches, FindNode, SnapshotGroupCriterion,
|
||||
};
|
||||
|
||||
use super::ls::print_node;
|
||||
|
||||
/// `find` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
pub(crate) struct FindCmd {
|
||||
/// pattern to find (can be specified multiple times)
|
||||
#[clap(long, value_name = "PATTERN", conflicts_with = "path")]
|
||||
glob: Vec<String>,
|
||||
|
||||
/// pattern to find case-insensitive (can be specified multiple times)
|
||||
#[clap(long, value_name = "PATTERN", conflicts_with = "path")]
|
||||
iglob: Vec<String>,
|
||||
|
||||
/// exact path to find
|
||||
#[clap(long, value_name = "PATH", value_hint = ValueHint::AnyPath)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Snapshots to search in. If none is given, use filter options to filter from all snapshots
|
||||
#[clap(value_name = "ID")]
|
||||
ids: Vec<String>,
|
||||
|
||||
/// Group snapshots by any combination of host,label,paths,tags
|
||||
#[clap(
|
||||
long,
|
||||
short = 'g',
|
||||
value_name = "CRITERION",
|
||||
default_value = "host,label,paths"
|
||||
)]
|
||||
group_by: SnapshotGroupCriterion,
|
||||
|
||||
/// Show all snapshots instead of summarizing snapshots with identical search results
|
||||
#[clap(long)]
|
||||
all: bool,
|
||||
|
||||
/// Also show snapshots which don't contain a search result.
|
||||
#[clap(long)]
|
||||
show_misses: bool,
|
||||
|
||||
/// Show uid/gid instead of user/group
|
||||
#[clap(long, long("numeric-uid-gid"))]
|
||||
numeric_id: bool,
|
||||
}
|
||||
|
||||
impl Runnable for FindCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl FindCmd {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let groups = repo.get_snapshot_group(&self.ids, self.group_by, |sn| {
|
||||
config.snapshot_filter.matches(sn)
|
||||
})?;
|
||||
for (group, mut snapshots) in groups {
|
||||
snapshots.sort_unstable();
|
||||
if !group.is_empty() {
|
||||
println!("\nsearching in snapshots group {group}...");
|
||||
}
|
||||
let ids = snapshots.iter().map(|sn| sn.tree);
|
||||
if let Some(path) = &self.path {
|
||||
let FindNode { nodes, matches } = repo.find_nodes_from_path(ids, path)?;
|
||||
for (idx, g) in &matches
|
||||
.iter()
|
||||
.zip(snapshots.iter())
|
||||
.chunk_by(|(idx, _)| *idx)
|
||||
{
|
||||
self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn));
|
||||
if let Some(idx) = idx {
|
||||
print_node(&nodes[*idx], path, self.numeric_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
for glob in &self.glob {
|
||||
_ = builder.add(Glob::new(glob)?);
|
||||
}
|
||||
for glob in &self.iglob {
|
||||
_ = builder.add(GlobBuilder::new(glob).case_insensitive(true).build()?);
|
||||
}
|
||||
let globset = builder.build()?;
|
||||
let matches = |path: &Path, _: &Node| {
|
||||
globset.is_match(path) || path.file_name().is_some_and(|f| globset.is_match(f))
|
||||
};
|
||||
let FindMatches {
|
||||
paths,
|
||||
nodes,
|
||||
matches,
|
||||
} = repo.find_matching_nodes(ids, &matches)?;
|
||||
for (idx, g) in &matches
|
||||
.iter()
|
||||
.zip(snapshots.iter())
|
||||
.chunk_by(|(idx, _)| *idx)
|
||||
{
|
||||
self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn));
|
||||
for (path_idx, node_idx) in idx {
|
||||
print_node(&nodes[*node_idx], &paths[*path_idx], self.numeric_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_identical_snapshots<'a>(
|
||||
&self,
|
||||
mut idx: impl Iterator,
|
||||
mut g: impl Iterator<Item = &'a SnapshotFile>,
|
||||
) {
|
||||
let empty_result = idx.next().is_none();
|
||||
let not = if empty_result { "not " } else { "" };
|
||||
if self.show_misses || !empty_result {
|
||||
if self.all {
|
||||
for sn in g {
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
|
||||
println!("{not}found in {} from {time}", sn.id);
|
||||
}
|
||||
} else {
|
||||
let sn = g.next().unwrap();
|
||||
let count = g.count();
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
|
||||
match count {
|
||||
0 => println!("{not}found in {} from {time}", sn.id),
|
||||
count => println!("{not}found in {} from {time} (+{count})", sn.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,15 @@
|
||||
//! `forget` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository, helpers::table_with_titles, status_err, Application, RusticConfig,
|
||||
RUSTIC_APP,
|
||||
};
|
||||
use crate::repository::CliOpenRepo;
|
||||
use crate::{helpers::table_with_titles, status_err, Application, RusticConfig, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{config::Override, Shutdown};
|
||||
use abscissa_core::{Command, FrameworkError, Runnable};
|
||||
use anyhow::Result;
|
||||
|
||||
use merge::Merge;
|
||||
use serde::Deserialize;
|
||||
use chrono::Local;
|
||||
use conflate::Merge;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
||||
use crate::{commands::prune::PruneCmd, filtering::SnapshotFilter};
|
||||
@ -63,17 +62,18 @@ impl Override<RusticConfig> for ForgetCmd {
|
||||
|
||||
/// Forget options
|
||||
#[serde_as]
|
||||
#[derive(Clone, Default, Debug, clap::Parser, Deserialize, Merge)]
|
||||
#[derive(Clone, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case")]
|
||||
pub struct ForgetOptions {
|
||||
/// Group snapshots by any combination of host,label,paths,tags (default: "host,label,paths")
|
||||
#[clap(long, short = 'g', value_name = "CRITERION")]
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
group_by: Option<SnapshotGroupCriterion>,
|
||||
|
||||
/// Also prune the repository
|
||||
#[clap(long)]
|
||||
#[merge(strategy = merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
prune: bool,
|
||||
|
||||
/// Snapshot filter options
|
||||
@ -89,7 +89,11 @@ pub struct ForgetOptions {
|
||||
|
||||
impl Runnable for ForgetCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -97,9 +101,11 @@ impl Runnable for ForgetCmd {
|
||||
}
|
||||
|
||||
impl ForgetCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
/// be careful about self vs `RUSTIC_APP.config()` usage
|
||||
/// only the `RUSTIC_APP.config()` involves the TOML and ENV merged configurations
|
||||
/// see <https://github.com/rustic-rs/rustic/issues/1242>
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let group_by = config.forget.group_by.unwrap_or_default();
|
||||
|
||||
@ -108,15 +114,26 @@ impl ForgetCmd {
|
||||
config.forget.filter.matches(sn)
|
||||
})?
|
||||
} else {
|
||||
let now = Local::now();
|
||||
let item = ForgetGroup {
|
||||
group: SnapshotGroup::default(),
|
||||
snapshots: repo
|
||||
.get_snapshots(&self.ids)?
|
||||
.into_iter()
|
||||
.map(|sn| ForgetSnapshot {
|
||||
snapshot: sn,
|
||||
keep: false,
|
||||
reasons: vec!["id argument".to_string()],
|
||||
.map(|sn| {
|
||||
if sn.must_keep(now) {
|
||||
ForgetSnapshot {
|
||||
snapshot: sn,
|
||||
keep: true,
|
||||
reasons: vec!["snapshot".to_string()],
|
||||
}
|
||||
} else {
|
||||
ForgetSnapshot {
|
||||
snapshot: sn,
|
||||
keep: false,
|
||||
reasons: vec!["id argument".to_string()],
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
@ -143,7 +160,7 @@ impl ForgetCmd {
|
||||
(_, _, true) => {}
|
||||
}
|
||||
|
||||
if self.config.prune {
|
||||
if config.forget.prune {
|
||||
let mut prune_opts = self.prune_opts.clone();
|
||||
prune_opts.opts.ignore_snaps = forget_snaps;
|
||||
prune_opts.run();
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
use abscissa_core::{status_err, Command, Runnable, Shutdown};
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::{Application, RUSTIC_APP};
|
||||
|
||||
use dialoguer::Password;
|
||||
|
||||
use crate::{repository::CliRepo, Application, RUSTIC_APP};
|
||||
|
||||
use rustic_core::{ConfigOptions, KeyOptions, OpenStatus, Repository};
|
||||
|
||||
/// `init` subcommand
|
||||
@ -23,7 +22,11 @@ pub(crate) struct InitCmd {
|
||||
|
||||
impl Runnable for InitCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -31,12 +34,9 @@ impl Runnable for InitCmd {
|
||||
}
|
||||
|
||||
impl InitCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let po = config.global.progress_options;
|
||||
let repo = Repository::new_with_progress(&config.repository, po)?;
|
||||
|
||||
// Note: This is again checked in repo.init_with_password(), however we want to inform
|
||||
// users before they are prompted to enter a password
|
||||
if repo.config_id()?.is_some() {
|
||||
@ -51,7 +51,7 @@ impl InitCmd {
|
||||
);
|
||||
}
|
||||
|
||||
let _ = init(repo, &self.key_opts, &self.config_opts)?;
|
||||
let _ = init(repo.0, &self.key_opts, &self.config_opts)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -69,7 +69,7 @@ impl InitCmd {
|
||||
/// * [`RepositoryErrorKind::OpeningPasswordFileFailed`] - If opening the password file failed
|
||||
/// * [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`] - If reading the password failed
|
||||
/// * [`RepositoryErrorKind::FromSplitError`] - If splitting the password command failed
|
||||
/// * [`RepositoryErrorKind::PasswordCommandParsingFailed`] - If parsing the password command failed
|
||||
/// * [`RepositoryErrorKind::PasswordCommandExecutionFailed`] - If executing the password command failed
|
||||
/// * [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`] - If reading the password from the command failed
|
||||
///
|
||||
/// # Returns
|
||||
@ -79,13 +79,18 @@ impl InitCmd {
|
||||
/// [`RepositoryErrorKind::OpeningPasswordFileFailed`]: rustic_core::error::RepositoryErrorKind::OpeningPasswordFileFailed
|
||||
/// [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromReaderFailed
|
||||
/// [`RepositoryErrorKind::FromSplitError`]: rustic_core::error::RepositoryErrorKind::FromSplitError
|
||||
/// [`RepositoryErrorKind::PasswordCommandParsingFailed`]: rustic_core::error::RepositoryErrorKind::PasswordCommandParsingFailed
|
||||
/// [`RepositoryErrorKind::PasswordCommandExecutionFailed`]: rustic_core::error::RepositoryErrorKind::PasswordCommandExecutionFailed
|
||||
/// [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromCommandFailed
|
||||
pub(crate) fn init<P, S>(
|
||||
repo: Repository<P, S>,
|
||||
key_opts: &KeyOptions,
|
||||
config_opts: &ConfigOptions,
|
||||
) -> Result<Repository<P, OpenStatus>> {
|
||||
let pass = init_password(&repo)?;
|
||||
Ok(repo.init_with_password(&pass, key_opts, config_opts)?)
|
||||
}
|
||||
|
||||
pub(crate) fn init_password<P, S>(repo: &Repository<P, S>) -> Result<String> {
|
||||
let pass = repo.password()?.unwrap_or_else(|| {
|
||||
match Password::new()
|
||||
.with_prompt("enter password for new key")
|
||||
@ -101,5 +106,5 @@ pub(crate) fn init<P, S>(
|
||||
}
|
||||
});
|
||||
|
||||
Ok(repo.init_with_password(&pass, key_opts, config_opts)?)
|
||||
Ok(pass)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! `key` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@ -9,7 +9,7 @@ use anyhow::Result;
|
||||
use dialoguer::Password;
|
||||
use log::info;
|
||||
|
||||
use rustic_core::{KeyOptions, Repository, RepositoryOptions};
|
||||
use rustic_core::{CommandInput, KeyOptions, RepositoryOptions};
|
||||
|
||||
/// `key` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
@ -27,10 +27,18 @@ enum KeySubCmd {
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
pub(crate) struct AddCmd {
|
||||
/// New password
|
||||
#[clap(long)]
|
||||
pub(crate) new_password: Option<String>,
|
||||
|
||||
/// File from which to read the new password
|
||||
#[clap(long)]
|
||||
pub(crate) new_password_file: Option<PathBuf>,
|
||||
|
||||
/// Command to get the new password from
|
||||
#[clap(long)]
|
||||
pub(crate) new_password_command: Option<CommandInput>,
|
||||
|
||||
/// Key options
|
||||
#[clap(flatten)]
|
||||
pub(crate) key_opts: KeyOptions,
|
||||
@ -44,7 +52,11 @@ impl Runnable for KeyCmd {
|
||||
|
||||
impl Runnable for AddCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -52,21 +64,15 @@ impl Runnable for AddCmd {
|
||||
}
|
||||
|
||||
impl AddCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
// create new Repository options which just contain password information
|
||||
let mut pass_opts = RepositoryOptions::default();
|
||||
pass_opts.password = self.new_password.clone();
|
||||
pass_opts.password_file = self.new_password_file.clone();
|
||||
pass_opts.password_command = self.new_password_command.clone();
|
||||
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
// create new "artificial" repo using the given password options
|
||||
let repo_opts = RepositoryOptions {
|
||||
password_file: self.new_password_file.clone(),
|
||||
repository: Some(String::new()), // fake repository to make Repository::new() not bail
|
||||
..Default::default()
|
||||
};
|
||||
let repo_newpass = Repository::new(&repo_opts)?;
|
||||
|
||||
let pass = repo_newpass
|
||||
.password()
|
||||
let pass = pass_opts
|
||||
.evaluate_password()
|
||||
.map_err(|err| err.into())
|
||||
.transpose()
|
||||
.unwrap_or_else(|| -> Result<_> {
|
||||
|
||||
@ -1,24 +1,29 @@
|
||||
//! `list` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use std::num::NonZero;
|
||||
|
||||
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use rustic_core::repofile::{FileType, IndexFile};
|
||||
use rustic_core::repofile::{IndexFile, IndexId, KeyId, PackId, SnapshotId};
|
||||
|
||||
/// `list` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
pub(crate) struct ListCmd {
|
||||
/// File types to list
|
||||
#[clap(value_parser=["blobs", "index", "packs", "snapshots", "keys"])]
|
||||
#[clap(value_parser=["blobs", "indexpacks", "indexcontent", "index", "packs", "snapshots", "keys"])]
|
||||
tpe: String,
|
||||
}
|
||||
|
||||
impl Runnable for ListCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -26,37 +31,73 @@ impl Runnable for ListCmd {
|
||||
}
|
||||
|
||||
impl ListCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let tpe = match self.tpe.as_str() {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
match self.tpe.as_str() {
|
||||
// special treatment for listing blobs: read the index and display it
|
||||
"blobs" => {
|
||||
"blobs" | "indexpacks" | "indexcontent" => {
|
||||
for item in repo.stream_files::<IndexFile>()? {
|
||||
let (_, index) = item?;
|
||||
index.packs.into_iter().for_each(|pack| {
|
||||
for blob in pack.blobs {
|
||||
println!("{:?} {:?}", blob.tpe, blob.id);
|
||||
for pack in index.packs {
|
||||
match self.tpe.as_str() {
|
||||
"blobs" => {
|
||||
for blob in pack.blobs {
|
||||
println!("{:?} {:?}", blob.tpe, blob.id);
|
||||
}
|
||||
}
|
||||
"indexcontent" => {
|
||||
for blob in pack.blobs {
|
||||
println!(
|
||||
"{:?} {:?} {:?} {} {}",
|
||||
blob.tpe,
|
||||
blob.id,
|
||||
pack.id,
|
||||
blob.length,
|
||||
blob.uncompressed_length.map_or(0, NonZero::get)
|
||||
);
|
||||
}
|
||||
}
|
||||
"indexpacks" => println!(
|
||||
"{:?} {:?} {} {}",
|
||||
pack.blob_type(),
|
||||
pack.id,
|
||||
pack.pack_size(),
|
||||
pack.time.map_or_else(String::new, |time| format!(
|
||||
"{}",
|
||||
time.format("%Y-%m-%d %H:%M:%S")
|
||||
))
|
||||
),
|
||||
t => {
|
||||
bail!("invalid type: {}", t);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"index" => {
|
||||
for id in repo.list::<IndexId>()? {
|
||||
println!("{id:?}");
|
||||
}
|
||||
}
|
||||
"packs" => {
|
||||
for id in repo.list::<PackId>()? {
|
||||
println!("{id:?}");
|
||||
}
|
||||
}
|
||||
"snapshots" => {
|
||||
for id in repo.list::<SnapshotId>()? {
|
||||
println!("{id:?}");
|
||||
}
|
||||
}
|
||||
"keys" => {
|
||||
for id in repo.list::<KeyId>()? {
|
||||
println!("{id:?}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
"index" => FileType::Index,
|
||||
"packs" => FileType::Pack,
|
||||
"snapshots" => FileType::Snapshot,
|
||||
"keys" => FileType::Key,
|
||||
t => {
|
||||
bail!("invalid type: {}", t);
|
||||
}
|
||||
};
|
||||
|
||||
for id in repo.list(tpe)? {
|
||||
println!("{id:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
@ -36,13 +36,21 @@ pub(crate) struct LsCmd {
|
||||
snap: String,
|
||||
|
||||
/// show summary
|
||||
#[clap(long, short = 's')]
|
||||
#[clap(long, short = 's', conflicts_with = "json")]
|
||||
summary: bool,
|
||||
|
||||
/// show long listing
|
||||
#[clap(long, short = 'l')]
|
||||
#[clap(long, short = 'l', conflicts_with = "json")]
|
||||
long: bool,
|
||||
|
||||
/// show listing in json
|
||||
#[clap(long, conflicts_with_all = ["summary", "long"])]
|
||||
json: bool,
|
||||
|
||||
/// show uid/gid instead of user/group
|
||||
#[clap(long, long("numeric-uid-gid"))]
|
||||
numeric_id: bool,
|
||||
|
||||
/// Listing options
|
||||
#[clap(flatten)]
|
||||
ls_opts: LsOptions,
|
||||
@ -50,7 +58,11 @@ pub(crate) struct LsCmd {
|
||||
|
||||
impl Runnable for LsCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -61,10 +73,10 @@ impl Runnable for LsCmd {
|
||||
///
|
||||
/// This struct is used to print a summary of the ls command.
|
||||
#[derive(Default)]
|
||||
struct Summary {
|
||||
files: usize,
|
||||
size: u64,
|
||||
dirs: usize,
|
||||
pub struct Summary {
|
||||
pub files: usize,
|
||||
pub size: u64,
|
||||
pub dirs: usize,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
@ -73,7 +85,7 @@ impl Summary {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node` - the node to update the summary with
|
||||
fn update(&mut self, node: &Node) {
|
||||
pub fn update(&mut self, node: &Node) {
|
||||
if node.is_dir() {
|
||||
self.dirs += 1;
|
||||
}
|
||||
@ -84,11 +96,42 @@ impl Summary {
|
||||
}
|
||||
}
|
||||
|
||||
impl LsCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
pub trait NodeLs {
|
||||
fn mode_str(&self) -> String;
|
||||
fn link_str(&self) -> String;
|
||||
}
|
||||
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
impl NodeLs for Node {
|
||||
fn mode_str(&self) -> String {
|
||||
format!(
|
||||
"{:>1}{:>9}",
|
||||
match self.node_type {
|
||||
NodeType::Dir => 'd',
|
||||
NodeType::Symlink { .. } => 'l',
|
||||
NodeType::Chardev { .. } => 'c',
|
||||
NodeType::Dev { .. } => 'b',
|
||||
NodeType::Fifo { .. } => 'p',
|
||||
NodeType::Socket => 's',
|
||||
_ => '-',
|
||||
},
|
||||
self.meta
|
||||
.mode
|
||||
.map(parse_permissions)
|
||||
.unwrap_or_else(|| "?????????".to_string())
|
||||
)
|
||||
}
|
||||
fn link_str(&self) -> String {
|
||||
if let NodeType::Symlink { .. } = &self.node_type {
|
||||
["->", &self.node_type.to_link().to_string_lossy()].join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LsCmd {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let node =
|
||||
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
|
||||
@ -99,14 +142,29 @@ impl LsCmd {
|
||||
|
||||
let mut summary = Summary::default();
|
||||
|
||||
if self.json {
|
||||
print!("[");
|
||||
}
|
||||
|
||||
let mut first_item = true;
|
||||
for item in repo.ls(&node, &ls_opts)? {
|
||||
let (path, node) = item?;
|
||||
summary.update(&node);
|
||||
if self.long {
|
||||
print_node(&node, &path);
|
||||
if self.json {
|
||||
if !first_item {
|
||||
print!(",");
|
||||
}
|
||||
print!("{}", serde_json::to_string(&path)?);
|
||||
} else if self.long {
|
||||
print_node(&node, &path, self.numeric_id);
|
||||
} else {
|
||||
println!("{path:?} ");
|
||||
println!("{}", path.display());
|
||||
}
|
||||
first_item = false;
|
||||
}
|
||||
|
||||
if self.json {
|
||||
println!("]");
|
||||
}
|
||||
|
||||
if self.summary {
|
||||
@ -126,34 +184,28 @@ impl LsCmd {
|
||||
///
|
||||
/// * `node` - the node to print
|
||||
/// * `path` - the path of the node
|
||||
fn print_node(node: &Node, path: &Path) {
|
||||
pub fn print_node(node: &Node, path: &Path, numeric_uid_gid: bool) {
|
||||
println!(
|
||||
"{:>1}{:>9} {:>8} {:>8} {:>9} {:>12} {path:?} {}",
|
||||
match node.node_type {
|
||||
NodeType::Dir => 'd',
|
||||
NodeType::Symlink { .. } => 'l',
|
||||
NodeType::Chardev { .. } => 'c',
|
||||
NodeType::Dev { .. } => 'b',
|
||||
NodeType::Fifo { .. } => 'p',
|
||||
NodeType::Socket => 's',
|
||||
_ => '-',
|
||||
},
|
||||
node.meta
|
||||
.mode
|
||||
.map(parse_permissions)
|
||||
.unwrap_or_else(|| "?????????".to_string()),
|
||||
node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
|
||||
node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
|
||||
"{:>10} {:>8} {:>8} {:>9} {:>17} {path:?} {}",
|
||||
node.mode_str(),
|
||||
if numeric_uid_gid {
|
||||
node.meta.uid.map(|uid| uid.to_string())
|
||||
} else {
|
||||
node.meta.user.clone()
|
||||
}
|
||||
.unwrap_or_else(|| "?".to_string()),
|
||||
if numeric_uid_gid {
|
||||
node.meta.gid.map(|uid| uid.to_string())
|
||||
} else {
|
||||
node.meta.group.clone()
|
||||
}
|
||||
.unwrap_or_else(|| "?".to_string()),
|
||||
node.meta.size,
|
||||
node.meta
|
||||
.mtime
|
||||
.map(|t| t.format("%_d %b %H:%M").to_string())
|
||||
.map(|t| t.format("%_d %b %Y %H:%M").to_string())
|
||||
.unwrap_or_else(|| "?".to_string()),
|
||||
if let NodeType::Symlink { .. } = &node.node_type {
|
||||
["->", &node.node_type.to_link().to_string_lossy()].join(" ")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
node.link_str(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! `merge` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use log::info;
|
||||
@ -31,7 +31,11 @@ pub(super) struct MergeCmd {
|
||||
|
||||
impl Runnable for MergeCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -39,9 +43,9 @@ impl Runnable for MergeCmd {
|
||||
}
|
||||
|
||||
impl MergeCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?.to_indexed_ids()?;
|
||||
let repo = repo.to_indexed_ids()?;
|
||||
|
||||
let snapshots = if self.ids.is_empty() {
|
||||
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?
|
||||
|
||||
148
src/commands/mount.rs
Normal file
148
src/commands/mount.rs
Normal file
@ -0,0 +1,148 @@
|
||||
//! `mount` subcommand
|
||||
|
||||
// ignore markdown clippy lints as we use doc-comments to generate clap help texts
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
mod fusefs;
|
||||
use fusefs::FuseFS;
|
||||
|
||||
use std::{ffi::OsStr, path::PathBuf};
|
||||
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RusticConfig, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
|
||||
use anyhow::{anyhow, Result};
|
||||
use conflate::Merge;
|
||||
use fuse_mt::{mount, FuseMT};
|
||||
use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct MountCmd {
|
||||
/// The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
path_template: Option<String>,
|
||||
|
||||
/// The time template to use to display times in the path template. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for format options. [default: "%Y-%m-%d_%H-%M-%S"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
time_template: Option<String>,
|
||||
|
||||
/// Don't allow other users to access the mount point
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
no_allow_other: bool,
|
||||
|
||||
/// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
file_access: Option<String>,
|
||||
|
||||
/// The mount point to use
|
||||
#[clap(value_name = "PATH")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
mountpoint: Option<PathBuf>,
|
||||
|
||||
/// Specify directly which snapshot/path to mount
|
||||
#[clap(value_name = "SNAPSHOT[:PATH]")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
snapshot_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Override<RusticConfig> for MountCmd {
|
||||
// Process the given command line options, overriding settings from
|
||||
// a configuration file using explicit flags taken from command-line
|
||||
// arguments.
|
||||
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
|
||||
let mut self_config = self.clone();
|
||||
// merge "mount" section from config file, if given
|
||||
self_config.merge(config.mount);
|
||||
config.mount = self_config;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runnable for MountCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl MountCmd {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let mountpoint = config
|
||||
.mount
|
||||
.mountpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("please specify a mountpoint"))?;
|
||||
|
||||
let path_template = config
|
||||
.mount
|
||||
.path_template
|
||||
.clone()
|
||||
.unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string());
|
||||
let time_template = config
|
||||
.mount
|
||||
.time_template
|
||||
.clone()
|
||||
.unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string());
|
||||
|
||||
let sn_filter = |sn: &_| config.snapshot_filter.matches(sn);
|
||||
let vfs = if let Some(snap_path) = &config.mount.snapshot_path {
|
||||
let node = repo.node_from_snapshot_path(snap_path, sn_filter)?;
|
||||
Vfs::from_dir_node(&node)
|
||||
} else {
|
||||
let snapshots = repo.get_matching_snapshots(sn_filter)?;
|
||||
Vfs::from_snapshots(
|
||||
snapshots,
|
||||
&path_template,
|
||||
&time_template,
|
||||
Latest::AsLink,
|
||||
IdenticalSnapshot::AsLink,
|
||||
)?
|
||||
};
|
||||
|
||||
let name_opt = format!("fsname=rusticfs:{}", repo.config().id);
|
||||
let mut options = vec![
|
||||
OsStr::new("-o"),
|
||||
OsStr::new(&name_opt),
|
||||
OsStr::new("-o"),
|
||||
OsStr::new("kernel_cache"),
|
||||
];
|
||||
|
||||
if !config.mount.no_allow_other {
|
||||
options.extend_from_slice(&[
|
||||
OsStr::new("-o"),
|
||||
OsStr::new("allow_other"),
|
||||
OsStr::new("-o"),
|
||||
OsStr::new("default_permissions"),
|
||||
]);
|
||||
}
|
||||
|
||||
let file_access = config.mount.file_access.as_ref().map_or_else(
|
||||
|| {
|
||||
if repo.config().is_hot == Some(true) {
|
||||
Ok(FilePolicy::Forbidden)
|
||||
} else {
|
||||
Ok(FilePolicy::Read)
|
||||
}
|
||||
},
|
||||
|s| s.parse(),
|
||||
)?;
|
||||
|
||||
let fs = FuseMT::new(FuseFS::new(repo, vfs, file_access), 1);
|
||||
mount(fs, mountpoint, &options)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
238
src/commands/mount/fusefs.rs
Normal file
238
src/commands/mount/fusefs.rs
Normal file
@ -0,0 +1,238 @@
|
||||
#[cfg(not(windows))]
|
||||
use std::os::unix::prelude::OsStrExt;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::{CString, OsStr},
|
||||
path::Path,
|
||||
sync::RwLock,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use rustic_core::{
|
||||
repofile::{Node, NodeType},
|
||||
vfs::{FilePolicy, OpenFile, Vfs},
|
||||
IndexedFull, Repository,
|
||||
};
|
||||
|
||||
use fuse_mt::{
|
||||
CallbackResult, DirectoryEntry, FileAttr, FileType, FilesystemMT, RequestInfo, ResultData,
|
||||
ResultEmpty, ResultEntry, ResultOpen, ResultReaddir, ResultSlice, ResultXattr, Xattr,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
|
||||
pub struct FuseFS<P, S> {
|
||||
repo: Repository<P, S>,
|
||||
vfs: Vfs,
|
||||
open_files: RwLock<BTreeMap<u64, OpenFile>>,
|
||||
now: SystemTime,
|
||||
file_policy: FilePolicy,
|
||||
}
|
||||
|
||||
impl<P, S: IndexedFull> FuseFS<P, S> {
|
||||
pub(crate) fn new(repo: Repository<P, S>, vfs: Vfs, file_policy: FilePolicy) -> Self {
|
||||
let open_files = RwLock::new(BTreeMap::new());
|
||||
|
||||
Self {
|
||||
repo,
|
||||
vfs,
|
||||
open_files,
|
||||
now: SystemTime::now(),
|
||||
file_policy,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_from_path(&self, path: &Path) -> Result<Node, i32> {
|
||||
self.vfs
|
||||
.node_from_path(&self.repo, path)
|
||||
.map_err(|_| libc::ENOENT)
|
||||
}
|
||||
|
||||
fn dir_entries_from_path(&self, path: &Path) -> Result<Vec<Node>, i32> {
|
||||
self.vfs
|
||||
.dir_entries_from_path(&self.repo, path)
|
||||
.map_err(|_| libc::ENOENT)
|
||||
}
|
||||
}
|
||||
|
||||
fn node_to_filetype(node: &Node) -> FileType {
|
||||
match node.node_type {
|
||||
NodeType::File => FileType::RegularFile,
|
||||
NodeType::Dir => FileType::Directory,
|
||||
NodeType::Symlink { .. } => FileType::Symlink,
|
||||
NodeType::Chardev { .. } => FileType::CharDevice,
|
||||
NodeType::Dev { .. } => FileType::BlockDevice,
|
||||
NodeType::Fifo => FileType::NamedPipe,
|
||||
NodeType::Socket => FileType::Socket,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type_to_rdev(tpe: &NodeType) -> u32 {
|
||||
u32::try_from(match tpe {
|
||||
NodeType::Dev { device } | NodeType::Chardev { device } => *device,
|
||||
_ => 0,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn node_to_linktarget(node: &Node) -> Option<&OsStr> {
|
||||
if node.is_symlink() {
|
||||
Some(node.node_type.to_link().as_os_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn node_to_file_attr(node: &Node, now: SystemTime) -> FileAttr {
|
||||
FileAttr {
|
||||
// Size in bytes
|
||||
size: node.meta.size,
|
||||
// Size in blocks
|
||||
blocks: 0,
|
||||
// Time of last access
|
||||
atime: node.meta.atime.map(SystemTime::from).unwrap_or(now),
|
||||
// Time of last modification
|
||||
mtime: node.meta.mtime.map(SystemTime::from).unwrap_or(now),
|
||||
// Time of last metadata change
|
||||
ctime: node.meta.ctime.map(SystemTime::from).unwrap_or(now),
|
||||
// Time of creation (macOS only)
|
||||
crtime: now,
|
||||
// Kind of file (directory, file, pipe, etc.)
|
||||
kind: node_to_filetype(node),
|
||||
// Permissions
|
||||
perm: node.meta.mode.unwrap_or(0o755) as u16,
|
||||
// Number of hard links
|
||||
nlink: node.meta.links.try_into().unwrap_or(1),
|
||||
// User ID
|
||||
uid: node.meta.uid.unwrap_or(0),
|
||||
// Group ID
|
||||
gid: node.meta.gid.unwrap_or(0),
|
||||
// Device ID (if special file)
|
||||
rdev: node_type_to_rdev(&node.node_type),
|
||||
// Flags (macOS only; see chflags(2))
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl<P, S: IndexedFull> FilesystemMT for FuseFS<P, S> {
|
||||
fn getattr(&self, _req: RequestInfo, path: &Path, _fh: Option<u64>) -> ResultEntry {
|
||||
let node = self.node_from_path(path)?;
|
||||
Ok((Duration::from_secs(1), node_to_file_attr(&node, self.now)))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn readlink(&self, _req: RequestInfo, path: &Path) -> ResultData {
|
||||
let target = node_to_linktarget(&self.node_from_path(path)?)
|
||||
.ok_or(libc::ENOSYS)?
|
||||
.as_bytes()
|
||||
.to_vec();
|
||||
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn open(&self, _req: RequestInfo, path: &Path, _flags: u32) -> ResultOpen {
|
||||
if matches!(self.file_policy, FilePolicy::Forbidden) {
|
||||
return Err(libc::ENOTSUP);
|
||||
}
|
||||
let node = self.node_from_path(path)?;
|
||||
let open = self.repo.open_file(&node).map_err(|_| libc::ENOSYS)?;
|
||||
let fh = {
|
||||
let mut open_files = self.open_files.write().unwrap();
|
||||
let fh = open_files.last_key_value().map_or(0, |(fh, _)| *fh + 1);
|
||||
_ = open_files.insert(fh, open);
|
||||
fh
|
||||
};
|
||||
Ok((fh, 0))
|
||||
}
|
||||
|
||||
fn release(
|
||||
&self,
|
||||
_req: RequestInfo,
|
||||
_path: &Path,
|
||||
fh: u64,
|
||||
_flags: u32,
|
||||
_lock_owner: u64,
|
||||
_flush: bool,
|
||||
) -> ResultEmpty {
|
||||
_ = self.open_files.write().unwrap().remove(&fh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
_req: RequestInfo,
|
||||
_path: &Path,
|
||||
fh: u64,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
|
||||
callback: impl FnOnce(ResultSlice<'_>) -> CallbackResult,
|
||||
) -> CallbackResult {
|
||||
if let Some(open_file) = self.open_files.read().unwrap().get(&fh) {
|
||||
if let Ok(data) =
|
||||
self.repo
|
||||
.read_file_at(open_file, offset.try_into().unwrap(), size as usize)
|
||||
{
|
||||
return callback(Ok(&data));
|
||||
}
|
||||
}
|
||||
callback(Err(libc::ENOSYS))
|
||||
}
|
||||
|
||||
fn opendir(&self, _req: RequestInfo, _path: &Path, _flags: u32) -> ResultOpen {
|
||||
Ok((0, 0))
|
||||
}
|
||||
|
||||
fn readdir(&self, _req: RequestInfo, path: &Path, _fh: u64) -> ResultReaddir {
|
||||
let nodes = self.dir_entries_from_path(path)?;
|
||||
|
||||
let result = nodes
|
||||
.into_iter()
|
||||
.map(|node| DirectoryEntry {
|
||||
name: node.name(),
|
||||
kind: node_to_filetype(&node),
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn releasedir(&self, _req: RequestInfo, _path: &Path, _fh: u64, _flags: u32) -> ResultEmpty {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listxattr(&self, _req: RequestInfo, path: &Path, size: u32) -> ResultXattr {
|
||||
let node = self.node_from_path(path)?;
|
||||
let xattrs = node
|
||||
.meta
|
||||
.extended_attributes
|
||||
.into_iter()
|
||||
// convert into null-terminated [u8]
|
||||
.map(|a| CString::new(a.name).unwrap().into_bytes_with_nul())
|
||||
.concat();
|
||||
|
||||
if size == 0 {
|
||||
Ok(Xattr::Size(u32::try_from(xattrs.len()).unwrap()))
|
||||
} else {
|
||||
Ok(Xattr::Data(xattrs))
|
||||
}
|
||||
}
|
||||
|
||||
fn getxattr(&self, _req: RequestInfo, path: &Path, name: &OsStr, size: u32) -> ResultXattr {
|
||||
let node = self.node_from_path(path)?;
|
||||
match node
|
||||
.meta
|
||||
.extended_attributes
|
||||
.into_iter()
|
||||
.find(|a| name == OsStr::new(&a.name))
|
||||
{
|
||||
None => Err(libc::ENOSYS),
|
||||
Some(attr) => {
|
||||
let value = attr.value.unwrap_or_default();
|
||||
if size == 0 {
|
||||
Ok(Xattr::Size(u32::try_from(value.len()).unwrap()))
|
||||
} else {
|
||||
Ok(Xattr::Data(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
//! `prune` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
|
||||
helpers::bytes_size_to_string, repository::CliOpenRepo, status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use log::debug;
|
||||
@ -21,7 +21,11 @@ pub(crate) struct PruneCmd {
|
||||
|
||||
impl Runnable for PruneCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -29,18 +33,17 @@ impl Runnable for PruneCmd {
|
||||
}
|
||||
|
||||
impl PruneCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let pruner = repo.prune_plan(&self.opts)?;
|
||||
let prune_plan = repo.prune_plan(&self.opts)?;
|
||||
|
||||
print_stats(&pruner.stats);
|
||||
print_stats(&prune_plan.stats);
|
||||
|
||||
if config.global.dry_run {
|
||||
repo.warm_up(pruner.repack_packs().into_iter())?;
|
||||
repo.warm_up(prune_plan.repack_packs().into_iter())?;
|
||||
} else {
|
||||
pruner.do_prune(&repo, &self.opts)?;
|
||||
repo.prune(&self.opts, prune_plan)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -58,6 +61,9 @@ fn print_stats(stats: &PruneStats) {
|
||||
let blob_stat = stats.blobs_sum();
|
||||
let size_stat = stats.size_sum();
|
||||
|
||||
debug!("statistics:");
|
||||
debug!("{:#?}", stats.debug);
|
||||
|
||||
debug!(
|
||||
"used: {:>10} blobs, {:>10}",
|
||||
blob_stat.used,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
//! `repair` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{
|
||||
repository::{CliIndexedRepo, CliOpenRepo},
|
||||
status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
|
||||
use anyhow::Result;
|
||||
@ -50,7 +53,8 @@ impl Runnable for RepairCmd {
|
||||
|
||||
impl Runnable for IndexSubCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
let config = RUSTIC_APP.config();
|
||||
if let Err(err) = config.repository.run_open(|repo| self.inner_run(repo)) {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -58,9 +62,8 @@ impl Runnable for IndexSubCmd {
|
||||
}
|
||||
|
||||
impl IndexSubCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
repo.repair_index(&self.opts, config.global.dry_run)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -68,7 +71,8 @@ impl IndexSubCmd {
|
||||
|
||||
impl Runnable for SnapSubCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
let config = RUSTIC_APP.config();
|
||||
if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -76,9 +80,8 @@ impl Runnable for SnapSubCmd {
|
||||
}
|
||||
|
||||
impl SnapSubCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
let snaps = if self.ids.is_empty() {
|
||||
repo.get_all_snapshots()?
|
||||
} else {
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
//! `repoinfo` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
|
||||
helpers::{bytes_size_to_string, table_right_from},
|
||||
repository::CliRepo,
|
||||
status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::helpers::table_right_from;
|
||||
use anyhow::Result;
|
||||
use rustic_core::{IndexInfos, RepoFileInfo, RepoFileInfos, Repository};
|
||||
use rustic_core::{IndexInfos, RepoFileInfo, RepoFileInfos};
|
||||
|
||||
/// `repoinfo` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
@ -29,7 +30,11 @@ pub(crate) struct RepoInfoCmd {
|
||||
|
||||
impl Runnable for RepoInfoCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -47,22 +52,13 @@ struct Infos {
|
||||
}
|
||||
|
||||
impl RepoInfoCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
fn inner_run(&self, repo: CliRepo) -> Result<()> {
|
||||
let infos = Infos {
|
||||
files: (!self.only_index)
|
||||
.then(|| {
|
||||
let po = config.global.progress_options;
|
||||
let repo = Repository::new_with_progress(&config.repository, po)?;
|
||||
repo.infos_files()
|
||||
})
|
||||
.then(|| -> Result<_> { Ok(repo.infos_files()?) })
|
||||
.transpose()?,
|
||||
index: (!self.only_files)
|
||||
.then(|| -> Result<_> {
|
||||
let repo = open_repository(&config)?;
|
||||
Ok(repo.infos_index()?)
|
||||
})
|
||||
.then(|| -> Result<_> { Ok(repo.open()?.infos_index()?) })
|
||||
.transpose()?,
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! `restore` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
|
||||
helpers::bytes_size_to_string, repository::CliIndexedRepo, status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
@ -41,7 +41,11 @@ pub(crate) struct RestoreCmd {
|
||||
}
|
||||
impl Runnable for RestoreCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -49,10 +53,9 @@ impl Runnable for RestoreCmd {
|
||||
}
|
||||
|
||||
impl RestoreCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let dry_run = config.global.dry_run;
|
||||
let repo = open_repository(&config)?.to_indexed()?;
|
||||
|
||||
let node =
|
||||
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
|
||||
@ -64,7 +67,7 @@ impl RestoreCmd {
|
||||
|
||||
let dest = LocalDestination::new(&self.dest, true, !node.is_dir())?;
|
||||
|
||||
let restore_infos = repo.prepare_restore(&self.opts, ls.clone(), &dest, dry_run)?;
|
||||
let restore_infos = repo.prepare_restore(&self.opts, ls, &dest, dry_run)?;
|
||||
|
||||
let fs = restore_infos.stats.files;
|
||||
println!(
|
||||
@ -94,6 +97,10 @@ impl RestoreCmd {
|
||||
if dry_run {
|
||||
repo.warm_up(restore_infos.to_packs().into_iter())?;
|
||||
} else {
|
||||
// save some memory
|
||||
let repo = repo.drop_data_from_index();
|
||||
|
||||
let ls = repo.ls(&node, &ls_opts)?;
|
||||
repo.restore(restore_infos, &self.opts, ls, &dest)?;
|
||||
println!("restore done.");
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
//! `show-config` subcommand
|
||||
|
||||
use crate::{Application, RUSTIC_APP};
|
||||
use crate::{status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable};
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
use anyhow::Result;
|
||||
use toml::to_string_pretty;
|
||||
|
||||
/// `show-config` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
@ -10,7 +12,17 @@ pub(crate) struct ShowConfigCmd {}
|
||||
|
||||
impl Runnable for ShowConfigCmd {
|
||||
fn run(&self) {
|
||||
let config = RUSTIC_APP.config();
|
||||
println!("{config:#?}");
|
||||
if let Err(err) = self.inner_run() {
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowConfigCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
let config = to_string_pretty(RUSTIC_APP.config().as_ref())?;
|
||||
println!("{config}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
//! `smapshot` subcommand
|
||||
|
||||
use crate::{
|
||||
commands::open_repository,
|
||||
helpers::{bold_cell, bytes_size_to_string, table, table_right_from},
|
||||
repository::CliOpenRepo,
|
||||
status_err, Application, RUSTIC_APP,
|
||||
};
|
||||
|
||||
@ -17,6 +17,9 @@ use rustic_core::{
|
||||
SnapshotGroupCriterion,
|
||||
};
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
use super::tui;
|
||||
|
||||
/// `snapshot` subcommand
|
||||
#[derive(clap::Parser, Command, Debug)]
|
||||
pub(crate) struct SnapshotCmd {
|
||||
@ -44,11 +47,20 @@ pub(crate) struct SnapshotCmd {
|
||||
/// Show all snapshots instead of summarizing identical follow-up snapshots
|
||||
#[clap(long, conflicts_with_all = &["long", "json"])]
|
||||
all: bool,
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
/// Run in interactive UI mode
|
||||
#[clap(long, short)]
|
||||
pub interactive: bool,
|
||||
}
|
||||
|
||||
impl Runnable for SnapshotCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -56,9 +68,13 @@ impl Runnable for SnapshotCmd {
|
||||
}
|
||||
|
||||
impl SnapshotCmd {
|
||||
fn inner_run(&self) -> Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
#[cfg(feature = "tui")]
|
||||
if self.interactive {
|
||||
return tui::run(self.group_by);
|
||||
}
|
||||
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let groups = repo.get_snapshot_group(&self.ids, self.group_by, |sn| {
|
||||
config.snapshot_filter.matches(sn)
|
||||
@ -80,40 +96,17 @@ impl SnapshotCmd {
|
||||
|
||||
if self.long {
|
||||
for snap in snapshots {
|
||||
snap.print_table();
|
||||
let mut table = table();
|
||||
|
||||
let add_entry = |title: &str, value: String| {
|
||||
_ = table.add_row([bold_cell(title), Cell::new(value)]);
|
||||
};
|
||||
fill_table(&snap, add_entry);
|
||||
|
||||
println!("{table}");
|
||||
println!();
|
||||
}
|
||||
} else {
|
||||
let snap_to_table = |(sn, count): (SnapshotFile, usize)| {
|
||||
let tags = sn.tags.formatln();
|
||||
let paths = sn.paths.formatln();
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
|
||||
let (files, dirs, size) = sn.summary.as_ref().map_or_else(
|
||||
|| ("?".to_string(), "?".to_string(), "?".to_string()),
|
||||
|s| {
|
||||
(
|
||||
s.total_files_processed.to_string(),
|
||||
s.total_dirs_processed.to_string(),
|
||||
bytes_size_to_string(s.total_bytes_processed),
|
||||
)
|
||||
},
|
||||
);
|
||||
let id = match count {
|
||||
0 => format!("{}", sn.id),
|
||||
count => format!("{} (+{})", sn.id, count),
|
||||
};
|
||||
[
|
||||
id,
|
||||
time.to_string(),
|
||||
sn.hostname,
|
||||
sn.label,
|
||||
tags,
|
||||
paths,
|
||||
files,
|
||||
dirs,
|
||||
size,
|
||||
]
|
||||
};
|
||||
|
||||
let mut table = table_right_from(
|
||||
6,
|
||||
[
|
||||
@ -121,14 +114,19 @@ impl SnapshotCmd {
|
||||
],
|
||||
);
|
||||
|
||||
let snapshots: Vec<_> = snapshots
|
||||
.into_iter()
|
||||
.group_by(|sn| if self.all { sn.id } else { sn.tree })
|
||||
.into_iter()
|
||||
.map(|(_, mut g)| (g.next().unwrap(), g.count()))
|
||||
.map(snap_to_table)
|
||||
.collect();
|
||||
_ = table.add_rows(snapshots);
|
||||
if self.all {
|
||||
// Add all snapshots to output table
|
||||
_ = table.add_rows(snapshots.into_iter().map(|sn| snap_to_table(&sn, 0)));
|
||||
} else {
|
||||
// Group snapshts by treeid and output into table
|
||||
_ = table.add_rows(
|
||||
snapshots
|
||||
.into_iter()
|
||||
.chunk_by(|sn| sn.tree)
|
||||
.into_iter()
|
||||
.map(|(_, mut g)| snap_to_table(&g.next().unwrap(), g.count())),
|
||||
);
|
||||
}
|
||||
println!("{table}");
|
||||
}
|
||||
println!("{count} snapshot(s)");
|
||||
@ -141,99 +139,115 @@ impl SnapshotCmd {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to print a table
|
||||
trait PrintTable {
|
||||
/// Print a table
|
||||
fn print_table(&self);
|
||||
pub fn snap_to_table(sn: &SnapshotFile, count: usize) -> [String; 9] {
|
||||
let tags = sn.tags.formatln();
|
||||
let paths = sn.paths.formatln();
|
||||
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
|
||||
let (files, dirs, size) = sn.summary.as_ref().map_or_else(
|
||||
|| ("?".to_string(), "?".to_string(), "?".to_string()),
|
||||
|s| {
|
||||
(
|
||||
s.total_files_processed.to_string(),
|
||||
s.total_dirs_processed.to_string(),
|
||||
bytes_size_to_string(s.total_bytes_processed),
|
||||
)
|
||||
},
|
||||
);
|
||||
let id = match count {
|
||||
0 => format!("{}", sn.id),
|
||||
count => format!("{} (+{})", sn.id, count),
|
||||
};
|
||||
[
|
||||
id,
|
||||
time.to_string(),
|
||||
sn.hostname.clone(),
|
||||
sn.label.clone(),
|
||||
tags,
|
||||
paths,
|
||||
files,
|
||||
dirs,
|
||||
size,
|
||||
]
|
||||
}
|
||||
|
||||
impl PrintTable for SnapshotFile {
|
||||
fn print_table(&self) {
|
||||
let mut table = table();
|
||||
|
||||
let mut add_entry = |title: &str, value: String| {
|
||||
_ = table.add_row([bold_cell(title), Cell::new(value)]);
|
||||
};
|
||||
|
||||
add_entry("Snapshot", self.id.to_hex().to_string());
|
||||
// note that if original was not set, it is set to self.id by the load process
|
||||
if self.original != Some(self.id) {
|
||||
add_entry("Original ID", self.original.unwrap().to_hex().to_string());
|
||||
pub fn fill_table(snap: &SnapshotFile, mut add_entry: impl FnMut(&str, String)) {
|
||||
add_entry("Snapshot", snap.id.to_hex().to_string());
|
||||
// note that if original was not set, it is set to snap.id by the load process
|
||||
if let Some(original) = snap.original {
|
||||
if original != snap.id {
|
||||
add_entry("Original ID", original.to_hex().to_string());
|
||||
}
|
||||
add_entry("Time", self.time.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
add_entry("Generated by", self.program_version.clone());
|
||||
add_entry("Host", self.hostname.clone());
|
||||
add_entry("Label", self.label.clone());
|
||||
add_entry("Tags", self.tags.formatln());
|
||||
let delete = match self.delete {
|
||||
DeleteOption::NotSet => "not set".to_string(),
|
||||
DeleteOption::Never => "never".to_string(),
|
||||
DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")),
|
||||
};
|
||||
add_entry("Delete", delete);
|
||||
add_entry("Paths", self.paths.formatln());
|
||||
let parent = self.parent.map_or_else(
|
||||
|| "no parent snapshot".to_string(),
|
||||
|p| p.to_hex().to_string(),
|
||||
}
|
||||
add_entry("Time", snap.time.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
add_entry("Generated by", snap.program_version.clone());
|
||||
add_entry("Host", snap.hostname.clone());
|
||||
add_entry("Label", snap.label.clone());
|
||||
add_entry("Tags", snap.tags.formatln());
|
||||
let delete = match snap.delete {
|
||||
DeleteOption::NotSet => "not set".to_string(),
|
||||
DeleteOption::Never => "never".to_string(),
|
||||
DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")),
|
||||
};
|
||||
add_entry("Delete", delete);
|
||||
add_entry("Paths", snap.paths.formatln());
|
||||
let parent = snap.parent.map_or_else(
|
||||
|| "no parent snapshot".to_string(),
|
||||
|p| p.to_hex().to_string(),
|
||||
);
|
||||
add_entry("Parent", parent);
|
||||
if let Some(ref summary) = snap.summary {
|
||||
add_entry("", String::new());
|
||||
add_entry("Command", summary.command.clone());
|
||||
|
||||
let source = format!(
|
||||
"files: {} / dirs: {} / size: {}",
|
||||
summary.total_files_processed,
|
||||
summary.total_dirs_processed,
|
||||
bytes_size_to_string(summary.total_bytes_processed)
|
||||
);
|
||||
add_entry("Parent", parent);
|
||||
if let Some(ref summary) = self.summary {
|
||||
add_entry("", String::new());
|
||||
add_entry("Command", summary.command.clone());
|
||||
add_entry("Source", source);
|
||||
add_entry("", String::new());
|
||||
|
||||
let source = format!(
|
||||
"files: {} / dirs: {} / size: {}",
|
||||
summary.total_files_processed,
|
||||
summary.total_dirs_processed,
|
||||
bytes_size_to_string(summary.total_bytes_processed)
|
||||
);
|
||||
add_entry("Source", source);
|
||||
add_entry("", String::new());
|
||||
let files = format!(
|
||||
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
|
||||
summary.files_new, summary.files_changed, summary.files_unmodified,
|
||||
);
|
||||
add_entry("Files", files);
|
||||
|
||||
let files = format!(
|
||||
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
|
||||
summary.files_new, summary.files_changed, summary.files_unmodified,
|
||||
);
|
||||
add_entry("Files", files);
|
||||
let trees = format!(
|
||||
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
|
||||
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
|
||||
);
|
||||
add_entry("Dirs", trees);
|
||||
add_entry("", String::new());
|
||||
|
||||
let trees = format!(
|
||||
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
|
||||
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
|
||||
);
|
||||
add_entry("Dirs", trees);
|
||||
add_entry("", String::new());
|
||||
|
||||
let written = format!(
|
||||
"data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
|
||||
let written = format!(
|
||||
"data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
|
||||
tree: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
|
||||
total: {:>10} blobs / raw: {:>10} / packed: {:>10}",
|
||||
summary.data_blobs,
|
||||
bytes_size_to_string(summary.data_added_files),
|
||||
bytes_size_to_string(summary.data_added_files_packed),
|
||||
summary.tree_blobs,
|
||||
bytes_size_to_string(summary.data_added_trees),
|
||||
bytes_size_to_string(summary.data_added_trees_packed),
|
||||
summary.tree_blobs + summary.data_blobs,
|
||||
bytes_size_to_string(summary.data_added),
|
||||
bytes_size_to_string(summary.data_added_packed),
|
||||
);
|
||||
add_entry("Added to repo", written);
|
||||
summary.data_blobs,
|
||||
bytes_size_to_string(summary.data_added_files),
|
||||
bytes_size_to_string(summary.data_added_files_packed),
|
||||
summary.tree_blobs,
|
||||
bytes_size_to_string(summary.data_added_trees),
|
||||
bytes_size_to_string(summary.data_added_trees_packed),
|
||||
summary.tree_blobs + summary.data_blobs,
|
||||
bytes_size_to_string(summary.data_added),
|
||||
bytes_size_to_string(summary.data_added_packed),
|
||||
);
|
||||
add_entry("Added to repo", written);
|
||||
|
||||
let duration = format!(
|
||||
"backup start: {} / backup end: {} / backup duration: {}\n\
|
||||
let duration = format!(
|
||||
"backup start: {} / backup end: {} / backup duration: {}\n\
|
||||
total duration: {}",
|
||||
summary.backup_start.format("%Y-%m-%d %H:%M:%S"),
|
||||
summary.backup_end.format("%Y-%m-%d %H:%M:%S"),
|
||||
format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)),
|
||||
format_duration(std::time::Duration::from_secs_f64(summary.total_duration))
|
||||
);
|
||||
add_entry("Duration", duration);
|
||||
}
|
||||
if let Some(ref description) = self.description {
|
||||
add_entry("Description", description.clone());
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!();
|
||||
summary.backup_start.format("%Y-%m-%d %H:%M:%S"),
|
||||
summary.backup_end.format("%Y-%m-%d %H:%M:%S"),
|
||||
format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)),
|
||||
format_duration(std::time::Duration::from_secs_f64(summary.total_duration))
|
||||
);
|
||||
add_entry("Duration", duration);
|
||||
}
|
||||
if let Some(ref description) = snap.description {
|
||||
add_entry("Description", description.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
//! `tag` subcommand
|
||||
|
||||
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
|
||||
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
|
||||
|
||||
use abscissa_core::{Command, Runnable, Shutdown};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration, Local};
|
||||
|
||||
use rustic_core::{repofile::DeleteOption, StringList};
|
||||
@ -61,7 +62,11 @@ pub(crate) struct TagCmd {
|
||||
|
||||
impl Runnable for TagCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = self.inner_run() {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_open(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
@ -69,9 +74,8 @@ impl Runnable for TagCmd {
|
||||
}
|
||||
|
||||
impl TagCmd {
|
||||
fn inner_run(&self) -> anyhow::Result<()> {
|
||||
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
let repo = open_repository(&config)?;
|
||||
|
||||
let snapshots = if self.ids.is_empty() {
|
||||
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?
|
||||
|
||||
112
src/commands/tui.rs
Normal file
112
src/commands/tui.rs
Normal file
@ -0,0 +1,112 @@
|
||||
//! `tui` subcommand
|
||||
mod ls;
|
||||
mod progress;
|
||||
mod restore;
|
||||
mod snapshots;
|
||||
mod tree;
|
||||
mod widgets;
|
||||
|
||||
use crossterm::event::{KeyEvent, KeyModifiers};
|
||||
use progress::TuiProgressBars;
|
||||
use scopeguard::defer;
|
||||
use snapshots::Snapshots;
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::{Application, RUSTIC_APP};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::prelude::*;
|
||||
use rustic_core::{IndexedFull, Progress, ProgressBars, SnapshotGroupCriterion};
|
||||
|
||||
struct App<'a, P, S> {
|
||||
snapshots: Snapshots<'a, P, S>,
|
||||
}
|
||||
|
||||
pub fn run(group_by: SnapshotGroupCriterion) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
// setup terminal
|
||||
let terminal = init_terminal()?;
|
||||
let terminal = Arc::new(RwLock::new(terminal));
|
||||
|
||||
// restore terminal (even when leaving through ?, early return, or panic)
|
||||
defer! {
|
||||
reset_terminal().unwrap();
|
||||
}
|
||||
|
||||
let progress = TuiProgressBars {
|
||||
terminal: terminal.clone(),
|
||||
};
|
||||
let res = config
|
||||
.repository
|
||||
.run_indexed_with_progress(progress.clone(), |repo| {
|
||||
let p = progress.progress_spinner("starting rustic in interactive mode...");
|
||||
p.finish();
|
||||
// create app and run it
|
||||
let snapshots = Snapshots::new(&repo, config.snapshot_filter.clone(), group_by)?;
|
||||
let app = App { snapshots };
|
||||
run_app(terminal, app)
|
||||
});
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{err:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initializes the terminal.
|
||||
fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
|
||||
enable_raw_mode()?;
|
||||
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.hide_cursor()?;
|
||||
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
/// Resets the terminal.
|
||||
fn reset_terminal() -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app<B: Backend, P: ProgressBars, S: IndexedFull>(
|
||||
terminal: Arc<RwLock<Terminal<B>>>,
|
||||
mut app: App<'_, P, S>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
_ = terminal.write().unwrap().draw(|f| ui(f, &mut app))?;
|
||||
let event = event::read()?;
|
||||
use KeyCode::*;
|
||||
|
||||
if let Event::Key(KeyEvent {
|
||||
code: Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if app.snapshots.input(event)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui<P: ProgressBars, S: IndexedFull>(f: &mut Frame<'_>, app: &mut App<'_, P, S>) {
|
||||
let area = f.area();
|
||||
app.snapshots.draw(area, f);
|
||||
}
|
||||
320
src/commands/tui/ls.rs
Normal file
320
src/commands/tui/ls.rs
Normal file
@ -0,0 +1,320 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
use rustic_core::{
|
||||
repofile::{Node, SnapshotFile, Tree},
|
||||
vfs::OpenFile,
|
||||
IndexedFull, ProgressBars, Repository,
|
||||
};
|
||||
use style::palette::tailwind;
|
||||
|
||||
use crate::commands::{
|
||||
ls::{NodeLs, Summary},
|
||||
tui::{
|
||||
restore::Restore,
|
||||
widgets::{
|
||||
popup_prompt, popup_scrollable_text, popup_text, Draw, PopUpPrompt, PopUpText,
|
||||
ProcessEvent, PromptResult, SelectTable, TextInputResult, WithBlock,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use super::widgets::PopUpInput;
|
||||
|
||||
// the states this screen can be in
|
||||
enum CurrentScreen<'a, P, S> {
|
||||
Snapshot,
|
||||
ShowHelp(PopUpText),
|
||||
Restore(Restore<'a, P, S>),
|
||||
PromptExit(PopUpPrompt),
|
||||
ShowFile(PopUpInput),
|
||||
}
|
||||
|
||||
const INFO_TEXT: &str =
|
||||
"(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (v) view | (r) restore | (?) show all commands";
|
||||
|
||||
const HELP_TEXT: &str = r#"
|
||||
General Commands:
|
||||
|
||||
q,Esc : exit
|
||||
Enter : enter dir
|
||||
Backspace : return to parent dir
|
||||
v : view file contents (text files only, up to 1MiB)
|
||||
r : restore selected item
|
||||
n : toggle numeric IDs
|
||||
? : show this help page
|
||||
|
||||
"#;
|
||||
|
||||
pub(crate) struct Snapshot<'a, P, S> {
|
||||
current_screen: CurrentScreen<'a, P, S>,
|
||||
numeric: bool,
|
||||
table: WithBlock<SelectTable>,
|
||||
repo: &'a Repository<P, S>,
|
||||
snapshot: SnapshotFile,
|
||||
path: PathBuf,
|
||||
trees: Vec<(Tree, usize)>, // Stack of parent trees with position
|
||||
tree: Tree,
|
||||
}
|
||||
|
||||
pub enum SnapshotResult {
|
||||
Exit,
|
||||
Return,
|
||||
None,
|
||||
}
|
||||
|
||||
impl<'a, P: ProgressBars, S: IndexedFull> Snapshot<'a, P, S> {
|
||||
pub fn new(repo: &'a Repository<P, S>, snapshot: SnapshotFile) -> Result<Self> {
|
||||
let header = ["Name", "Size", "Mode", "User", "Group", "Time"]
|
||||
.into_iter()
|
||||
.map(Text::from)
|
||||
.collect();
|
||||
|
||||
let tree = repo.get_tree(&snapshot.tree)?;
|
||||
let mut app = Self {
|
||||
current_screen: CurrentScreen::Snapshot,
|
||||
numeric: false,
|
||||
table: WithBlock::new(SelectTable::new(header), Block::new()),
|
||||
repo,
|
||||
snapshot,
|
||||
path: PathBuf::new(),
|
||||
trees: Vec::new(),
|
||||
tree,
|
||||
};
|
||||
app.update_table();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
fn ls_row(&self, node: &Node) -> Vec<Text<'static>> {
|
||||
let (user, group) = if self.numeric {
|
||||
(
|
||||
node.meta
|
||||
.uid
|
||||
.map_or_else(|| "?".to_string(), |id| id.to_string()),
|
||||
node.meta
|
||||
.gid
|
||||
.map_or_else(|| "?".to_string(), |id| id.to_string()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
|
||||
node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
|
||||
)
|
||||
};
|
||||
let name = node.name().to_string_lossy().to_string();
|
||||
let size = node.meta.size.to_string();
|
||||
let mtime = node
|
||||
.meta
|
||||
.mtime
|
||||
.map(|t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
[name, size, node.mode_str(), user, group, mtime]
|
||||
.into_iter()
|
||||
.map(Text::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn selected_node(&self) -> Option<&Node> {
|
||||
self.table.widget.selected().map(|i| &self.tree.nodes[i])
|
||||
}
|
||||
|
||||
pub fn update_table(&mut self) {
|
||||
let old_selection = if self.tree.nodes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.table.widget.selected().unwrap_or_default())
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
let mut summary = Summary::default();
|
||||
for node in &self.tree.nodes {
|
||||
summary.update(node);
|
||||
let row = self.ls_row(node);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
self.table.widget.set_content(rows, 1);
|
||||
|
||||
self.table.block = Block::new()
|
||||
.borders(Borders::BOTTOM | Borders::TOP)
|
||||
.title(format!("{}:{}", self.snapshot.id, self.path.display()))
|
||||
.title_bottom(format!(
|
||||
"total: {}, files: {}, dirs: {}, size: {} - {}",
|
||||
self.tree.nodes.len(),
|
||||
summary.files,
|
||||
summary.dirs,
|
||||
summary.size,
|
||||
if self.numeric {
|
||||
"numeric IDs"
|
||||
} else {
|
||||
" Id names"
|
||||
}
|
||||
))
|
||||
.title_alignment(Alignment::Center);
|
||||
self.table.widget.select(old_selection);
|
||||
}
|
||||
|
||||
pub fn enter(&mut self) -> Result<()> {
|
||||
if let Some(idx) = self.table.widget.selected() {
|
||||
let node = &self.tree.nodes[idx];
|
||||
if node.is_dir() {
|
||||
self.path.push(node.name());
|
||||
let tree = self.tree.clone();
|
||||
self.tree = self.repo.get_tree(&node.subtree.unwrap())?;
|
||||
self.trees.push((tree, idx));
|
||||
}
|
||||
}
|
||||
self.table.widget.set_to(0);
|
||||
self.update_table();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn goback(&mut self) -> bool {
|
||||
_ = self.path.pop();
|
||||
if let Some((tree, idx)) = self.trees.pop() {
|
||||
self.tree = tree;
|
||||
self.table.widget.set_to(idx);
|
||||
self.update_table();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_numeric(&mut self) {
|
||||
self.numeric = !self.numeric;
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: Event) -> Result<SnapshotResult> {
|
||||
use KeyCode::*;
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::Snapshot => match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
|
||||
Enter | Right => self.enter()?,
|
||||
Backspace | Left => {
|
||||
if self.goback() {
|
||||
return Ok(SnapshotResult::Return);
|
||||
}
|
||||
}
|
||||
Esc | Char('q') => {
|
||||
self.current_screen = CurrentScreen::PromptExit(popup_prompt(
|
||||
"exit rustic",
|
||||
"do you want to exit? (y/n)".into(),
|
||||
));
|
||||
}
|
||||
Char('?') => {
|
||||
self.current_screen =
|
||||
CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
|
||||
}
|
||||
Char('n') => self.toggle_numeric(),
|
||||
Char('v') => {
|
||||
// viewing is not supported on cold repositories
|
||||
if self.repo.config().is_hot != Some(true) {
|
||||
if let Some(node) = self.selected_node() {
|
||||
if node.is_file() {
|
||||
if let Ok(data) = OpenFile::from_node(self.repo, node).read_at(
|
||||
self.repo,
|
||||
0,
|
||||
node.meta.size.min(1_000_000).try_into().unwrap(),
|
||||
) {
|
||||
// viewing is only supported for text files
|
||||
if let Ok(content) = String::from_utf8(data.to_vec()) {
|
||||
let lines = content.lines().count();
|
||||
let path = self.path.join(node.name());
|
||||
let path = path.display();
|
||||
self.current_screen =
|
||||
CurrentScreen::ShowFile(popup_scrollable_text(
|
||||
format!("{}:/{path}", self.snapshot.id),
|
||||
&content,
|
||||
(lines + 1).min(40).try_into().unwrap(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Char('r') => {
|
||||
if let Some(node) = self.selected_node() {
|
||||
let is_absolute = self
|
||||
.snapshot
|
||||
.paths
|
||||
.iter()
|
||||
.any(|p| Path::new(p).is_absolute());
|
||||
let path = self.path.join(node.name());
|
||||
let path = path.display();
|
||||
let default_targt = if is_absolute {
|
||||
format!("/{path}")
|
||||
} else {
|
||||
format!("{path}")
|
||||
};
|
||||
let restore = Restore::new(
|
||||
self.repo,
|
||||
node.clone(),
|
||||
format!("{}:/{path}", self.snapshot.id),
|
||||
&default_targt,
|
||||
);
|
||||
self.current_screen = CurrentScreen::Restore(restore);
|
||||
}
|
||||
}
|
||||
_ => self.table.input(event),
|
||||
},
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::ShowFile(prompt) => match prompt.input(event) {
|
||||
TextInputResult::Cancel | TextInputResult::Input(_) => {
|
||||
self.current_screen = CurrentScreen::Snapshot;
|
||||
}
|
||||
TextInputResult::None => {}
|
||||
},
|
||||
CurrentScreen::ShowHelp(_) => match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if matches!(key.code, Char('q') | Esc | Enter | Char(' ') | Char('?')) {
|
||||
self.current_screen = CurrentScreen::Snapshot;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::Restore(restore) => {
|
||||
if restore.input(event)? {
|
||||
self.current_screen = CurrentScreen::Snapshot;
|
||||
}
|
||||
}
|
||||
CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
|
||||
PromptResult::Ok => return Ok(SnapshotResult::Exit),
|
||||
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot,
|
||||
PromptResult::None => {}
|
||||
},
|
||||
}
|
||||
Ok(SnapshotResult::None)
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
|
||||
|
||||
if let CurrentScreen::Restore(restore) = &mut self.current_screen {
|
||||
restore.draw(area, f);
|
||||
} else {
|
||||
// draw the table
|
||||
self.table.draw(rects[0], f);
|
||||
|
||||
// draw the footer
|
||||
let buffer_bg = tailwind::SLATE.c950;
|
||||
let row_fg = tailwind::SLATE.c200;
|
||||
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
|
||||
.style(Style::new().fg(row_fg).bg(buffer_bg))
|
||||
.centered();
|
||||
f.render_widget(info_footer, rects[1]);
|
||||
}
|
||||
|
||||
// draw popups
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::Snapshot | CurrentScreen::Restore(_) => {}
|
||||
CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
|
||||
CurrentScreen::PromptExit(popup) => popup.draw(area, f),
|
||||
CurrentScreen::ShowFile(popup) => popup.draw(area, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
174
src/commands/tui/progress.rs
Normal file
174
src/commands/tui/progress.rs
Normal file
@ -0,0 +1,174 @@
|
||||
use std::io::Stdout;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bytesize::ByteSize;
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use rustic_core::{Progress, ProgressBars};
|
||||
|
||||
use super::widgets::{popup_gauge, popup_text, Draw};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TuiProgressBars {
|
||||
pub terminal: Arc<RwLock<Terminal<CrosstermBackend<Stdout>>>>,
|
||||
}
|
||||
|
||||
impl TuiProgressBars {
|
||||
fn as_progress(&self, progress_type: TuiProgressType, prefix: String) -> TuiProgress {
|
||||
TuiProgress {
|
||||
terminal: self.terminal.clone(),
|
||||
data: Arc::new(RwLock::new(CounterData::new(prefix))),
|
||||
progress_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressBars for TuiProgressBars {
|
||||
type P = TuiProgress;
|
||||
fn progress_hidden(&self) -> Self::P {
|
||||
self.as_progress(TuiProgressType::Hidden, String::new())
|
||||
}
|
||||
fn progress_spinner(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
|
||||
let progress = self.as_progress(TuiProgressType::Spinner, String::from(prefix.into()));
|
||||
progress.popup();
|
||||
progress
|
||||
}
|
||||
fn progress_counter(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
|
||||
let progress = self.as_progress(TuiProgressType::Counter, String::from(prefix.into()));
|
||||
progress.popup();
|
||||
progress
|
||||
}
|
||||
fn progress_bytes(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
|
||||
let progress = self.as_progress(TuiProgressType::Bytes, String::from(prefix.into()));
|
||||
progress.popup();
|
||||
progress
|
||||
}
|
||||
}
|
||||
|
||||
struct CounterData {
|
||||
prefix: String,
|
||||
begin: SystemTime,
|
||||
length: Option<u64>,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl CounterData {
|
||||
fn new(prefix: String) -> Self {
|
||||
Self {
|
||||
prefix,
|
||||
begin: SystemTime::now(),
|
||||
length: None,
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum TuiProgressType {
|
||||
Hidden,
|
||||
Spinner,
|
||||
Counter,
|
||||
Bytes,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TuiProgress {
|
||||
terminal: Arc<RwLock<Terminal<CrosstermBackend<Stdout>>>>,
|
||||
data: Arc<RwLock<CounterData>>,
|
||||
progress_type: TuiProgressType,
|
||||
}
|
||||
|
||||
fn fmt_duration(d: Duration) -> String {
|
||||
let seconds = d.as_secs();
|
||||
let (minutes, seconds) = (seconds / 60, seconds % 60);
|
||||
let (hours, minutes) = (minutes / 60, minutes % 60);
|
||||
format!("[{hours:02}:{minutes:02}:{seconds:02}]")
|
||||
}
|
||||
|
||||
impl TuiProgress {
|
||||
fn popup(&self) {
|
||||
let data = self.data.read().unwrap();
|
||||
let elapsed = data.begin.elapsed().unwrap();
|
||||
let length = data.length;
|
||||
let count = data.count;
|
||||
let ratio = match length {
|
||||
None | Some(0) => 0.0,
|
||||
Some(l) => count as f64 / l as f64,
|
||||
};
|
||||
let eta = match ratio {
|
||||
r if r < 0.01 => " ETA: -".to_string(),
|
||||
r if r > 0.999999 => String::new(),
|
||||
r => {
|
||||
format!(
|
||||
" ETA: {}",
|
||||
fmt_duration(Duration::from_secs(1) + elapsed.div_f64(r / (1.0 - r)))
|
||||
)
|
||||
}
|
||||
};
|
||||
let prefix = &data.prefix;
|
||||
let message = match self.progress_type {
|
||||
TuiProgressType::Spinner => {
|
||||
format!("{} {prefix}", fmt_duration(elapsed))
|
||||
}
|
||||
TuiProgressType::Counter => {
|
||||
format!(
|
||||
"{} {prefix} {}{}{eta}",
|
||||
fmt_duration(elapsed),
|
||||
count,
|
||||
length.map_or(String::new(), |l| format!("/{l}"))
|
||||
)
|
||||
}
|
||||
TuiProgressType::Bytes => {
|
||||
format!(
|
||||
"{} {prefix} {}{}{eta}",
|
||||
fmt_duration(elapsed),
|
||||
ByteSize(count).to_string_as(true),
|
||||
length.map_or(String::new(), |l| format!(
|
||||
"/{}",
|
||||
ByteSize(l).to_string_as(true)
|
||||
))
|
||||
)
|
||||
}
|
||||
TuiProgressType::Hidden => String::new(),
|
||||
};
|
||||
drop(data);
|
||||
|
||||
let mut terminal = self.terminal.write().unwrap();
|
||||
_ = terminal
|
||||
.draw(|f| {
|
||||
let area = f.area();
|
||||
match self.progress_type {
|
||||
TuiProgressType::Hidden => {}
|
||||
TuiProgressType::Spinner => {
|
||||
let mut popup = popup_text("progress", message.into());
|
||||
popup.draw(area, f);
|
||||
}
|
||||
TuiProgressType::Counter | TuiProgressType::Bytes => {
|
||||
let mut popup = popup_gauge("progress", message.into(), ratio);
|
||||
popup.draw(area, f);
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Progress for TuiProgress {
|
||||
fn is_hidden(&self) -> bool {
|
||||
matches!(self.progress_type, TuiProgressType::Hidden)
|
||||
}
|
||||
fn set_length(&self, len: u64) {
|
||||
self.data.write().unwrap().length = Some(len);
|
||||
self.popup();
|
||||
}
|
||||
fn set_title(&self, title: &'static str) {
|
||||
self.data.write().unwrap().prefix = String::from(title);
|
||||
self.popup();
|
||||
}
|
||||
|
||||
fn inc(&self, inc: u64) {
|
||||
self.data.write().unwrap().count += inc;
|
||||
self.popup();
|
||||
}
|
||||
fn finish(&self) {}
|
||||
}
|
||||
160
src/commands/tui/restore.rs
Normal file
160
src/commands/tui/restore.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||
use ratatui::prelude::*;
|
||||
use rustic_core::{
|
||||
repofile::Node, IndexedFull, LocalDestination, LsOptions, ProgressBars, Repository,
|
||||
RestoreOptions, RestorePlan,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::tui::widgets::{
|
||||
popup_input, popup_prompt, Draw, PopUpInput, PopUpPrompt, PopUpText, ProcessEvent,
|
||||
PromptResult, TextInputResult,
|
||||
},
|
||||
helpers::bytes_size_to_string,
|
||||
};
|
||||
|
||||
use super::widgets::popup_text;
|
||||
|
||||
// the states this screen can be in
|
||||
enum CurrentScreen {
|
||||
GetDestination(PopUpInput),
|
||||
PromptRestore(PopUpPrompt, Option<RestorePlan>),
|
||||
RestoreDone(PopUpText),
|
||||
}
|
||||
|
||||
pub(crate) struct Restore<'a, P, S> {
|
||||
current_screen: CurrentScreen,
|
||||
repo: &'a Repository<P, S>,
|
||||
opts: RestoreOptions,
|
||||
node: Node,
|
||||
source: String,
|
||||
dest: String,
|
||||
}
|
||||
|
||||
impl<'a, P: ProgressBars, S: IndexedFull> Restore<'a, P, S> {
|
||||
pub fn new(repo: &'a Repository<P, S>, node: Node, source: String, path: &str) -> Self {
|
||||
let opts = RestoreOptions::default();
|
||||
let title = format!("restore {} to:", source);
|
||||
let popup = popup_input(title, "enter restore destination", path, 1);
|
||||
Self {
|
||||
current_screen: CurrentScreen::GetDestination(popup),
|
||||
node,
|
||||
repo,
|
||||
opts,
|
||||
source,
|
||||
dest: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_plan(&mut self, mut dest: String, dry_run: bool) -> Result<RestorePlan> {
|
||||
if dest.is_empty() {
|
||||
dest = ".".to_string();
|
||||
}
|
||||
self.dest = dest;
|
||||
let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?;
|
||||
|
||||
// for restore, always recurse into tree
|
||||
let mut ls_opts = LsOptions::default();
|
||||
ls_opts.recursive = true;
|
||||
|
||||
let ls = self.repo.ls(&self.node, &ls_opts)?;
|
||||
|
||||
let plan = self.repo.prepare_restore(&self.opts, ls, &dest, dry_run)?;
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
// restore using the plan
|
||||
//
|
||||
// Note: This currently runs `prepare_restore` again and doesn't use `plan`
|
||||
// TODO: Fix when restore is changed such that `prepare_restore` is always dry_run and all modification is done in `restore`
|
||||
fn restore(&self, _plan: RestorePlan) -> Result<()> {
|
||||
let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?;
|
||||
|
||||
// for restore, always recurse into tree
|
||||
let mut ls_opts = LsOptions::default();
|
||||
ls_opts.recursive = true;
|
||||
|
||||
let ls = self.repo.ls(&self.node, &ls_opts)?;
|
||||
let plan = self
|
||||
.repo
|
||||
.prepare_restore(&self.opts, ls.clone(), &dest, false)?;
|
||||
|
||||
// the actual restore
|
||||
self.repo.restore(plan, &self.opts, ls, &dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: Event) -> Result<bool> {
|
||||
use KeyCode::*;
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::GetDestination(prompt) => match prompt.input(event) {
|
||||
TextInputResult::Cancel => return Ok(true),
|
||||
TextInputResult::Input(input) => {
|
||||
let plan = self.compute_plan(input, true)?;
|
||||
let fs = plan.stats.files;
|
||||
let ds = plan.stats.dirs;
|
||||
let popup = popup_prompt(
|
||||
"restore information",
|
||||
Text::from(format!(
|
||||
r#"
|
||||
restoring from: {}
|
||||
restoring to: {}
|
||||
|
||||
Files: {} to restore, {} unchanged, {} verified, {} to modify, {} additional
|
||||
Dirs: {} to restore, {} to modify, {} additional
|
||||
Total restore size: {}
|
||||
|
||||
Do you want to proceed (y/n)?
|
||||
"#,
|
||||
self.source,
|
||||
self.dest,
|
||||
fs.restore,
|
||||
fs.unchanged,
|
||||
fs.verified,
|
||||
fs.modify,
|
||||
fs.additional,
|
||||
ds.restore,
|
||||
ds.modify,
|
||||
ds.additional,
|
||||
bytes_size_to_string(plan.restore_size)
|
||||
)),
|
||||
);
|
||||
self.current_screen = CurrentScreen::PromptRestore(popup, Some(plan));
|
||||
}
|
||||
TextInputResult::None => {}
|
||||
},
|
||||
CurrentScreen::PromptRestore(prompt, plan) => match prompt.input(event) {
|
||||
PromptResult::Ok => {
|
||||
let plan = plan.take().unwrap();
|
||||
self.restore(plan)?;
|
||||
self.current_screen = CurrentScreen::RestoreDone(popup_text(
|
||||
"restore done",
|
||||
format!("restored {} successfully to {}", self.source, self.dest).into(),
|
||||
));
|
||||
}
|
||||
PromptResult::Cancel => return Ok(true),
|
||||
PromptResult::None => {}
|
||||
},
|
||||
CurrentScreen::RestoreDone(_) => match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if matches!(key.code, Char('q') | Esc | Enter | Char(' ')) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
// draw popups
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::GetDestination(popup) => popup.draw(area, f),
|
||||
CurrentScreen::PromptRestore(popup, _) => popup.draw(area, f),
|
||||
CurrentScreen::RestoreDone(popup) => popup.draw(area, f),
|
||||
}
|
||||
}
|
||||
}
|
||||
928
src/commands/tui/snapshots.rs
Normal file
928
src/commands/tui/snapshots.rs
Normal file
@ -0,0 +1,928 @@
|
||||
use std::{collections::BTreeSet, iter::once, mem, str::FromStr};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use itertools::Itertools;
|
||||
use ratatui::{prelude::*, widgets::*};
|
||||
use rustic_core::{
|
||||
repofile::{DeleteOption, SnapshotFile},
|
||||
IndexedFull, ProgressBars, Repository, SnapshotGroup, SnapshotGroupCriterion, StringList,
|
||||
};
|
||||
use style::palette::tailwind;
|
||||
|
||||
use crate::{
|
||||
commands::{
|
||||
snapshots::{fill_table, snap_to_table},
|
||||
tui::{
|
||||
ls::{Snapshot, SnapshotResult},
|
||||
tree::{Tree, TreeIterItem, TreeNode},
|
||||
widgets::{
|
||||
popup_input, popup_prompt, popup_table, popup_text, Draw, PopUpInput, PopUpPrompt,
|
||||
PopUpTable, PopUpText, ProcessEvent, PromptResult, SelectTable, TextInputResult,
|
||||
WithBlock,
|
||||
},
|
||||
},
|
||||
},
|
||||
filtering::SnapshotFilter,
|
||||
};
|
||||
|
||||
// the states this screen can be in
|
||||
enum CurrentScreen<'a, P, S> {
|
||||
Snapshots,
|
||||
ShowHelp(PopUpText),
|
||||
SnapshotDetails(PopUpTable),
|
||||
EnterLabel(PopUpInput),
|
||||
EnterDescription(PopUpInput),
|
||||
EnterAddTags(PopUpInput),
|
||||
EnterSetTags(PopUpInput),
|
||||
EnterRemoveTags(PopUpInput),
|
||||
EnterFilter(PopUpInput),
|
||||
PromptWrite(PopUpPrompt),
|
||||
PromptExit(PopUpPrompt),
|
||||
Dir(Snapshot<'a, P, S>),
|
||||
}
|
||||
|
||||
// status of each snapshot
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct SnapStatus {
|
||||
marked: bool,
|
||||
modified: bool,
|
||||
to_forget: bool,
|
||||
}
|
||||
|
||||
impl SnapStatus {
|
||||
fn toggle_mark(&mut self) {
|
||||
self.marked = !self.marked;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum View {
|
||||
Filter,
|
||||
All,
|
||||
Marked,
|
||||
Modified,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum SnapshotNode {
|
||||
Group(SnapshotGroup),
|
||||
Snap(usize),
|
||||
}
|
||||
|
||||
const INFO_TEXT: &str =
|
||||
"(Esc) quit | (F5) reload snapshots | (Enter) show contents | (v) toggle view | (i) show snapshot | (?) show all commands";
|
||||
|
||||
const HELP_TEXT: &str = r#"General Commands:
|
||||
q, Esc : exit
|
||||
F5 : re-read all snapshots from repository
|
||||
Enter : show snapshot contents
|
||||
v : toggle snapshot view [Filtered -> All -> Marked -> Modified]
|
||||
V : modify filter to use
|
||||
Ctrl-v : reset filter
|
||||
i : show detailed snapshot information for selected snapshot
|
||||
w : write modified snapshots and delete snapshots to-forget
|
||||
? : show this help page
|
||||
|
||||
Commands for marking snapshot(s):
|
||||
|
||||
x : toggle marking for selected snapshot
|
||||
X : toggle markings for all snapshots
|
||||
Ctrl-x : clear all markings
|
||||
|
||||
Commands applied to marked snapshot(s) (selected if none marked):
|
||||
|
||||
f : toggle to-forget for snapshot(s)
|
||||
Ctrl-f : clear to-forget for snapshot(s)
|
||||
l : set label for snapshot(s)
|
||||
Ctrl-l : remove label for snapshot(s)
|
||||
d : set description for snapshot(s)
|
||||
Ctrl-d : remove description for snapshot(s)
|
||||
t : add tag(s) for snapshot(s)
|
||||
Ctrl-t : remove all tags for snapshot(s)
|
||||
s : set tag(s) for snapshot(s)
|
||||
r : remove tag(s) for snapshot(s)
|
||||
p : set delete protection for snapshot(s)
|
||||
Ctrl-p : remove delete protection for snapshot(s)
|
||||
"#;
|
||||
|
||||
pub(crate) struct Snapshots<'a, P, S> {
|
||||
current_screen: CurrentScreen<'a, P, S>,
|
||||
current_view: View,
|
||||
table: WithBlock<SelectTable>,
|
||||
repo: &'a Repository<P, S>,
|
||||
snaps_status: Vec<SnapStatus>,
|
||||
snapshots: Vec<SnapshotFile>,
|
||||
original_snapshots: Vec<SnapshotFile>,
|
||||
filtered_snapshots: Vec<usize>,
|
||||
tree: Tree<SnapshotNode, usize>,
|
||||
filter: SnapshotFilter,
|
||||
default_filter: SnapshotFilter,
|
||||
group_by: SnapshotGroupCriterion,
|
||||
}
|
||||
|
||||
impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> {
|
||||
pub fn new(
|
||||
repo: &'a Repository<P, S>,
|
||||
filter: SnapshotFilter,
|
||||
group_by: SnapshotGroupCriterion,
|
||||
) -> Result<Self> {
|
||||
let header = [
|
||||
"", " ID", "Time", "Host", "Label", "Tags", "Paths", "Files", "Dirs", "Size",
|
||||
]
|
||||
.into_iter()
|
||||
.map(Text::from)
|
||||
.collect();
|
||||
|
||||
let mut app = Self {
|
||||
current_screen: CurrentScreen::Snapshots,
|
||||
current_view: View::Filter,
|
||||
table: WithBlock::new(SelectTable::new(header), Block::new()),
|
||||
repo,
|
||||
snaps_status: Vec::new(),
|
||||
original_snapshots: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
filtered_snapshots: Vec::new(),
|
||||
tree: Tree::Leaf(0),
|
||||
default_filter: filter.clone(),
|
||||
filter,
|
||||
group_by,
|
||||
};
|
||||
app.reread()?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
fn selected_tree(&self) -> Option<TreeIterItem<'_, SnapshotNode, usize>> {
|
||||
self.table
|
||||
.widget
|
||||
.selected()
|
||||
.and_then(|selected| self.tree.iter_open().nth(selected))
|
||||
}
|
||||
|
||||
fn selected_tree_mut(&mut self) -> Option<&mut Tree<SnapshotNode, usize>> {
|
||||
self.table
|
||||
.widget
|
||||
.selected()
|
||||
.and_then(|selected| self.tree.nth_mut(selected))
|
||||
}
|
||||
|
||||
fn snap_idx(&self) -> Vec<usize> {
|
||||
self.selected_tree()
|
||||
.iter()
|
||||
.flat_map(|item| item.tree.iter().map(|item| item.tree))
|
||||
.filter_map(|tree| tree.leaf_data().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected_snapshot(&self) -> Option<&SnapshotFile> {
|
||||
self.selected_tree().map(|tree_info| match tree_info.tree {
|
||||
Tree::Leaf(index)
|
||||
| Tree::Node(TreeNode {
|
||||
data: SnapshotNode::Snap(index),
|
||||
..
|
||||
}) => Some(&self.snapshots[*index]),
|
||||
_ => None,
|
||||
})?
|
||||
}
|
||||
|
||||
pub fn has_mark(&self) -> bool {
|
||||
self.snaps_status.iter().any(|s| s.marked)
|
||||
}
|
||||
|
||||
pub fn has_modified(&self) -> bool {
|
||||
self.snaps_status.iter().any(|s| s.modified)
|
||||
}
|
||||
|
||||
pub fn toggle_view_mark(&mut self) {
|
||||
match self.current_view {
|
||||
View::Filter => self.current_view = View::All,
|
||||
View::All => {
|
||||
self.current_view = View::Marked;
|
||||
if !self.has_mark() {
|
||||
self.toggle_view_mark();
|
||||
}
|
||||
}
|
||||
View::Marked => {
|
||||
self.current_view = View::Modified;
|
||||
if !self.has_modified() {
|
||||
self.toggle_view_mark();
|
||||
}
|
||||
}
|
||||
View::Modified => self.current_view = View::Filter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_view(&mut self) {
|
||||
self.toggle_view_mark();
|
||||
self.apply_view();
|
||||
}
|
||||
|
||||
pub fn apply_view(&mut self) {
|
||||
// select snapshots to show
|
||||
self.filtered_snapshots = self
|
||||
.snapshots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.zip(self.snaps_status.iter())
|
||||
.filter_map(|((i, sn), status)| {
|
||||
match self.current_view {
|
||||
View::All => true,
|
||||
View::Filter => self.filter.matches(sn),
|
||||
View::Marked => status.marked,
|
||||
View::Modified => status.modified,
|
||||
}
|
||||
.then_some(i)
|
||||
})
|
||||
.collect();
|
||||
self.create_tree();
|
||||
}
|
||||
|
||||
pub fn create_tree(&mut self) {
|
||||
// remember current snapshot index
|
||||
let old_tree = self.selected_tree().map(|t| t.tree);
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (group, snaps) in &self
|
||||
.filtered_snapshots
|
||||
.iter()
|
||||
.chunk_by(|i| SnapshotGroup::from_snapshot(&self.snapshots[**i], self.group_by))
|
||||
{
|
||||
let mut same_id_group = Vec::new();
|
||||
for (_, s) in &snaps.into_iter().chunk_by(|i| self.snapshots[**i].tree) {
|
||||
let leafs: Vec<_> = s.map(|i| Tree::leaf(*i)).collect();
|
||||
let first = leafs[0].leaf_data().unwrap(); // Cannot be None as leafs[0] is a leaf!
|
||||
if leafs.len() == 1 {
|
||||
same_id_group.push(Tree::leaf(*first));
|
||||
} else {
|
||||
same_id_group.push(Tree::node(SnapshotNode::Snap(*first), false, leafs));
|
||||
}
|
||||
}
|
||||
result.push(Tree::node(SnapshotNode::Group(group), false, same_id_group));
|
||||
}
|
||||
let tree = Tree::node(SnapshotNode::Snap(0), true, result);
|
||||
|
||||
let len = tree.iter_open().count();
|
||||
let selected = if len == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tree.iter()
|
||||
.position(|info| Some(info.tree) == old_tree)
|
||||
.unwrap_or(len - 1),
|
||||
)
|
||||
};
|
||||
|
||||
self.tree = tree;
|
||||
self.update_table();
|
||||
self.table.widget.select(selected);
|
||||
}
|
||||
|
||||
fn table_row(&self, info: TreeIterItem<'_, SnapshotNode, usize>) -> Vec<Text<'static>> {
|
||||
let (has_mark, has_not_mark, has_modified, has_to_forget) = info
|
||||
.tree
|
||||
.iter()
|
||||
.filter_map(|item| item.leaf_data().copied())
|
||||
.fold(
|
||||
(false, false, false, false),
|
||||
|(mut a, mut b, mut c, mut d), i| {
|
||||
if self.snaps_status[i].marked {
|
||||
a = true;
|
||||
} else {
|
||||
b = true;
|
||||
}
|
||||
|
||||
if self.snaps_status[i].modified {
|
||||
c = true;
|
||||
}
|
||||
|
||||
if self.snaps_status[i].to_forget {
|
||||
d = true;
|
||||
}
|
||||
|
||||
(a, b, c, d)
|
||||
},
|
||||
);
|
||||
|
||||
let mark = match (has_mark, has_not_mark) {
|
||||
(false, _) => " ",
|
||||
(true, true) => "*",
|
||||
(true, false) => "X",
|
||||
};
|
||||
let modified = if has_modified { "*" } else { " " };
|
||||
let del = if has_to_forget { "🗑" } else { "" };
|
||||
let mut collapse = " ".repeat(info.depth);
|
||||
collapse.push_str(match info.tree {
|
||||
Tree::Leaf(_) => "",
|
||||
Tree::Node(TreeNode { open: false, .. }) => "\u{25b6} ", // Arrow to right
|
||||
Tree::Node(TreeNode { open: true, .. }) => "\u{25bc} ", // Arrow down
|
||||
});
|
||||
|
||||
match info.tree {
|
||||
Tree::Leaf(index)
|
||||
| Tree::Node(TreeNode {
|
||||
data: SnapshotNode::Snap(index),
|
||||
..
|
||||
}) => {
|
||||
let snap = &self.snapshots[*index];
|
||||
let symbols = match (
|
||||
snap.delete == DeleteOption::NotSet,
|
||||
snap.description.is_none(),
|
||||
) {
|
||||
(true, true) => "",
|
||||
(true, false) => "🗎",
|
||||
(false, true) => "🛡",
|
||||
(false, false) => "🛡 🗎",
|
||||
};
|
||||
let count = info.tree.child_count();
|
||||
once(&mark.to_string())
|
||||
.chain(snap_to_table(snap, count).iter())
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(i, mut content)| {
|
||||
if i == 1 {
|
||||
// ID gets modified and protected marks
|
||||
content = format!("{collapse}{modified}{del}{content}{symbols}");
|
||||
}
|
||||
Text::from(content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Tree::Node(TreeNode {
|
||||
data: SnapshotNode::Group(group),
|
||||
..
|
||||
}) => {
|
||||
let host = group
|
||||
.hostname
|
||||
.as_ref()
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
let label = group.label.as_ref().map(String::from).unwrap_or_default();
|
||||
|
||||
let paths = group
|
||||
.paths
|
||||
.as_ref()
|
||||
.map_or_else(String::default, |p| p.formatln());
|
||||
let tags = group
|
||||
.tags
|
||||
.as_ref()
|
||||
.map_or_else(String::default, |t| t.formatln());
|
||||
[
|
||||
mark.to_string(),
|
||||
format!("{collapse}{modified}{del}group"),
|
||||
String::default(),
|
||||
host,
|
||||
label,
|
||||
tags,
|
||||
paths,
|
||||
String::default(),
|
||||
String::default(),
|
||||
String::default(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(Text::from)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_table(&mut self) {
|
||||
let max_tags = self
|
||||
.filtered_snapshots
|
||||
.iter()
|
||||
.map(|&i| self.snapshots[i].tags.iter().count())
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let max_paths = self
|
||||
.filtered_snapshots
|
||||
.iter()
|
||||
.map(|&i| self.snapshots[i].paths.iter().count())
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let height = max_tags.max(max_paths).max(1) + 1;
|
||||
|
||||
let rows = self
|
||||
.tree
|
||||
.iter_open()
|
||||
.map(|tree| self.table_row(tree))
|
||||
.collect();
|
||||
|
||||
self.table.widget.set_content(rows, height);
|
||||
self.table.block = Block::new()
|
||||
.borders(Borders::BOTTOM)
|
||||
.title_bottom(format!(
|
||||
"{:?} view: {}, total: {}, marked: {}, modified: {}, to forget: {}",
|
||||
self.current_view,
|
||||
self.filtered_snapshots.len(),
|
||||
self.snapshots.len(),
|
||||
self.count_marked_snaps(),
|
||||
self.count_modified_snaps(),
|
||||
self.count_forget_snaps()
|
||||
))
|
||||
.title_alignment(Alignment::Center);
|
||||
}
|
||||
|
||||
pub fn toggle_mark(&mut self) {
|
||||
for snap_idx in self.snap_idx() {
|
||||
self.snaps_status[snap_idx].toggle_mark();
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn toggle_mark_all(&mut self) {
|
||||
for snap_idx in &self.filtered_snapshots {
|
||||
self.snaps_status[*snap_idx].toggle_mark();
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn clear_marks(&mut self) {
|
||||
for status in self.snaps_status.iter_mut() {
|
||||
status.marked = false;
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn reset_filter(&mut self) {
|
||||
self.filter = self.default_filter.clone();
|
||||
self.apply_view();
|
||||
}
|
||||
|
||||
pub fn collapse(&mut self) {
|
||||
if let Some(tree) = self.selected_tree_mut() {
|
||||
tree.close();
|
||||
self.update_table();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extendable(&self) -> bool {
|
||||
matches!(self.selected_tree(), Some(tree_info) if tree_info.tree.openable())
|
||||
}
|
||||
|
||||
pub fn extend(&mut self) {
|
||||
if let Some(tree) = self.selected_tree_mut() {
|
||||
tree.open();
|
||||
self.update_table();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_details(&self) -> PopUpTable {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(snap) = self.selected_snapshot() {
|
||||
fill_table(snap, |title, value| {
|
||||
rows.push(vec![Text::from(title.to_string()), Text::from(value)]);
|
||||
});
|
||||
}
|
||||
popup_table("snapshot details", rows)
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> Result<Option<Snapshot<'a, P, S>>> {
|
||||
self.selected_snapshot().map_or(Ok(None), |snap| {
|
||||
Some(Snapshot::new(self.repo, snap.clone())).transpose()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_marked_snaps(&self) -> usize {
|
||||
self.snaps_status.iter().filter(|s| s.marked).count()
|
||||
}
|
||||
|
||||
pub fn count_modified_snaps(&self) -> usize {
|
||||
self.snaps_status.iter().filter(|s| s.modified).count()
|
||||
}
|
||||
|
||||
pub fn count_forget_snaps(&self) -> usize {
|
||||
self.snaps_status.iter().filter(|s| s.to_forget).count()
|
||||
}
|
||||
|
||||
// process marked snapshots (or the current one if none is marked)
|
||||
// the process function must return true if it modified the snapshot, else false
|
||||
pub fn process_marked_snaps(&mut self, mut process: impl FnMut(&mut SnapshotFile) -> bool) {
|
||||
let has_mark = self.has_mark();
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
|
||||
for ((snap, status), original_snap) in self
|
||||
.snapshots
|
||||
.iter_mut()
|
||||
.zip(self.snaps_status.iter_mut())
|
||||
.zip(self.original_snapshots.iter())
|
||||
{
|
||||
if status.marked && process(snap) {
|
||||
// Note that snap impls Eq, but only by comparing the time!
|
||||
status.modified =
|
||||
serde_json::to_string(snap).ok() != serde_json::to_string(original_snap).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn get_snap_entity(&mut self, f: impl Fn(&SnapshotFile) -> String) -> String {
|
||||
let has_mark = self.has_mark();
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
|
||||
let entity = self
|
||||
.snapshots
|
||||
.iter()
|
||||
.zip(self.snaps_status.iter())
|
||||
.filter_map(|(snap, status)| status.marked.then_some(f(snap)))
|
||||
.reduce(|entity, e| if entity == e { e } else { String::new() })
|
||||
.unwrap_or_default();
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn get_label(&mut self) -> String {
|
||||
self.get_snap_entity(|snap| snap.label.clone())
|
||||
}
|
||||
|
||||
pub fn get_tags(&mut self) -> String {
|
||||
self.get_snap_entity(|snap| snap.tags.formatln())
|
||||
}
|
||||
|
||||
pub fn get_description(&mut self) -> String {
|
||||
self.get_snap_entity(|snap| snap.description.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn get_filter(&self) -> Result<String> {
|
||||
Ok(toml::to_string_pretty(&self.filter)?)
|
||||
}
|
||||
|
||||
pub fn set_filter(&mut self, filter: String) {
|
||||
if let Ok(filter) = toml::from_str::<SnapshotFilter>(&filter) {
|
||||
self.filter = filter;
|
||||
self.apply_view();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_label(&mut self, label: String) {
|
||||
self.process_marked_snaps(|snap| {
|
||||
if snap.label == label {
|
||||
return false;
|
||||
}
|
||||
snap.label.clone_from(&label);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_label(&mut self) {
|
||||
self.set_label(String::new());
|
||||
}
|
||||
|
||||
pub fn set_description(&mut self, desc: String) {
|
||||
let desc = if desc.is_empty() { None } else { Some(desc) };
|
||||
self.process_marked_snaps(|snap| {
|
||||
if snap.description == desc {
|
||||
return false;
|
||||
}
|
||||
snap.description.clone_from(&desc);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_description(&mut self) {
|
||||
self.set_description(String::new());
|
||||
}
|
||||
|
||||
pub fn add_tags(&mut self, tags: String) {
|
||||
let tags = vec![StringList::from_str(&tags).unwrap()];
|
||||
self.process_marked_snaps(|snap| snap.add_tags(tags.clone()));
|
||||
}
|
||||
|
||||
pub fn set_tags(&mut self, tags: String) {
|
||||
let tags = vec![StringList::from_str(&tags).unwrap()];
|
||||
self.process_marked_snaps(|snap| snap.set_tags(tags.clone()));
|
||||
}
|
||||
|
||||
pub fn remove_tags(&mut self, tags: String) {
|
||||
let tags = vec![StringList::from_str(&tags).unwrap()];
|
||||
self.process_marked_snaps(|snap| snap.remove_tags(&tags));
|
||||
}
|
||||
|
||||
pub fn clear_tags(&mut self) {
|
||||
let no_tags = vec![StringList::default()];
|
||||
self.process_marked_snaps(|snap| snap.set_tags(no_tags.clone()));
|
||||
}
|
||||
|
||||
pub fn set_delete_protection_to(&mut self, delete: DeleteOption) {
|
||||
self.process_marked_snaps(|snap| {
|
||||
if snap.delete == delete {
|
||||
return false;
|
||||
}
|
||||
snap.delete = delete;
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_to_forget(&mut self) {
|
||||
let has_mark = self.has_mark();
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
for (snap, status) in self.snapshots.iter_mut().zip(self.snaps_status.iter_mut()) {
|
||||
if status.marked {
|
||||
if status.to_forget {
|
||||
status.to_forget = false;
|
||||
} else if !snap.must_keep(now) {
|
||||
status.to_forget = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_mark {
|
||||
self.toggle_mark();
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn clear_to_forget(&mut self) {
|
||||
for status in self.snaps_status.iter_mut() {
|
||||
status.to_forget = false;
|
||||
}
|
||||
self.update_table();
|
||||
}
|
||||
|
||||
pub fn apply_input(&mut self, input: String) {
|
||||
match self.current_screen {
|
||||
CurrentScreen::EnterLabel(_) => self.set_label(input),
|
||||
CurrentScreen::EnterDescription(_) => self.set_description(input),
|
||||
CurrentScreen::EnterAddTags(_) => self.add_tags(input),
|
||||
CurrentScreen::EnterSetTags(_) => self.set_tags(input),
|
||||
CurrentScreen::EnterRemoveTags(_) => self.remove_tags(input),
|
||||
CurrentScreen::EnterFilter(_) => self.set_filter(input),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_delete_protection(&mut self) {
|
||||
self.set_delete_protection_to(DeleteOption::Never);
|
||||
}
|
||||
|
||||
pub fn clear_delete_protection(&mut self) {
|
||||
self.set_delete_protection_to(DeleteOption::NotSet);
|
||||
}
|
||||
|
||||
pub fn write(&mut self) -> Result<()> {
|
||||
if !self.has_modified() && self.count_forget_snaps() == 0 {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let save_snaps: Vec<_> = self
|
||||
.snapshots
|
||||
.iter()
|
||||
.zip(self.snaps_status.iter())
|
||||
.filter_map(|(snap, status)| (status.modified && !status.to_forget).then_some(snap))
|
||||
.cloned()
|
||||
.collect();
|
||||
let old_snap_ids = save_snaps.iter().map(|sn| sn.id);
|
||||
let snap_ids_to_forget = self
|
||||
.snapshots
|
||||
.iter()
|
||||
.zip(self.snaps_status.iter())
|
||||
.filter_map(|(snap, status)| status.to_forget.then_some(snap.id));
|
||||
let delete_ids: Vec<_> = old_snap_ids.chain(snap_ids_to_forget).collect();
|
||||
self.repo.save_snapshots(save_snaps)?;
|
||||
self.repo.delete_snapshots(&delete_ids)?;
|
||||
// remove snapshots-to-reread
|
||||
let ids: BTreeSet<_> = delete_ids.into_iter().collect();
|
||||
self.snapshots.retain(|snap| !ids.contains(&snap.id));
|
||||
// re-read snapshots
|
||||
self.reread()
|
||||
}
|
||||
|
||||
// re-read all snapshots
|
||||
pub fn reread(&mut self) -> Result<()> {
|
||||
let snapshots = mem::take(&mut self.snapshots);
|
||||
self.snapshots = self.repo.update_all_snapshots(snapshots)?;
|
||||
self.snapshots
|
||||
.sort_unstable_by(|sn1, sn2| sn1.cmp_group(self.group_by, sn2).then(sn1.cmp(sn2)));
|
||||
self.snaps_status = vec![SnapStatus::default(); self.snapshots.len()];
|
||||
self.original_snapshots.clone_from(&self.snapshots);
|
||||
self.table.widget.select(None);
|
||||
self.apply_view();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn input(&mut self, event: Event) -> Result<bool> {
|
||||
use KeyCode::*;
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::Snapshots => {
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if key.modifiers == KeyModifiers::CONTROL {
|
||||
match key.code {
|
||||
Char('f') => self.clear_to_forget(),
|
||||
Char('x') => self.clear_marks(),
|
||||
Char('l') => self.clear_label(),
|
||||
Char('d') => self.clear_description(),
|
||||
Char('t') => self.clear_tags(),
|
||||
Char('p') => self.clear_delete_protection(),
|
||||
Char('v') => self.reset_filter(),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
Esc | Char('q') => {
|
||||
self.current_screen = CurrentScreen::PromptExit(popup_prompt(
|
||||
"exit rustic",
|
||||
"do you want to exit? (y/n)".into(),
|
||||
));
|
||||
}
|
||||
Char('f') => self.toggle_to_forget(),
|
||||
F(5) => self.reread()?,
|
||||
Enter => {
|
||||
if let Some(dir) = self.dir()? {
|
||||
self.current_screen = CurrentScreen::Dir(dir);
|
||||
}
|
||||
}
|
||||
Right => {
|
||||
if self.extendable() {
|
||||
self.extend();
|
||||
} else if let Some(dir) = self.dir()? {
|
||||
self.current_screen = CurrentScreen::Dir(dir);
|
||||
}
|
||||
}
|
||||
Char('+') => {
|
||||
if self.extendable() {
|
||||
self.extend();
|
||||
}
|
||||
}
|
||||
Left | Char('-') => self.collapse(),
|
||||
Char('?') => {
|
||||
self.current_screen = CurrentScreen::ShowHelp(popup_text(
|
||||
"help",
|
||||
HELP_TEXT.into(),
|
||||
));
|
||||
}
|
||||
Char('x') => {
|
||||
self.toggle_mark();
|
||||
self.table.widget.next();
|
||||
}
|
||||
Char('X') => self.toggle_mark_all(),
|
||||
Char('v') => self.toggle_view(),
|
||||
Char('V') => {
|
||||
self.current_screen = CurrentScreen::EnterFilter(popup_input(
|
||||
"set filter (Ctrl-s to confirm)",
|
||||
"enter filter in TOML format",
|
||||
&self.get_filter()?,
|
||||
15,
|
||||
));
|
||||
}
|
||||
Char('i') => {
|
||||
self.current_screen =
|
||||
CurrentScreen::SnapshotDetails(self.snapshot_details());
|
||||
}
|
||||
Char('l') => {
|
||||
self.current_screen = CurrentScreen::EnterLabel(popup_input(
|
||||
"set label",
|
||||
"enter label",
|
||||
&self.get_label(),
|
||||
1,
|
||||
));
|
||||
}
|
||||
Char('d') => {
|
||||
self.current_screen =
|
||||
CurrentScreen::EnterDescription(popup_input(
|
||||
"set description (Ctrl-s to confirm)",
|
||||
"enter description",
|
||||
&self.get_description(),
|
||||
5,
|
||||
));
|
||||
}
|
||||
Char('t') => {
|
||||
self.current_screen = CurrentScreen::EnterAddTags(popup_input(
|
||||
"add tags",
|
||||
"enter tags",
|
||||
"",
|
||||
1,
|
||||
));
|
||||
}
|
||||
Char('s') => {
|
||||
self.current_screen = CurrentScreen::EnterSetTags(popup_input(
|
||||
"set tags",
|
||||
"enter tags",
|
||||
&self.get_tags(),
|
||||
1,
|
||||
));
|
||||
}
|
||||
Char('r') => {
|
||||
self.current_screen = CurrentScreen::EnterRemoveTags(
|
||||
popup_input("remove tags", "enter tags", "", 1),
|
||||
);
|
||||
}
|
||||
// TODO: Allow to enter delete protection option
|
||||
Char('p') => self.set_delete_protection(),
|
||||
Char('w') => {
|
||||
let msg = format!(
|
||||
"Do you want to write {} modified and remove {} snapshots? (y/n)",
|
||||
self.count_modified_snaps(),
|
||||
self.count_forget_snaps()
|
||||
);
|
||||
self.current_screen = CurrentScreen::PromptWrite(popup_prompt(
|
||||
"write snapshots",
|
||||
msg.into(),
|
||||
));
|
||||
}
|
||||
_ => self.table.input(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
CurrentScreen::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
if matches!(
|
||||
key.code,
|
||||
Char('q') | Esc | Enter | Char(' ') | Char('i') | Char('?')
|
||||
) {
|
||||
self.current_screen = CurrentScreen::Snapshots;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
CurrentScreen::EnterLabel(prompt)
|
||||
| CurrentScreen::EnterDescription(prompt)
|
||||
| CurrentScreen::EnterAddTags(prompt)
|
||||
| CurrentScreen::EnterSetTags(prompt)
|
||||
| CurrentScreen::EnterRemoveTags(prompt)
|
||||
| CurrentScreen::EnterFilter(prompt) => match prompt.input(event) {
|
||||
TextInputResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
|
||||
TextInputResult::Input(input) => {
|
||||
self.apply_input(input);
|
||||
self.current_screen = CurrentScreen::Snapshots;
|
||||
}
|
||||
TextInputResult::None => {}
|
||||
},
|
||||
CurrentScreen::PromptWrite(prompt) => match prompt.input(event) {
|
||||
PromptResult::Ok => {
|
||||
self.write()?;
|
||||
self.current_screen = CurrentScreen::Snapshots;
|
||||
}
|
||||
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
|
||||
PromptResult::None => {}
|
||||
},
|
||||
CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
|
||||
PromptResult::Ok => return Ok(true),
|
||||
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
|
||||
PromptResult::None => {}
|
||||
},
|
||||
CurrentScreen::Dir(dir) => match dir.input(event)? {
|
||||
SnapshotResult::Exit => return Ok(true),
|
||||
SnapshotResult::Return => self.current_screen = CurrentScreen::Snapshots,
|
||||
SnapshotResult::None => {}
|
||||
},
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
if let CurrentScreen::Dir(dir) = &mut self.current_screen {
|
||||
dir.draw(area, f);
|
||||
return;
|
||||
}
|
||||
|
||||
let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
|
||||
|
||||
// draw the table
|
||||
self.table.draw(rects[0], f);
|
||||
|
||||
// draw the footer
|
||||
let buffer_bg = tailwind::SLATE.c950;
|
||||
let row_fg = tailwind::SLATE.c200;
|
||||
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
|
||||
.style(Style::new().fg(row_fg).bg(buffer_bg))
|
||||
.centered();
|
||||
f.render_widget(info_footer, rects[1]);
|
||||
|
||||
// draw popups
|
||||
match &mut self.current_screen {
|
||||
CurrentScreen::SnapshotDetails(popup) => popup.draw(area, f),
|
||||
CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
|
||||
CurrentScreen::EnterLabel(popup)
|
||||
| CurrentScreen::EnterDescription(popup)
|
||||
| CurrentScreen::EnterAddTags(popup)
|
||||
| CurrentScreen::EnterSetTags(popup)
|
||||
| CurrentScreen::EnterRemoveTags(popup)
|
||||
| CurrentScreen::EnterFilter(popup) => popup.draw(area, f),
|
||||
CurrentScreen::PromptWrite(popup) | CurrentScreen::PromptExit(popup) => {
|
||||
popup.draw(area, f);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
src/commands/tui/tree.rs
Normal file
144
src/commands/tui/tree.rs
Normal file
@ -0,0 +1,144 @@
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct TreeNode<Data, LeafData> {
|
||||
pub data: Data,
|
||||
pub open: bool,
|
||||
pub children: Vec<Tree<Data, LeafData>>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Tree<Data, LeafData> {
|
||||
Node(TreeNode<Data, LeafData>),
|
||||
Leaf(LeafData),
|
||||
}
|
||||
|
||||
impl<Data, LeafData> Tree<Data, LeafData> {
|
||||
pub fn leaf(data: LeafData) -> Self {
|
||||
Self::Leaf(data)
|
||||
}
|
||||
pub fn node(data: Data, open: bool, children: Vec<Self>) -> Self {
|
||||
Self::Node(TreeNode {
|
||||
data,
|
||||
open,
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn child_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Leaf(_) => 0,
|
||||
Self::Node(TreeNode { children, .. }) => {
|
||||
children.len() + children.iter().map(Self::child_count).sum::<usize>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leaf_data(&self) -> Option<&LeafData> {
|
||||
match self {
|
||||
Self::Node(_) => None,
|
||||
Self::Leaf(data) => Some(data),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openable(&self) -> bool {
|
||||
matches!(self, Self::Node(node) if !node.open)
|
||||
}
|
||||
|
||||
pub fn open(&mut self) {
|
||||
if let Self::Node(node) = self {
|
||||
node.open = true;
|
||||
}
|
||||
}
|
||||
pub fn close(&mut self) {
|
||||
if let Self::Node(node) = self {
|
||||
node.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = TreeIterItem<'_, Data, LeafData>> {
|
||||
TreeIter {
|
||||
tree: Some(self),
|
||||
iter_stack: Vec::new(),
|
||||
only_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
// iter open tree descending only into open nodes.
|
||||
// Note: This iterator skips the root node!
|
||||
pub fn iter_open(&self) -> impl Iterator<Item = TreeIterItem<'_, Data, LeafData>> {
|
||||
TreeIter {
|
||||
tree: Some(self),
|
||||
iter_stack: Vec::new(),
|
||||
only_open: true,
|
||||
}
|
||||
.skip(1)
|
||||
}
|
||||
|
||||
pub fn nth_mut(&mut self, n: usize) -> Option<&mut Self> {
|
||||
let mut count = 0;
|
||||
let mut tree = Some(self);
|
||||
let mut iter_stack = Vec::new();
|
||||
loop {
|
||||
if count == n + 1 {
|
||||
return tree;
|
||||
}
|
||||
let item = tree?;
|
||||
if let Self::Node(node) = item {
|
||||
if node.open {
|
||||
iter_stack.push(node.children.iter_mut());
|
||||
}
|
||||
}
|
||||
tree = next_from_iter_stack(&mut iter_stack);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeIterItem<'a, Data, LeadData> {
|
||||
pub depth: usize,
|
||||
pub tree: &'a Tree<Data, LeadData>,
|
||||
}
|
||||
|
||||
impl<'a, Data, LeafData> TreeIterItem<'a, Data, LeafData> {
|
||||
pub fn leaf_data(&self) -> Option<&LeafData> {
|
||||
self.tree.leaf_data()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TreeIter<'a, Data, LeafData> {
|
||||
tree: Option<&'a Tree<Data, LeafData>>,
|
||||
iter_stack: Vec<std::slice::Iter<'a, Tree<Data, LeafData>>>,
|
||||
only_open: bool,
|
||||
}
|
||||
|
||||
impl<'a, Data, LeafData> Iterator for TreeIter<'a, Data, LeafData> {
|
||||
type Item = TreeIterItem<'a, Data, LeafData>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let item = self.tree?;
|
||||
let depth = self.iter_stack.len();
|
||||
if let Tree::Node(node) = item {
|
||||
if !self.only_open || node.open {
|
||||
self.iter_stack.push(node.children.iter());
|
||||
}
|
||||
}
|
||||
|
||||
self.tree = next_from_iter_stack(&mut self.iter_stack);
|
||||
Some(TreeIterItem { depth, tree: item })
|
||||
}
|
||||
}
|
||||
|
||||
// helper function to get next item from iteration stack when iterating over a Tree
|
||||
fn next_from_iter_stack<T>(stack: &mut Vec<impl Iterator<Item = T>>) -> Option<T> {
|
||||
loop {
|
||||
match stack.pop() {
|
||||
None => {
|
||||
break None;
|
||||
}
|
||||
Some(mut iter) => {
|
||||
if let Some(next) = iter.next() {
|
||||
stack.push(iter);
|
||||
break Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/commands/tui/widgets.rs
Normal file
102
src/commands/tui/widgets.rs
Normal file
@ -0,0 +1,102 @@
|
||||
mod popup;
|
||||
mod prompt;
|
||||
mod select_table;
|
||||
mod sized_gauge;
|
||||
mod sized_paragraph;
|
||||
mod sized_table;
|
||||
mod text_input;
|
||||
mod with_block;
|
||||
|
||||
pub use popup::*;
|
||||
pub use prompt::*;
|
||||
use ratatui::widgets::block::Title;
|
||||
pub use select_table::*;
|
||||
pub use sized_gauge::*;
|
||||
pub use sized_paragraph::*;
|
||||
pub use sized_table::*;
|
||||
pub use text_input::*;
|
||||
pub use with_block::*;
|
||||
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::*;
|
||||
|
||||
pub trait ProcessEvent {
|
||||
type Result;
|
||||
fn input(&mut self, event: Event) -> Self::Result;
|
||||
}
|
||||
|
||||
pub trait SizedWidget {
|
||||
fn height(&self) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
fn width(&self) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Draw {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>);
|
||||
}
|
||||
|
||||
// the widgets we are using and convenience builders
|
||||
pub type PopUpInput = PopUp<WithBlock<TextInput>>;
|
||||
pub fn popup_input(
|
||||
title: impl Into<Title<'static>>,
|
||||
text: &str,
|
||||
initial: &str,
|
||||
lines: u16,
|
||||
) -> PopUpInput {
|
||||
PopUp(WithBlock::new(
|
||||
TextInput::new(Some(text), initial, lines, true),
|
||||
Block::bordered().title(title),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn popup_scrollable_text(
|
||||
title: impl Into<Title<'static>>,
|
||||
text: &str,
|
||||
lines: u16,
|
||||
) -> PopUpInput {
|
||||
PopUp(WithBlock::new(
|
||||
TextInput::new(None, text, lines, false),
|
||||
Block::bordered().title(title),
|
||||
))
|
||||
}
|
||||
|
||||
pub type PopUpText = PopUp<WithBlock<SizedParagraph>>;
|
||||
pub fn popup_text(title: impl Into<Title<'static>>, text: Text<'static>) -> PopUpText {
|
||||
PopUp(WithBlock::new(
|
||||
SizedParagraph::new(text),
|
||||
Block::bordered().title(title),
|
||||
))
|
||||
}
|
||||
|
||||
pub type PopUpTable = PopUp<WithBlock<SizedTable>>;
|
||||
pub fn popup_table(
|
||||
title: impl Into<Title<'static>>,
|
||||
content: Vec<Vec<Text<'static>>>,
|
||||
) -> PopUpTable {
|
||||
PopUp(WithBlock::new(
|
||||
SizedTable::new(content),
|
||||
Block::bordered().title(title),
|
||||
))
|
||||
}
|
||||
|
||||
pub type PopUpPrompt = Prompt<PopUpText>;
|
||||
pub fn popup_prompt(title: &'static str, text: Text<'static>) -> PopUpPrompt {
|
||||
Prompt(popup_text(title, text))
|
||||
}
|
||||
|
||||
pub type PopUpGauge = PopUp<WithBlock<SizedGauge>>;
|
||||
pub fn popup_gauge(
|
||||
title: impl Into<Title<'static>>,
|
||||
text: Span<'static>,
|
||||
ratio: f64,
|
||||
) -> PopUpGauge {
|
||||
PopUp(WithBlock::new(
|
||||
SizedGauge::new(text, ratio),
|
||||
Block::bordered().title(title),
|
||||
))
|
||||
}
|
||||
38
src/commands/tui/widgets/popup.rs
Normal file
38
src/commands/tui/widgets/popup.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use super::*;
|
||||
|
||||
// Make a popup from a SizedWidget
|
||||
pub struct PopUp<T>(pub T);
|
||||
|
||||
impl<T: ProcessEvent> ProcessEvent for PopUp<T> {
|
||||
type Result = T::Result;
|
||||
fn input(&mut self, event: Event) -> Self::Result {
|
||||
self.0.input(event)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Draw + SizedWidget> Draw for PopUp<T> {
|
||||
fn draw(&mut self, mut area: Rect, f: &mut Frame<'_>) {
|
||||
// center vertically
|
||||
if let Some(h) = self.0.height() {
|
||||
let layout = Layout::vertical([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(h),
|
||||
Constraint::Min(1),
|
||||
]);
|
||||
area = layout.split(area)[1];
|
||||
}
|
||||
|
||||
// center horizontally
|
||||
if let Some(w) = self.0.width() {
|
||||
let layout = Layout::horizontal([
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(w),
|
||||
Constraint::Min(1),
|
||||
]);
|
||||
area = layout.split(area)[1];
|
||||
}
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
self.0.draw(area, f);
|
||||
}
|
||||
}
|
||||
39
src/commands/tui/widgets/prompt.rs
Normal file
39
src/commands/tui/widgets/prompt.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use super::*;
|
||||
|
||||
pub struct Prompt<T>(pub T);
|
||||
|
||||
pub enum PromptResult {
|
||||
Ok,
|
||||
Cancel,
|
||||
None,
|
||||
}
|
||||
|
||||
impl<T: SizedWidget> SizedWidget for Prompt<T> {
|
||||
fn height(&self) -> Option<u16> {
|
||||
self.0.height()
|
||||
}
|
||||
fn width(&self) -> Option<u16> {
|
||||
self.0.width()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Draw> Draw for Prompt<T> {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
self.0.draw(area, f);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ProcessEvent for Prompt<T> {
|
||||
type Result = PromptResult;
|
||||
fn input(&mut self, event: Event) -> PromptResult {
|
||||
use KeyCode::*;
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
|
||||
Char('q') | Char('n') | Char('c') | Esc => PromptResult::Cancel,
|
||||
Enter | Char('y') | Char('j') | Char(' ') => PromptResult::Ok,
|
||||
_ => PromptResult::None,
|
||||
},
|
||||
_ => PromptResult::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
196
src/commands/tui/widgets/select_table.rs
Normal file
196
src/commands/tui/widgets/select_table.rs
Normal file
@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
use std::iter::once;
|
||||
use style::palette::tailwind;
|
||||
|
||||
struct TableColors {
|
||||
buffer_bg: Color,
|
||||
header_bg: Color,
|
||||
header_fg: Color,
|
||||
row_fg: Color,
|
||||
selected_style_fg: Color,
|
||||
normal_row_color: Color,
|
||||
alt_row_color: Color,
|
||||
}
|
||||
|
||||
impl TableColors {
|
||||
fn new(color: &tailwind::Palette) -> Self {
|
||||
Self {
|
||||
buffer_bg: tailwind::SLATE.c950,
|
||||
header_bg: color.c900,
|
||||
header_fg: tailwind::SLATE.c200,
|
||||
row_fg: tailwind::SLATE.c200,
|
||||
selected_style_fg: color.c400,
|
||||
normal_row_color: tailwind::SLATE.c950,
|
||||
alt_row_color: tailwind::SLATE.c900,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SelectTable {
|
||||
header: Vec<Text<'static>>,
|
||||
table: Table<'static>,
|
||||
state: TableState,
|
||||
scroll_state: ScrollbarState,
|
||||
rows: usize,
|
||||
rows_display: usize,
|
||||
row_height: usize,
|
||||
}
|
||||
|
||||
impl SelectTable {
|
||||
pub fn new(header: Vec<Text<'static>>) -> Self {
|
||||
let table = Table::default();
|
||||
|
||||
Self {
|
||||
header,
|
||||
table,
|
||||
state: TableState::default(),
|
||||
scroll_state: ScrollbarState::new(0),
|
||||
rows: 0,
|
||||
rows_display: 0,
|
||||
row_height: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_content(&mut self, content: Vec<Vec<Text<'static>>>, row_height: usize) {
|
||||
let colors = TableColors::new(&tailwind::BLUE);
|
||||
let selected_style = Style::default()
|
||||
.add_modifier(Modifier::REVERSED)
|
||||
.fg(colors.selected_style_fg);
|
||||
|
||||
let header_style = Style::default().fg(colors.header_fg).bg(colors.header_bg);
|
||||
|
||||
self.row_height = row_height;
|
||||
let widths = once(&self.header)
|
||||
.chain(content.iter())
|
||||
.map(|row| row.iter().map(Text::width).collect())
|
||||
.reduce(|widths: Vec<usize>, row| {
|
||||
row.iter()
|
||||
.zip(widths.iter())
|
||||
.map(|(r, w)| r.max(w))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
self.rows = content.len();
|
||||
self.scroll_state = ScrollbarState::new(self.rows * self.row_height);
|
||||
|
||||
let content = content.into_iter().enumerate().map(|(i, row)| {
|
||||
let color = match i % 2 {
|
||||
0 => colors.normal_row_color,
|
||||
_ => colors.alt_row_color,
|
||||
};
|
||||
Row::new(row)
|
||||
.style(Style::new().fg(colors.row_fg).bg(color))
|
||||
.height(self.row_height.try_into().unwrap())
|
||||
});
|
||||
|
||||
self.table = Table::default()
|
||||
.header(Row::new(self.header.clone()).style(header_style))
|
||||
.row_highlight_style(selected_style)
|
||||
.bg(colors.buffer_bg)
|
||||
.widths(widths.iter().map(|w| {
|
||||
(*w).try_into()
|
||||
.ok()
|
||||
.map_or(Constraint::Min(0), Constraint::Length)
|
||||
}))
|
||||
.flex(layout::Flex::SpaceBetween)
|
||||
.rows(content);
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.state.selected()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, index: Option<usize>) {
|
||||
self.state.select(index);
|
||||
}
|
||||
|
||||
pub fn set_to(&mut self, i: usize) {
|
||||
self.state.select(Some(i));
|
||||
self.scroll_state = self.scroll_state.position(i * self.row_height);
|
||||
}
|
||||
|
||||
pub fn go_forward(&mut self, step: usize) {
|
||||
if let Some(selected_old) = self.state.selected() {
|
||||
let selected = (selected_old + step).min(self.rows - 1);
|
||||
self.set_to(selected);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_back(&mut self, step: usize) {
|
||||
if let Some(selected_old) = self.state.selected() {
|
||||
let selected = selected_old.saturating_sub(step);
|
||||
self.set_to(selected);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
self.go_forward(1);
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
self.go_forward(self.rows_display);
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
self.go_back(1);
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.go_back(self.rows_display);
|
||||
}
|
||||
|
||||
pub fn home(&mut self) {
|
||||
if self.state.selected().is_some() {
|
||||
self.set_to(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(&mut self) {
|
||||
if self.state.selected().is_some() {
|
||||
self.set_to(self.rows - 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rows(&mut self, rows: usize) {
|
||||
self.rows_display = rows / self.row_height;
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessEvent for SelectTable {
|
||||
type Result = ();
|
||||
fn input(&mut self, event: Event) {
|
||||
use KeyCode::*;
|
||||
match event {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
|
||||
Down => self.next(),
|
||||
Up => self.previous(),
|
||||
PageDown => self.page_down(),
|
||||
PageUp => self.page_up(),
|
||||
Home => self.home(),
|
||||
End => self.end(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SizedWidget for SelectTable {}
|
||||
|
||||
impl Draw for SelectTable {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
self.set_rows(area.height.into());
|
||||
let chunks = Layout::horizontal([Constraint::Min(0), Constraint::Length(1)]).split(area);
|
||||
f.render_stateful_widget(&self.table, chunks[0], &mut self.state);
|
||||
f.render_stateful_widget(
|
||||
Scrollbar::default()
|
||||
.orientation(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None),
|
||||
chunks[1],
|
||||
&mut self.scroll_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/commands/tui/widgets/sized_gauge.rs
Normal file
33
src/commands/tui/widgets/sized_gauge.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use super::*;
|
||||
|
||||
pub struct SizedGauge {
|
||||
p: Gauge<'static>,
|
||||
width: Option<u16>,
|
||||
}
|
||||
|
||||
impl SizedGauge {
|
||||
pub fn new(text: Span<'static>, ratio: f64) -> Self {
|
||||
let width = text.width().try_into().ok();
|
||||
let p = Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Blue))
|
||||
.use_unicode(true)
|
||||
.label(text)
|
||||
.ratio(ratio);
|
||||
Self { p, width }
|
||||
}
|
||||
}
|
||||
|
||||
impl SizedWidget for SizedGauge {
|
||||
fn width(&self) -> Option<u16> {
|
||||
self.width.map(|w| w + 10)
|
||||
}
|
||||
fn height(&self) -> Option<u16> {
|
||||
Some(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Draw for SizedGauge {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
f.render_widget(&self.p, area);
|
||||
}
|
||||
}
|
||||
31
src/commands/tui/widgets/sized_paragraph.rs
Normal file
31
src/commands/tui/widgets/sized_paragraph.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
pub struct SizedParagraph {
|
||||
p: Paragraph<'static>,
|
||||
height: Option<u16>,
|
||||
width: Option<u16>,
|
||||
}
|
||||
|
||||
impl SizedParagraph {
|
||||
pub fn new(text: Text<'static>) -> Self {
|
||||
let height = text.height().try_into().ok();
|
||||
let width = text.width().try_into().ok();
|
||||
let p = Paragraph::new(text);
|
||||
Self { p, height, width }
|
||||
}
|
||||
}
|
||||
|
||||
impl SizedWidget for SizedParagraph {
|
||||
fn width(&self) -> Option<u16> {
|
||||
self.width
|
||||
}
|
||||
fn height(&self) -> Option<u16> {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
impl Draw for SizedParagraph {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
f.render_widget(&self.p, area);
|
||||
}
|
||||
}
|
||||
63
src/commands/tui/widgets/sized_table.rs
Normal file
63
src/commands/tui/widgets/sized_table.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use super::*;
|
||||
|
||||
pub struct SizedTable {
|
||||
table: Table<'static>,
|
||||
height: usize,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl SizedTable {
|
||||
pub fn new(content: Vec<Vec<Text<'static>>>) -> Self {
|
||||
let height = content
|
||||
.iter()
|
||||
.map(|row| row.iter().map(Text::height).max().unwrap_or_default())
|
||||
.sum::<usize>();
|
||||
|
||||
let widths = content
|
||||
.iter()
|
||||
.map(|row| row.iter().map(Text::width).collect())
|
||||
.reduce(|widths: Vec<usize>, row| {
|
||||
row.iter()
|
||||
.zip(widths.iter())
|
||||
.map(|(r, w)| r.max(w))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let width = widths
|
||||
.iter()
|
||||
.cloned()
|
||||
.reduce(|width, w| width + w + 1) // +1 because of space between entries
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows = content.into_iter().map(Row::new);
|
||||
let table = Table::default()
|
||||
.widths(widths.iter().map(|w| {
|
||||
(*w).try_into()
|
||||
.ok()
|
||||
.map_or(Constraint::Min(0), Constraint::Length)
|
||||
}))
|
||||
.rows(rows);
|
||||
Self {
|
||||
table,
|
||||
height,
|
||||
width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SizedWidget for SizedTable {
|
||||
fn height(&self) -> Option<u16> {
|
||||
self.height.try_into().ok()
|
||||
}
|
||||
fn width(&self) -> Option<u16> {
|
||||
self.width.try_into().ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Draw for SizedTable {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
f.render_widget(&self.table, area);
|
||||
}
|
||||
}
|
||||
88
src/commands/tui/widgets/text_input.rs
Normal file
88
src/commands/tui/widgets/text_input.rs
Normal file
@ -0,0 +1,88 @@
|
||||
use super::*;
|
||||
|
||||
use crossterm::event::KeyModifiers;
|
||||
use tui_textarea::{CursorMove, TextArea};
|
||||
|
||||
pub struct TextInput {
|
||||
textarea: TextArea<'static>,
|
||||
lines: u16,
|
||||
changeable: bool,
|
||||
}
|
||||
|
||||
pub enum TextInputResult {
|
||||
Cancel,
|
||||
Input(String),
|
||||
None,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn new(text: Option<&str>, initial: &str, lines: u16, changeable: bool) -> Self {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_style(Style::default());
|
||||
if let Some(text) = text {
|
||||
textarea.set_placeholder_text(text);
|
||||
}
|
||||
_ = textarea.insert_str(initial);
|
||||
if !changeable {
|
||||
textarea.move_cursor(CursorMove::Top);
|
||||
}
|
||||
Self {
|
||||
textarea,
|
||||
lines,
|
||||
changeable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SizedWidget for TextInput {
|
||||
fn height(&self) -> Option<u16> {
|
||||
Some(self.lines)
|
||||
}
|
||||
}
|
||||
|
||||
impl Draw for TextInput {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
f.render_widget(&self.textarea, area);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessEvent for TextInput {
|
||||
type Result = TextInputResult;
|
||||
fn input(&mut self, event: Event) -> TextInputResult {
|
||||
if let Event::Key(key) = event {
|
||||
let KeyEvent {
|
||||
code, modifiers, ..
|
||||
} = key;
|
||||
use KeyCode::*;
|
||||
if self.changeable {
|
||||
match (code, modifiers) {
|
||||
(Esc, _) => return TextInputResult::Cancel,
|
||||
(Enter, _) if self.lines == 1 => {
|
||||
return TextInputResult::Input(self.textarea.lines().join("\n"));
|
||||
}
|
||||
(Char('s'), KeyModifiers::CONTROL) => {
|
||||
return TextInputResult::Input(self.textarea.lines().join("\n"));
|
||||
}
|
||||
_ => {
|
||||
_ = self.textarea.input(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match (code, modifiers) {
|
||||
(Esc | Enter | Char('q') | Char('x'), _) => return TextInputResult::Cancel,
|
||||
(Home, _) => {
|
||||
self.textarea.move_cursor(CursorMove::Top);
|
||||
}
|
||||
(End, _) => {
|
||||
self.textarea.move_cursor(CursorMove::Bottom);
|
||||
}
|
||||
(PageDown | PageUp | Up | Down, _) => {
|
||||
_ = self.textarea.input(key);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
TextInputResult::None
|
||||
}
|
||||
}
|
||||
57
src/commands/tui/widgets/with_block.rs
Normal file
57
src/commands/tui/widgets/with_block.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use super::*;
|
||||
use layout::Size;
|
||||
|
||||
pub struct WithBlock<T> {
|
||||
pub block: Block<'static>,
|
||||
pub widget: T,
|
||||
}
|
||||
|
||||
impl<T> WithBlock<T> {
|
||||
pub fn new(widget: T, block: Block<'static>) -> Self {
|
||||
Self { block, widget }
|
||||
}
|
||||
|
||||
// Note: this could be a method of self.block, but is unfortunately not present
|
||||
// So we compute ourselves using self.block.inner() on an artificial Rect.
|
||||
fn size_diff(&self) -> Size {
|
||||
let rect = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: u16::MAX,
|
||||
height: u16::MAX,
|
||||
};
|
||||
let inner = self.block.inner(rect);
|
||||
Size {
|
||||
width: rect.as_size().width - inner.as_size().width,
|
||||
height: rect.as_size().height - inner.as_size().height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ProcessEvent> ProcessEvent for WithBlock<T> {
|
||||
type Result = T::Result;
|
||||
fn input(&mut self, event: Event) -> Self::Result {
|
||||
self.widget.input(event)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SizedWidget> SizedWidget for WithBlock<T> {
|
||||
fn height(&self) -> Option<u16> {
|
||||
self.widget
|
||||
.height()
|
||||
.map(|h| h.saturating_add(self.size_diff().height))
|
||||
}
|
||||
|
||||
fn width(&self) -> Option<u16> {
|
||||
self.widget
|
||||
.width()
|
||||
.map(|w| w.saturating_add(self.size_diff().width))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Draw + SizedWidget> Draw for WithBlock<T> {
|
||||
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
|
||||
f.render_widget(self.block.clone(), area);
|
||||
self.widget.draw(self.block.inner(area), f);
|
||||
}
|
||||
}
|
||||
143
src/commands/webdav.rs
Normal file
143
src/commands/webdav.rs
Normal file
@ -0,0 +1,143 @@
|
||||
//! `webdav` subcommand
|
||||
|
||||
// ignore markdown clippy lints as we use doc-comments to generate clap help texts
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::net::ToSocketAddrs;
|
||||
|
||||
use crate::{repository::CliIndexedRepo, status_err, Application, RusticConfig, RUSTIC_APP};
|
||||
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
|
||||
use anyhow::{anyhow, Result};
|
||||
use conflate::Merge;
|
||||
use dav_server::{warp::dav_handler, DavHandler};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
|
||||
|
||||
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct WebDavCmd {
|
||||
/// Address to bind the webdav server to. [default: "localhost:8000"]
|
||||
#[clap(long, value_name = "ADDRESS")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
address: Option<String>,
|
||||
|
||||
/// The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
path_template: Option<String>,
|
||||
|
||||
/// The time template to use to display times in the path template. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for format options. [default: "%Y-%m-%d_%H-%M-%S"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
time_template: Option<String>,
|
||||
|
||||
/// Use symlinks. This may not be supported by all WebDAV clients
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
symlinks: bool,
|
||||
|
||||
/// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
|
||||
#[clap(long)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
file_access: Option<String>,
|
||||
|
||||
/// Specify directly which snapshot/path to serve
|
||||
#[clap(value_name = "SNAPSHOT[:PATH]")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
snapshot_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Override<RusticConfig> for WebDavCmd {
|
||||
// Process the given command line options, overriding settings from
|
||||
// a configuration file using explicit flags taken from command-line
|
||||
// arguments.
|
||||
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
|
||||
let mut self_config = self.clone();
|
||||
// merge "webdav" section from config file, if given
|
||||
self_config.merge(config.webdav);
|
||||
config.webdav = self_config;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runnable for WebDavCmd {
|
||||
fn run(&self) {
|
||||
if let Err(err) = RUSTIC_APP
|
||||
.config()
|
||||
.repository
|
||||
.run_indexed(|repo| self.inner_run(repo))
|
||||
{
|
||||
status_err!("{}", err);
|
||||
RUSTIC_APP.shutdown(Shutdown::Crash);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl WebDavCmd {
|
||||
/// be careful about self VS RUSTIC_APP.config() usage
|
||||
/// only the RUSTIC_APP.config() involves the TOML and ENV merged configurations
|
||||
/// see https://github.com/rustic-rs/rustic/issues/1242
|
||||
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
|
||||
let config = RUSTIC_APP.config();
|
||||
|
||||
let path_template = config
|
||||
.webdav
|
||||
.path_template
|
||||
.clone()
|
||||
.unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string());
|
||||
let time_template = config
|
||||
.webdav
|
||||
.time_template
|
||||
.clone()
|
||||
.unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string());
|
||||
|
||||
let sn_filter = |sn: &_| config.snapshot_filter.matches(sn);
|
||||
|
||||
let vfs = if let Some(snap) = &config.webdav.snapshot_path {
|
||||
let node = repo.node_from_snapshot_path(snap, sn_filter)?;
|
||||
Vfs::from_dir_node(&node)
|
||||
} else {
|
||||
let snapshots = repo.get_matching_snapshots(sn_filter)?;
|
||||
let (latest, identical) = if config.webdav.symlinks {
|
||||
(Latest::AsLink, IdenticalSnapshot::AsLink)
|
||||
} else {
|
||||
(Latest::AsDir, IdenticalSnapshot::AsDir)
|
||||
};
|
||||
Vfs::from_snapshots(snapshots, &path_template, &time_template, latest, identical)?
|
||||
};
|
||||
|
||||
let addr = config
|
||||
.webdav
|
||||
.address
|
||||
.clone()
|
||||
.unwrap_or_else(|| "localhost:8000".to_string())
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("no address given"))?;
|
||||
|
||||
let file_access = config.webdav.file_access.as_ref().map_or_else(
|
||||
|| {
|
||||
if repo.config().is_hot == Some(true) {
|
||||
Ok(FilePolicy::Forbidden)
|
||||
} else {
|
||||
Ok(FilePolicy::Read)
|
||||
}
|
||||
},
|
||||
|s| s.parse(),
|
||||
)?;
|
||||
|
||||
let dav_server = DavHandler::builder()
|
||||
.filesystem(vfs.into_webdav_fs(repo, file_access))
|
||||
.build_handler();
|
||||
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(async {
|
||||
warp::serve(dav_handler(dav_server)).run(addr).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -4,27 +4,33 @@
|
||||
//! application's configuration file and/or command-line options
|
||||
//! for specifying it.
|
||||
|
||||
pub(crate) mod hooks;
|
||||
pub(crate) mod progress_options;
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use abscissa_core::{config::Config, path::AbsPathBuf, FrameworkError};
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, ValueHint};
|
||||
use conflate::Merge;
|
||||
use directories::ProjectDirs;
|
||||
|
||||
use merge::Merge;
|
||||
|
||||
use abscissa_core::config::Config;
|
||||
use abscissa_core::path::AbsPathBuf;
|
||||
use abscissa_core::FrameworkError;
|
||||
use clap::Parser;
|
||||
use itertools::Itertools;
|
||||
use log::Level;
|
||||
use rustic_core::RepositoryOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(not(all(feature = "mount", feature = "webdav")))]
|
||||
use toml::Value;
|
||||
|
||||
#[cfg(feature = "mount")]
|
||||
use crate::commands::mount::MountCmd;
|
||||
#[cfg(feature = "webdav")]
|
||||
use crate::commands::webdav::WebDavCmd;
|
||||
|
||||
use crate::{
|
||||
commands::{backup::BackupCmd, copy::Targets, forget::ForgetOptions},
|
||||
config::progress_options::ProgressOptions,
|
||||
commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
|
||||
config::{hooks::Hooks, progress_options::ProgressOptions},
|
||||
filtering::SnapshotFilter,
|
||||
repository::AllRepositoryOptions,
|
||||
};
|
||||
|
||||
/// Rustic Configuration
|
||||
@ -33,7 +39,7 @@ use crate::{
|
||||
///
|
||||
/// # Example
|
||||
// TODO: add example
|
||||
#[derive(Clone, Default, Debug, Parser, Deserialize, Merge)]
|
||||
#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct RusticConfig {
|
||||
/// Global options
|
||||
@ -42,7 +48,7 @@ pub struct RusticConfig {
|
||||
|
||||
/// Repository options
|
||||
#[clap(flatten, next_help_heading = "Repository options")]
|
||||
pub repository: RepositoryOptions,
|
||||
pub repository: AllRepositoryOptions,
|
||||
|
||||
/// Snapshot filter options
|
||||
#[clap(flatten, next_help_heading = "Snapshot filter options")]
|
||||
@ -54,11 +60,27 @@ pub struct RusticConfig {
|
||||
|
||||
/// Copy options
|
||||
#[clap(skip)]
|
||||
pub copy: Targets,
|
||||
pub copy: CopyCmd,
|
||||
|
||||
/// Forget options
|
||||
#[clap(skip)]
|
||||
pub forget: ForgetOptions,
|
||||
|
||||
/// mount options
|
||||
#[clap(skip)]
|
||||
#[cfg(feature = "mount")]
|
||||
pub mount: MountCmd,
|
||||
#[cfg(not(feature = "mount"))]
|
||||
#[merge(skip)]
|
||||
pub mount: Option<Value>,
|
||||
|
||||
/// webdav options
|
||||
#[clap(skip)]
|
||||
#[cfg(feature = "webdav")]
|
||||
pub webdav: WebDavCmd,
|
||||
#[cfg(not(feature = "webdav"))]
|
||||
#[merge(skip)]
|
||||
pub webdav: Option<Value>,
|
||||
}
|
||||
|
||||
impl RusticConfig {
|
||||
@ -83,7 +105,7 @@ impl RusticConfig {
|
||||
merge_logs.push((Level::Info, format!("using config {}", path.display())));
|
||||
let mut config = Self::load_toml_file(AbsPathBuf::canonicalize(path)?)?;
|
||||
// if "use_profile" is defined in config file, merge the referenced profiles first
|
||||
for profile in &config.global.use_profile.clone() {
|
||||
for profile in &config.global.use_profiles.clone() {
|
||||
config.merge_profile(profile, merge_logs, Level::Warn)?;
|
||||
}
|
||||
self.merge(config);
|
||||
@ -111,21 +133,27 @@ pub struct GlobalOptions {
|
||||
/// [default: "rustic"]
|
||||
#[clap(
|
||||
short = 'P',
|
||||
long,
|
||||
long = "use-profile",
|
||||
global = true,
|
||||
value_name = "PROFILE",
|
||||
env = "RUSTIC_USE_PROFILE"
|
||||
)]
|
||||
#[merge(strategy = merge::vec::append)]
|
||||
pub use_profile: Vec<String>,
|
||||
#[merge(strategy=conflate::vec::append)]
|
||||
pub use_profiles: Vec<String>,
|
||||
|
||||
/// Only show what would be done without modifying anything. Does not affect read-only commands.
|
||||
#[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
|
||||
#[merge(strategy = merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
pub dry_run: bool,
|
||||
|
||||
/// Check if index matches pack files and read pack headers if neccessary
|
||||
#[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
pub check_index: bool,
|
||||
|
||||
/// Use this log level [default: info]
|
||||
#[clap(long, global = true, env = "RUSTIC_LOG_LEVEL")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
pub log_level: Option<String>,
|
||||
|
||||
/// Write log messages to the given file instead of printing them.
|
||||
@ -133,7 +161,8 @@ pub struct GlobalOptions {
|
||||
/// # Note
|
||||
///
|
||||
/// Warnings and errors are still additionally printed unless they are ignored by `--log-level`
|
||||
#[clap(long, global = true, env = "RUSTIC_LOG_FILE", value_name = "LOGFILE")]
|
||||
#[clap(long, global = true, env = "RUSTIC_LOG_FILE", value_name = "LOGFILE", value_hint = ValueHint::FilePath)]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
pub log_file: Option<PathBuf>,
|
||||
|
||||
/// Settings to customize progress bars
|
||||
@ -141,18 +170,16 @@ pub struct GlobalOptions {
|
||||
#[serde(flatten)]
|
||||
pub progress_options: ProgressOptions,
|
||||
|
||||
/// Hooks
|
||||
#[clap(skip)]
|
||||
pub hooks: Hooks,
|
||||
|
||||
/// List of environment variables to set (only in config file)
|
||||
#[clap(skip)]
|
||||
#[merge(strategy = extend)]
|
||||
#[merge(strategy = conflate::hashmap::ignore)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Extend the contents of a [`HashMap`] with the contents of another
|
||||
/// [`HashMap`] with the same key and value types.
|
||||
fn extend(left: &mut HashMap<String, String>, right: HashMap<String, String>) {
|
||||
left.extend(right);
|
||||
}
|
||||
|
||||
/// Get the paths to the config file
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
107
src/config/hooks.rs
Normal file
107
src/config/hooks.rs
Normal file
@ -0,0 +1,107 @@
|
||||
//! rustic hooks configuration
|
||||
//!
|
||||
//! Hooks are commands that are executed before and after every rustic operation.
|
||||
//! They can be used to run custom scripts or commands before and after a backup,
|
||||
//! copy, forget, prune or other operation.
|
||||
//!
|
||||
//! Depending on the hook type, the command is being executed at a different point
|
||||
//! in the lifecycle of the program. The following hooks are available:
|
||||
//!
|
||||
//! - global hooks
|
||||
//! - repository hooks
|
||||
//! - backup hooks
|
||||
//! - specific source-related hooks
|
||||
|
||||
use anyhow::Result;
|
||||
use conflate::Merge;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rustic_core::CommandInput;
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct Hooks {
|
||||
/// Call this command before every rustic operation
|
||||
#[merge(strategy = conflate::vec::append)]
|
||||
pub run_before: Vec<CommandInput>,
|
||||
|
||||
/// Call this command after every successful rustic operation
|
||||
#[merge(strategy = conflate::vec::append)]
|
||||
pub run_after: Vec<CommandInput>,
|
||||
|
||||
/// Call this command after every failed rustic operation
|
||||
#[merge(strategy = conflate::vec::append)]
|
||||
pub run_failed: Vec<CommandInput>,
|
||||
|
||||
/// Call this command after every rustic operation
|
||||
#[merge(strategy = conflate::vec::append)]
|
||||
pub run_finally: Vec<CommandInput>,
|
||||
|
||||
#[serde(skip)]
|
||||
#[merge(skip)]
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
impl Hooks {
|
||||
pub fn with_context(&self, context: &str) -> Self {
|
||||
let mut hooks = self.clone();
|
||||
hooks.context = context.to_string();
|
||||
hooks
|
||||
}
|
||||
|
||||
fn run_all(cmds: &[CommandInput], context: &str, what: &str) -> Result<()> {
|
||||
for cmd in cmds {
|
||||
cmd.run(context, what)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_before(&self) -> Result<()> {
|
||||
Self::run_all(&self.run_before, &self.context, "run-before")
|
||||
}
|
||||
|
||||
pub fn run_after(&self) -> Result<()> {
|
||||
Self::run_all(&self.run_after, &self.context, "run-after")
|
||||
}
|
||||
|
||||
pub fn run_failed(&self) -> Result<()> {
|
||||
Self::run_all(&self.run_failed, &self.context, "run-failed")
|
||||
}
|
||||
|
||||
pub fn run_finally(&self) -> Result<()> {
|
||||
Self::run_all(&self.run_finally, &self.context, "run-finally")
|
||||
}
|
||||
|
||||
/// Run the given closure using the specified hooks.
|
||||
///
|
||||
/// Note: after a failure no error handling is done for the hooks `run_failed`
|
||||
/// and `run_finally` which must run after. However, they already log a warning
|
||||
/// or error depending on the `on_failure` setting.
|
||||
pub fn use_with<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
match self.run_before() {
|
||||
Ok(_) => match f() {
|
||||
Ok(result) => match self.run_after() {
|
||||
Ok(_) => {
|
||||
self.run_finally()?;
|
||||
Ok(result)
|
||||
}
|
||||
Err(err_after) => {
|
||||
_ = self.run_finally();
|
||||
Err(err_after)
|
||||
}
|
||||
},
|
||||
Err(err_f) => {
|
||||
_ = self.run_failed();
|
||||
_ = self.run_finally();
|
||||
Err(err_f)
|
||||
}
|
||||
},
|
||||
Err(err_before) => {
|
||||
_ = self.run_failed();
|
||||
_ = self.run_finally();
|
||||
Err(err_before)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ use std::{borrow::Cow, fmt::Write, time::Duration};
|
||||
use indicatif::{HumanDuration, ProgressBar, ProgressState, ProgressStyle};
|
||||
|
||||
use clap::Parser;
|
||||
use merge::Merge;
|
||||
use conflate::Merge;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
@ -19,7 +19,7 @@ use rustic_core::{Progress, ProgressBars};
|
||||
pub struct ProgressOptions {
|
||||
/// Don't show any progress bar
|
||||
#[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
|
||||
#[merge(strategy=merge::bool::overwrite_false)]
|
||||
#[merge(strategy=conflate::bool::overwrite_false)]
|
||||
pub no_progress: bool,
|
||||
|
||||
/// Interval to update progress bars
|
||||
@ -31,6 +31,7 @@ pub struct ProgressOptions {
|
||||
conflicts_with = "no_progress"
|
||||
)]
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
pub progress_interval: Option<humantime::Duration>,
|
||||
}
|
||||
|
||||
@ -131,11 +132,13 @@ impl Progress for RusticProgress {
|
||||
ProgressType::Bytes => {
|
||||
self.0.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.with_key("my_eta", |s: &ProgressState, w: &mut dyn Write|
|
||||
match (s.pos(), s.len()){
|
||||
(pos,Some(len)) if pos != 0 => write!(w,"{:#}", HumanDuration(Duration::from_secs(s.elapsed().as_secs() * (len-pos)/pos))),
|
||||
.with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| {
|
||||
let _ = match (s.pos(), s.len()){
|
||||
// Extra checks to prevent panics from dividing by zero or subtract overflow
|
||||
(pos,Some(len)) if pos != 0 && len > pos => write!(w,"{:#}", HumanDuration(Duration::from_secs(s.elapsed().as_secs() * (len-pos)/pos))),
|
||||
(_, _) => write!(w,"-"),
|
||||
}.unwrap())
|
||||
};
|
||||
})
|
||||
.template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})")
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
234
src/filtering.rs
234
src/filtering.rs
@ -1,11 +1,20 @@
|
||||
use crate::error::RhaiErrorKinds;
|
||||
|
||||
use bytesize::ByteSize;
|
||||
use derive_more::derive::Display;
|
||||
use log::warn;
|
||||
use rustic_core::{repofile::SnapshotFile, StringList};
|
||||
use std::{error::Error, str::FromStr};
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt::{Debug, Display},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use chrono::{DateTime, Local, NaiveTime};
|
||||
use conflate::Merge;
|
||||
use rhai::{serde::to_dynamic, Dynamic, Engine, FnPtr, AST};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
||||
/// A function to filter snapshots
|
||||
@ -24,6 +33,17 @@ impl FromStr for SnapshotFn {
|
||||
}
|
||||
}
|
||||
|
||||
#[cached(key = "String", convert = r#"{ s.to_string() }"#, size = 1)]
|
||||
fn string_to_fn(s: &str) -> Option<SnapshotFn> {
|
||||
match SnapshotFn::from_str(s) {
|
||||
Ok(filter_fn) => Some(filter_fn),
|
||||
Err(err) => {
|
||||
warn!("Error evaluating filter-fn {s}: {err}",);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotFn {
|
||||
/// Call the function with a [`SnapshotFile`]
|
||||
///
|
||||
@ -43,35 +63,72 @@ impl SnapshotFn {
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Default, Debug, Deserialize, merge::Merge, clap::Parser)]
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize, Merge, clap::Parser)]
|
||||
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub struct SnapshotFilter {
|
||||
/// Hostname to filter (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "HOSTNAME")]
|
||||
#[merge(strategy=merge::vec::overwrite_empty)]
|
||||
filter_host: Vec<String>,
|
||||
#[clap(long = "filter-host", global = true, value_name = "HOSTNAME")]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_hosts: Vec<String>,
|
||||
|
||||
/// Label to filter (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "LABEL")]
|
||||
#[merge(strategy=merge::vec::overwrite_empty)]
|
||||
filter_label: Vec<String>,
|
||||
#[clap(long = "filter-label", global = true, value_name = "LABEL")]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_labels: Vec<String>,
|
||||
|
||||
/// Path list to filter (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "PATH[,PATH,..]")]
|
||||
#[serde_as(as = "Vec<DisplayFromStr>")]
|
||||
#[merge(strategy=merge::vec::overwrite_empty)]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_paths: Vec<StringList>,
|
||||
|
||||
/// Path list to filter exactly (no superset) as given (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "PATH[,PATH,..]")]
|
||||
#[serde_as(as = "Vec<DisplayFromStr>")]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_paths_exact: Vec<StringList>,
|
||||
|
||||
/// Tag list to filter (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "TAG[,TAG,..]")]
|
||||
#[serde_as(as = "Vec<DisplayFromStr>")]
|
||||
#[merge(strategy=merge::vec::overwrite_empty)]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_tags: Vec<StringList>,
|
||||
|
||||
/// Tag list to filter exactly (no superset) as given (can be specified multiple times)
|
||||
#[clap(long, global = true, value_name = "TAG[,TAG,..]")]
|
||||
#[serde_as(as = "Vec<DisplayFromStr>")]
|
||||
#[merge(strategy=conflate::vec::overwrite_empty)]
|
||||
filter_tags_exact: Vec<StringList>,
|
||||
|
||||
/// Only use snapshots which are taken after the given given date/time
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[clap(long, global = true, value_name = "DATE(TIME)")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
filter_after: Option<AfterDate>,
|
||||
|
||||
/// Only use snapshots which are taken before the given given date/time
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[clap(long, global = true, value_name = "DATE(TIME)")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
filter_before: Option<BeforeDate>,
|
||||
|
||||
/// Only use snapshots with total size in given range
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[clap(long, global = true, value_name = "SIZE")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
filter_size: Option<SizeRange>,
|
||||
|
||||
/// Only use snapshots with size added to the repo in given range
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
#[clap(long, global = true, value_name = "SIZE")]
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
filter_size_added: Option<SizeRange>,
|
||||
|
||||
/// Function to filter snapshots
|
||||
#[clap(long, global = true, value_name = "FUNC")]
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
filter_fn: Option<SnapshotFn>,
|
||||
#[merge(strategy=conflate::option::overwrite_none)]
|
||||
filter_fn: Option<String>,
|
||||
}
|
||||
|
||||
impl SnapshotFilter {
|
||||
@ -87,24 +144,153 @@ impl SnapshotFilter {
|
||||
#[must_use]
|
||||
pub fn matches(&self, snapshot: &SnapshotFile) -> bool {
|
||||
if let Some(filter_fn) = &self.filter_fn {
|
||||
match filter_fn.call::<bool>(snapshot) {
|
||||
Ok(result) => {
|
||||
if !result {
|
||||
return false;
|
||||
if let Some(func) = string_to_fn(filter_fn) {
|
||||
match func.call::<bool>(snapshot) {
|
||||
Ok(result) => {
|
||||
if !result {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error evaluating filter-fn for snapshot {}: {err}",
|
||||
snapshot.id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Error evaluating filter-fn for snapshot {}: {err}",
|
||||
snapshot.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For the `Option`s we check if the option is set and the condition is not matched. In this case we can early return false.
|
||||
if matches!(&self.filter_after, Some(after) if !after.matches(snapshot.time))
|
||||
|| matches!(&self.filter_before, Some(before) if !before.matches(snapshot.time))
|
||||
|| matches!((&self.filter_size,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.total_bytes_processed))
|
||||
|| matches!((&self.filter_size_added,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.data_added))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// For the the `Vec`s we have two possibilities:
|
||||
// - There exists a suitable matches method on the snapshot item
|
||||
// (this automatically handles empty filter correctly):
|
||||
snapshot.paths.matches(&self.filter_paths)
|
||||
&& snapshot.tags.matches(&self.filter_tags)
|
||||
&& (self.filter_host.is_empty() || self.filter_host.contains(&snapshot.hostname))
|
||||
&& (self.filter_label.is_empty() || self.filter_label.contains(&snapshot.label))
|
||||
// - manually check if the snapshot item is contained in the `Vec`
|
||||
// but only if the `Vec` is not empty.
|
||||
// If it is empty, no condition is given.
|
||||
&& (self.filter_paths_exact.is_empty()
|
||||
|| self.filter_paths_exact.contains(&snapshot.paths))
|
||||
&& (self.filter_tags_exact.is_empty()
|
||||
|| self.filter_tags_exact.contains(&snapshot.tags))
|
||||
&& (self.filter_hosts.is_empty() || self.filter_hosts.contains(&snapshot.hostname))
|
||||
&& (self.filter_labels.is_empty() || self.filter_labels.contains(&snapshot.label))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Display)]
|
||||
struct AfterDate(DateTime<Local>);
|
||||
|
||||
impl AfterDate {
|
||||
fn matches(&self, datetime: DateTime<Local>) -> bool {
|
||||
self.0 < datetime
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AfterDate {
|
||||
type Err = anyhow::Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let before_midnight = NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap();
|
||||
let datetime = dateparser::parse_with(s, &Local, before_midnight)?;
|
||||
Ok(Self(datetime.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Display)]
|
||||
struct BeforeDate(DateTime<Local>);
|
||||
|
||||
impl BeforeDate {
|
||||
fn matches(&self, datetime: DateTime<Local>) -> bool {
|
||||
datetime < self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for BeforeDate {
|
||||
type Err = anyhow::Error;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||
let datetime = dateparser::parse_with(s, &Local, midnight)?;
|
||||
Ok(Self(datetime.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SizeRange {
|
||||
from: Option<ByteSize>,
|
||||
to: Option<ByteSize>,
|
||||
}
|
||||
|
||||
impl SizeRange {
|
||||
fn matches(&self, size: u64) -> bool {
|
||||
// The matches-expression is only true if the `Option` is `Some` and the size is smaller than from.
|
||||
// Hence, !matches is true either if `self.from` is `None` or if the size >= the values
|
||||
!matches!(self.from, Some(from) if size < from.0)
|
||||
// same logic here, but smaller and greater swapped.
|
||||
&& !matches!(self.to, Some(to) if size > to.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_size(s: &str) -> Result<Option<ByteSize>, String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(s.parse()?))
|
||||
}
|
||||
|
||||
impl FromStr for SizeRange {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let (from, to) = match s.split_once("..") {
|
||||
Some((s1, s2)) => (parse_size(s1)?, parse_size(s2)?),
|
||||
None => (parse_size(s)?, None),
|
||||
};
|
||||
Ok(Self { from, to })
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SizeRange {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(from) = self.from {
|
||||
f.write_str(&from.to_string_as(true))?;
|
||||
}
|
||||
f.write_str("..")?;
|
||||
if let Some(to) = self.to {
|
||||
f.write_str(&to.to_string_as(true))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case("..", None, None)]
|
||||
#[case("10", Some(10), None)]
|
||||
#[case("..10k", None, Some(10_000))]
|
||||
#[case("1MB..", Some(1_000_000), None)]
|
||||
#[case("1 MB .. 1 GiB", Some(1_000_000), Some(1_073_741_824))]
|
||||
#[case("10 .. 20 ", Some(10), Some(20))]
|
||||
#[case(" 2G ", Some(2_000_000_000), None)]
|
||||
fn size_range_from_str(
|
||||
#[case] input: SizeRange,
|
||||
#[case] from: Option<u64>,
|
||||
#[case] to: Option<u64>,
|
||||
) {
|
||||
assert_eq!(input.from.map(|v| v.0), from);
|
||||
assert_eq!(input.to.map(|v| v.0), to);
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,10 +34,8 @@ Application based on the [Abscissa] framework.
|
||||
patterns_in_fns_without_body,
|
||||
trivial_numeric_casts,
|
||||
unused_results,
|
||||
trivial_casts,
|
||||
unused_extern_crates,
|
||||
unused_import_braces,
|
||||
unused_qualifications,
|
||||
unconditional_recursion,
|
||||
unused,
|
||||
unused_allocation,
|
||||
@ -64,6 +62,7 @@ pub(crate) mod config;
|
||||
pub(crate) mod error;
|
||||
pub(crate) mod filtering;
|
||||
pub(crate) mod helpers;
|
||||
pub(crate) mod repository;
|
||||
|
||||
// rustic_cli Public API
|
||||
|
||||
|
||||
167
src/repository.rs
Normal file
167
src/repository.rs
Normal file
@ -0,0 +1,167 @@
|
||||
//! Rustic Config
|
||||
//!
|
||||
//! See instructions in `commands.rs` to specify the path to your
|
||||
//! application's configuration file and/or command-line options
|
||||
//! for specifying it.
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
|
||||
use abscissa_core::Application;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use clap::Parser;
|
||||
use conflate::Merge;
|
||||
use dialoguer::Password;
|
||||
use rustic_backend::BackendOptions;
|
||||
use rustic_core::{
|
||||
FullIndex, IndexedStatus, OpenStatus, ProgressBars, Repository, RepositoryOptions,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
config::{hooks::Hooks, progress_options::ProgressOptions},
|
||||
RUSTIC_APP,
|
||||
};
|
||||
|
||||
pub(super) mod constants {
|
||||
pub(super) const MAX_PASSWORD_RETRIES: usize = 5;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, Parser, Serialize, Deserialize, Merge)]
|
||||
#[serde(default, rename_all = "kebab-case")]
|
||||
pub struct AllRepositoryOptions {
|
||||
/// Backend options
|
||||
#[clap(flatten)]
|
||||
#[serde(flatten)]
|
||||
pub be: BackendOptions,
|
||||
|
||||
/// Repository options
|
||||
#[clap(flatten)]
|
||||
#[serde(flatten)]
|
||||
pub repo: RepositoryOptions,
|
||||
|
||||
/// Hooks
|
||||
#[clap(skip)]
|
||||
pub hooks: Hooks,
|
||||
}
|
||||
|
||||
pub type CliRepo = RusticRepo<ProgressOptions>;
|
||||
pub type CliOpenRepo = Repository<ProgressOptions, OpenStatus>;
|
||||
pub type RusticIndexedRepo<P> = Repository<P, IndexedStatus<FullIndex, OpenStatus>>;
|
||||
pub type CliIndexedRepo = RusticIndexedRepo<ProgressOptions>;
|
||||
|
||||
impl AllRepositoryOptions {
|
||||
fn repository<P>(&self, po: P) -> Result<RusticRepo<P>> {
|
||||
let backends = self.be.to_backends()?;
|
||||
let repo = Repository::new_with_progress(&self.repo, &backends, po)?;
|
||||
Ok(RusticRepo(repo))
|
||||
}
|
||||
|
||||
pub fn run<T>(&self, f: impl FnOnce(CliRepo) -> Result<T>) -> Result<T> {
|
||||
let hooks = self.hooks.with_context("repository");
|
||||
let po = RUSTIC_APP.config().global.progress_options;
|
||||
hooks.use_with(|| f(self.repository(po)?))
|
||||
}
|
||||
|
||||
pub fn run_open<T>(&self, f: impl FnOnce(CliOpenRepo) -> Result<T>) -> Result<T> {
|
||||
let hooks = self.hooks.with_context("repository");
|
||||
let po = RUSTIC_APP.config().global.progress_options;
|
||||
hooks.use_with(|| f(self.repository(po)?.open()?))
|
||||
}
|
||||
|
||||
pub fn run_open_or_init_with<T: Clone>(
|
||||
&self,
|
||||
do_init: bool,
|
||||
init: impl FnOnce(CliRepo) -> Result<CliOpenRepo>,
|
||||
f: impl FnOnce(CliOpenRepo) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let hooks = self.hooks.with_context("repository");
|
||||
let po = RUSTIC_APP.config().global.progress_options;
|
||||
hooks.use_with(|| {
|
||||
f(self
|
||||
.repository(po)?
|
||||
.open_or_init_repository_with(do_init, init)?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_indexed_with_progress<P: Clone + ProgressBars, T>(
|
||||
&self,
|
||||
po: P,
|
||||
f: impl FnOnce(RusticIndexedRepo<P>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let hooks = self.hooks.with_context("repository");
|
||||
hooks.use_with(|| f(self.repository(po)?.indexed()?))
|
||||
}
|
||||
|
||||
pub fn run_indexed<T>(&self, f: impl FnOnce(CliIndexedRepo) -> Result<T>) -> Result<T> {
|
||||
let po = RUSTIC_APP.config().global.progress_options;
|
||||
self.run_indexed_with_progress(po, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RusticRepo<P>(pub Repository<P, ()>);
|
||||
|
||||
impl<P> Deref for RusticRepo<P> {
|
||||
type Target = Repository<P, ()>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Clone + ProgressBars> RusticRepo<P> {
|
||||
pub fn open(self) -> Result<Repository<P, OpenStatus>> {
|
||||
match self.0.password()? {
|
||||
// if password is given, directly return the result of find_key_in_backend and don't retry
|
||||
Some(pass) => {
|
||||
return Ok(self.0.open_with_password(&pass)?);
|
||||
}
|
||||
None => {
|
||||
for _ in 0..constants::MAX_PASSWORD_RETRIES {
|
||||
let pass = Password::new()
|
||||
.with_prompt("enter repository password")
|
||||
.allow_empty_password(true)
|
||||
.interact()?;
|
||||
match self.0.clone().open_with_password(&pass) {
|
||||
Ok(repo) => return Ok(repo),
|
||||
Err(err) if err.is_incorrect_password() => continue,
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("incorrect password"))
|
||||
}
|
||||
|
||||
fn open_or_init_repository_with(
|
||||
self,
|
||||
do_init: bool,
|
||||
init: impl FnOnce(Self) -> Result<Repository<P, OpenStatus>>,
|
||||
) -> Result<Repository<P, OpenStatus>> {
|
||||
let dry_run = RUSTIC_APP.config().global.check_index;
|
||||
// Initialize repository if --init is set and it is not yet initialized
|
||||
let repo = if do_init && self.0.config_id()?.is_none() {
|
||||
if dry_run {
|
||||
bail!(
|
||||
"cannot initialize repository {} in dry-run mode!",
|
||||
self.0.name
|
||||
);
|
||||
}
|
||||
init(self)?
|
||||
} else {
|
||||
self.open()?
|
||||
};
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
fn indexed(self) -> Result<Repository<P, IndexedStatus<FullIndex, OpenStatus>>> {
|
||||
let open = self.open()?;
|
||||
let check_index = RUSTIC_APP.config().global.check_index;
|
||||
let repo = if check_index {
|
||||
open.to_indexed_checked()
|
||||
} else {
|
||||
open.to_indexed()
|
||||
}?;
|
||||
Ok(repo)
|
||||
}
|
||||
}
|
||||
@ -7,134 +7,94 @@
|
||||
//! You can run them with 'nextest':
|
||||
//! `cargo nextest run -E 'test(backup)'`.
|
||||
|
||||
use abscissa_core::testing::prelude::*;
|
||||
|
||||
use aho_corasick::PatternID;
|
||||
use dircmp::Comparison;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rustic_testing::{get_matches, TestResult};
|
||||
use std::io::Read;
|
||||
use tempfile::{tempdir, TempDir};
|
||||
|
||||
pub fn rustic_runner(temp_dir: &TempDir) -> CmdRunner {
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::{predicate, PredicateBooleanExt};
|
||||
|
||||
use rustic_testing::TestResult;
|
||||
|
||||
pub fn rustic_runner(temp_dir: &TempDir) -> TestResult<Command> {
|
||||
let password = "test";
|
||||
let repo_dir = temp_dir.path().join("repo");
|
||||
let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic"));
|
||||
|
||||
let mut runner = Command::new(env!("CARGO_BIN_EXE_rustic"));
|
||||
|
||||
runner
|
||||
.arg("-r")
|
||||
.arg(repo_dir)
|
||||
.arg("--password")
|
||||
.arg(password)
|
||||
.arg("--no-progress")
|
||||
.capture_stdout()
|
||||
.capture_stderr();
|
||||
runner
|
||||
.arg("--no-progress");
|
||||
|
||||
Ok(runner)
|
||||
}
|
||||
|
||||
fn setup() -> TestResult<TempDir> {
|
||||
let temp_dir = tempdir()?;
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.args(["init"]).run();
|
||||
rustic_runner(&temp_dir)?
|
||||
.args(["init"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("successfully created."))
|
||||
.stderr(predicate::str::contains("successfully added."));
|
||||
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
cmd.stdout().read_to_string(&mut stdout)?;
|
||||
cmd.stderr().read_to_string(&mut stderr)?;
|
||||
|
||||
let patterns = &["successfully added.", "successfully created."];
|
||||
let matches = get_matches(patterns, stderr)?;
|
||||
|
||||
assert_eq!(
|
||||
matches,
|
||||
vec![(PatternID::must(0), 19), (PatternID::must(1), 21),]
|
||||
);
|
||||
|
||||
cmd.wait()?.expect_success();
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backup_and_check_passes() -> TestResult<()> {
|
||||
let temp_dir = setup()?;
|
||||
let backup = "crates/";
|
||||
let backup = "src/";
|
||||
|
||||
{
|
||||
// Run `backup` for the first time
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("backup").arg(backup).run();
|
||||
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["successfully saved."];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches, vec![(PatternID::must(0), 19)]);
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("backup")
|
||||
.arg(backup)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("successfully saved."));
|
||||
}
|
||||
|
||||
{
|
||||
// Run `snapshots`
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("snapshots").run();
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["total: 1 snapshot(s)"];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches, vec![(PatternID::must(0), 20)]);
|
||||
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("snapshots")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("total: 1 snapshot(s)"));
|
||||
}
|
||||
|
||||
{
|
||||
// Run `backup` a second time
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("backup").arg(backup).run();
|
||||
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["Added to the repo: 0 B", "successfully saved."];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(
|
||||
matches,
|
||||
vec![(PatternID::must(0), 22), (PatternID::must(1), 19)]
|
||||
);
|
||||
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("backup")
|
||||
.arg(backup)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Added to the repo: 0 B"))
|
||||
.stdout(predicate::str::contains("successfully saved."));
|
||||
}
|
||||
|
||||
{
|
||||
// Run `snapshots` a second time
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("snapshots").run();
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["total: 2 snapshot(s)"];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches, vec![(PatternID::must(0), 20)]);
|
||||
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("snapshots")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("total: 2 snapshot(s)"));
|
||||
}
|
||||
|
||||
{
|
||||
// Run `check --read-data`
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.args(["check", "--read-data"]).run();
|
||||
let mut output = String::new();
|
||||
cmd.stderr().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["WARN", "ERROR"];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches.len(), 0);
|
||||
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.args(["check", "--read-data"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains("WARN").not())
|
||||
.stderr(predicate::str::contains("ERROR").not());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -144,44 +104,34 @@ fn test_backup_and_check_passes() -> TestResult<()> {
|
||||
fn test_backup_and_restore_passes() -> TestResult<()> {
|
||||
let temp_dir = setup()?;
|
||||
let restore_dir = temp_dir.path().join("restore");
|
||||
let backup = "crates";
|
||||
let backup = "src/";
|
||||
|
||||
// actual repository root to backup
|
||||
let current_dir = std::env::current_dir()?;
|
||||
let backup_files = current_dir.join(backup);
|
||||
let backup_files = std::env::current_dir()?.join(backup);
|
||||
|
||||
{
|
||||
// Run `backup` for the first time
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("backup").arg(&backup_files).run();
|
||||
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["successfully saved."];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches, vec![(PatternID::must(0), 19)]);
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("backup")
|
||||
.arg(&backup_files)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("successfully saved."));
|
||||
}
|
||||
{
|
||||
// Run `restore`
|
||||
let mut runner = rustic_runner(&temp_dir);
|
||||
let mut cmd = runner.arg("restore").arg("latest").arg(&restore_dir).run();
|
||||
|
||||
let mut output = String::new();
|
||||
cmd.stdout().read_to_string(&mut output)?;
|
||||
|
||||
let patterns = &["restore done"];
|
||||
let matches = get_matches(patterns, output)?;
|
||||
|
||||
assert_eq!(matches, vec![(PatternID::must(0), 12)]);
|
||||
cmd.wait()?.expect_success();
|
||||
rustic_runner(&temp_dir)?
|
||||
.arg("restore")
|
||||
.arg("latest")
|
||||
.arg(&restore_dir)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("restore done"));
|
||||
}
|
||||
|
||||
let comparison = Comparison::default();
|
||||
let compare_result = comparison.compare(&backup_files, &restore_dir.join(&backup_files))?;
|
||||
dbg!(&compare_result);
|
||||
// Compare the backup and the restored directory
|
||||
let compare_result =
|
||||
Comparison::default().compare(&backup_files, &restore_dir.join(&backup_files))?;
|
||||
|
||||
// no differences
|
||||
assert!(compare_result.is_empty());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -13,10 +13,9 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
path::PathBuf,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use abscissa_core::testing::prelude::*;
|
||||
|
||||
use rustic_testing::{files_differ, get_temp_file, TestResult};
|
||||
@ -25,7 +24,7 @@ use rustic_testing::{files_differ, get_temp_file, TestResult};
|
||||
/// the runner acquire a mutex when executing commands and inspecting
|
||||
/// exit statuses, serializing what would otherwise be multithreaded
|
||||
/// invocations as `cargo test` executes tests in parallel by default.
|
||||
pub static LAZY_RUNNER: Lazy<CmdRunner> = Lazy::new(|| {
|
||||
pub static LAZY_RUNNER: LazyLock<CmdRunner> = LazyLock::new(|| {
|
||||
let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic"));
|
||||
runner.exclusive().capture_stdout();
|
||||
runner
|
||||
|
||||
@ -1,31 +1,17 @@
|
||||
//! Configuration file tests
|
||||
|
||||
use log::LevelFilter;
|
||||
use anyhow::Result;
|
||||
use rstest::*;
|
||||
use rustic_rs::RusticConfig;
|
||||
use std::{error::Error, fs, path::PathBuf, str::FromStr};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
fn get_config_file_path() -> PathBuf {
|
||||
["config", "full.toml"].iter().collect()
|
||||
}
|
||||
/// Ensure `full.toml` parses as a valid config file
|
||||
#[test]
|
||||
fn parse_full_toml_example() -> Result<()> {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["locate-project", "--workspace", "--message-format", "plain"])
|
||||
.output()?;
|
||||
let root = PathBuf::from_str(String::from_utf8(output.stdout)?.as_str())?;
|
||||
let root_dir = root.parent().unwrap();
|
||||
let config_path = root_dir.join(get_config_file_path());
|
||||
/// Ensure all `configs` parse as a valid config files
|
||||
#[rstest]
|
||||
fn test_parse_rustic_configs_is_ok(
|
||||
#[files("config/**/*.toml")] config_path: PathBuf,
|
||||
) -> Result<()> {
|
||||
let toml_string = fs::read_to_string(config_path)?;
|
||||
let config: RusticConfig = toml::from_str(&toml_string)?;
|
||||
|
||||
assert_eq!(
|
||||
LevelFilter::from_str(config.global.log_level.unwrap().as_str())?,
|
||||
LevelFilter::Info
|
||||
);
|
||||
assert!(!config.global.dry_run);
|
||||
let _ = toml::from_str::<RusticConfig>(&toml_string)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user