diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b837d2d..2046398 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,9 @@ on: push: branches: - '*' + pull_request: + branches: + - '*' workflow_dispatch: permissions: @@ -53,22 +56,20 @@ jobs: with: targets: ${{ matrix.target }} - - run: sudo apt -y install musl-dev musl-tools + - name: Install Linux Dependencies + run: sudo apt -y install musl-dev musl-tools if: matrix.build == 'linux' - - run: choco install openssl - if: matrix.build == 'windows' - - - run: echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' | Out-File -FilePath - $env:GITHUB_ENV -Append + - name: Install Windows Dependencies + run: | + choco install openssl if: matrix.build == 'windows' - - name: Build Linux + - name: Set OpenSSL Directory on Windows run: | - cargo build --release --locked --target ${{ matrix.target }} --features "openssl/vendored" - if: matrix.build == 'linux' + echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' | Out-File -FilePath $env:GITHUB_ENV -Append + if: matrix.build == 'windows' - name: Build - run: | - cargo build --release --locked --target ${{ matrix.target }} - if: matrix.build != 'linux' + run: |- + cargo build --release --locked --target ${{ matrix.target }} ${{ matrix.build == 'linux' && '--features "openssl/vendored"' || '' }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1d291a7..fb58cf3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,12 +12,15 @@ jobs: name: Coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - - name: Run tests + - name: Run tests with coverage flags run: cargo test --verbose -- --nocapture env: RUST_BACKTRACE: full @@ -27,19 +30,28 @@ jobs: RUSTDOCFLAGS: -Cinstrument-coverage -Ccodegen-units=1 -Clink-dead-code -Coverflow-checks=off - name: Install grcov - run: if [[ ! -e ~/.cargo/bin/grcov ]]; then cargo install grcov; fi + run: | + if [[ ! -x "$(command -v grcov)" ]]; then + cargo install grcov + fi - - name: Run grcov - run: grcov . --binary-path target/debug/ -s . -t lcov --branch --ignore-not-existing - --ignore '../**' --ignore '/*' -o coverage.lcov + - name: Generate coverage report + run: | + grcov . --binary-path target/debug/ \ + -s . -t lcov --branch \ + --ignore-not-existing \ + --ignore '../**' --ignore '/*' \ + -o coverage.lcov - - name: Upload to codecov.io + - name: Upload to Codecov uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: files: coverage.lcov flags: rust + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: Coveralls GitHub Action + - name: Upload to Coveralls uses: coverallsapp/github-action@v2 + with: + path-to-lcov: coverage.lcov diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 61a72a8..42c19c5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,64 +17,64 @@ jobs: branch: main build: - name: Build and release + name: Build and Release runs-on: ${{ matrix.os }} needs: test strategy: matrix: include: - - build: linux - os: ubuntu-latest + - os: ubuntu-latest target: x86_64-unknown-linux-musl - - - build: macos - os: macos-latest + - os: macos-latest target: x86_64-apple-darwin - - - build: windows - os: windows-latest + - os: windows-latest target: x86_64-pc-windows-msvc steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v4 - - name: Get the release version from the tag + - name: Extract release version from tag + if: startsWith(github.ref, 'refs/tags/') run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - name: Install Rust + - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - run: sudo apt -y install musl-dev musl-tools - if: matrix.build == 'linux' + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' + run: sudo apt -y install musl-dev musl-tools - - run: choco install openssl - if: matrix.build == 'windows' + - name: Install Windows dependencies + if: matrix.os == 'windows-latest' + run: choco install openssl - - run: echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' | Out-File -FilePath - $env:GITHUB_ENV -Append - if: matrix.build == 'windows' + - name: Set OpenSSL directory on Windows + if: matrix.os == 'windows-latest' + run: echo 'OPENSSL_DIR=C:\Program Files\OpenSSL-Win64' >> $GITHUB_ENV - - name: Build + - name: Build project run: cargo build --release --locked --target ${{ matrix.target }} - - name: Build archive + - name: Archive build output shell: bash run: | binary_name="backup" - dirname="$binary_name-${{ env.VERSION }}-${{ matrix.target }}" mkdir "$dirname" - if [ "${{ matrix.os }}" = "windows-latest" ]; then - mv "target/${{ matrix.target }}/release/$binary_name.exe" "$dirname" + + # Move binary to the directory + if [ "${{ matrix.os }}" == "windows-latest" ]; then + mv "target/${{ matrix.target }}/release/$binary_name.exe" "$dirname/" else - mv "target/${{ matrix.target }}/release/$binary_name" "$dirname" + mv "target/${{ matrix.target }}/release/$binary_name" "$dirname/" fi - if [ "${{ matrix.os }}" = "windows-latest" ]; then + # Compress the directory + if [ "${{ matrix.os }}" == "windows-latest" ]; then 7z a "$dirname.zip" "$dirname" echo "ASSET=$dirname.zip" >> $GITHUB_ENV else @@ -82,32 +82,30 @@ jobs: echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV fi - - name: Release + - name: Release to GitHub if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v1 with: - files: |- - ${{ env.ASSET }} + files: ${{ env.ASSET }} publish: - name: Publish + name: Publish to Crates.io runs-on: ubuntu-latest - needs: - - build + needs: build steps: - - name: Checkout sources + - name: Checkout code uses: actions/checkout@v4 - - name: Install Rust + - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable - - run: cargo publish --token ${CRATES_TOKEN} + - name: Publish to Crates.io env: CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} + run: cargo publish --token $CRATES_TOKEN package: - name: PackageCloud - needs: - - build + name: Publish to PackageCloud + needs: build uses: ./.github/workflows/packagecloud.yml secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6810a0..13aca39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,61 +3,70 @@ name: Test on: workflow_call: - pull_request: - branches: - - '*' jobs: format: name: Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - - name: Format + - name: Run formatter run: cargo fmt --all -- --check lint: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable - - name: Clippy + - name: Run Clippy linter run: cargo clippy -- -D clippy::all -D clippy::nursery -D warnings check: name: Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 - - name: Check + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run cargo check run: cargo check test: name: Test + runs-on: ${{ matrix.os }} strategy: matrix: os: - ubuntu-latest - macOS-latest - windows-latest - rust: - - stable - runs-on: ${{ matrix.os }} + needs: - format - lint - check + steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable - - name: test - run: cargo test + - name: Run tests + run: cargo test -- --nocapture diff --git a/.justfile b/.justfile index 259f826..57de500 100644 --- a/.justfile +++ b/.justfile @@ -7,5 +7,5 @@ clippy: coverage: CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROFILE_FILE='coverage-%p-%m.profraw' cargo test grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/html - firefox target/coverage/html/index.html rm -rf *.profraw + firefox target/coverage/html/index.html& diff --git a/Cargo.lock b/Cargo.lock index 3d8c417..53f5f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,21 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -81,16 +96,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + [[package]] name = "backup" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", + "chrono", "clap", "config", "dirs", "openssl", "rusqlite", + "tempfile", + "walkdir", ] [[package]] @@ -99,6 +123,12 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + [[package]] name = "cc" version = "1.2.1" @@ -114,6 +144,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + [[package]] name = "clap" version = "4.5.21" @@ -159,6 +203,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "dirs" version = "5.0.1" @@ -189,6 +239,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -201,6 +261,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" + [[package]] name = "foreign-types" version = "0.3.2" @@ -255,17 +321,49 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "libc" -version = "0.2.162" +version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" [[package]] name = "libredox" @@ -288,6 +386,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + [[package]] name = "memchr" version = "2.7.4" @@ -310,6 +420,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -344,9 +463,9 @@ dependencies = [ [[package]] name = "openssl-src" -version = "300.4.0+3.4.0" +version = "300.4.1+3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a709e02f2b4aca747929cca5ed248880847c650233cf8b8cdc48f40aaf4898a6" +checksum = "faa4eac4138c62414b5622d1b31c5c304f34b406b013c079c2bbc652fdd6678c" dependencies = [ "cc", ] @@ -425,6 +544,28 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "0.38.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e4ea3e1cdc4b559b8e5650f9c8e5998e3e5c1343b4eaf034565f32318d63c0" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.215" @@ -474,6 +615,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -518,12 +672,95 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -533,6 +770,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index db2222a..dae0b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "backup" -version = "0.0.3" +version = "0.0.4" authors = ["Nicolas Embriz "] description = "create encrypted backups" documentation = "https://github.com/nbari/backup" @@ -14,8 +14,11 @@ edition = "2021" [dependencies] anyhow = "1" +chrono = "0.4" clap = { version = "4", features = ["string", "env"] } config = { version = "0.14", default-features = false, features = ["yaml"] } dirs = "5" openssl = { version = "0.10", optional = true, features = ["vendored"] } rusqlite = { version = "0.32", features = ["bundled"] } +tempfile = "3.14" +walkdir = "2.5" diff --git a/README.md b/README.md index 4e31557..e77b5a9 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,11 @@ Command line tool for creating encrypted backups avoiding duplicates. [![crates.io](https://img.shields.io/crates/v/backup.svg)](https://crates.io/crates/backup) [![Build Status](https://travis-ci.org/nbari/backup.svg?branch=master)](https://travis-ci.org/nbari/backup) + +## Usage + +Create a new backup of /home/user1 and /home/user2 + +```bash +backup new mybackup -d /home/user1 -d /home/user2 +``` diff --git a/src/bin/backup.rs b/src/bin/backup.rs index 551d92c..6a74293 100644 --- a/src/bin/backup.rs +++ b/src/bin/backup.rs @@ -4,11 +4,13 @@ use backup::cli::{actions, actions::Action, start}; // Main function fn main() -> Result<()> { // Start the program - let action = start()?; + let (action, globals) = start()?; // Handle the action match action { Action::New { .. } => actions::new::handle(action)?, + Action::Show => actions::show::handle(action, globals)?, + Action::Run { .. } => actions::run::handle(action)?, } Ok(()) diff --git a/src/cli/actions/mod.rs b/src/cli/actions/mod.rs index 24a288d..ebc65ce 100644 --- a/src/cli/actions/mod.rs +++ b/src/cli/actions/mod.rs @@ -1,4 +1,6 @@ pub mod new; +pub mod run; +pub mod show; use std::path::PathBuf; @@ -11,4 +13,8 @@ pub enum Action { exclude: Option>, config: PathBuf, }, + Show, + Run { + name: String, + }, } diff --git a/src/cli/actions/new.rs b/src/cli/actions/new.rs index 1a7470d..3016b3f 100644 --- a/src/cli/actions/new.rs +++ b/src/cli/actions/new.rs @@ -1,39 +1,35 @@ use crate::cli::actions::Action; use anyhow::Result; -use rusqlite::Connection; -use std::fs; -use std::path::PathBuf; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; /// Handle the create action pub fn handle(action: Action) -> Result<()> { - match action { - Action::New { - name, - config, - directory, - file, - exclude, - } => { - let db_path = config.join(format!("{}.db", name)); - - create_db_tables(db_path)?; - - if let Some(directory) = directory { - for dir in directory { - println!("Directory: {}", fs::canonicalize(dir)?.display()); - } - } + if let Action::New { + name, + config, + directory, + file, + exclude, + } = action + { + let db_path = config.join(format!("{}.db", name)); - if let Some(file) = file { - for file in file { - println!("File: {}", fs::canonicalize(file)?.display()); - } - } + // Create the backup database tables + create_db_tables(&db_path)?; + + let backup_dirs = get_unique_dir_parents(directory.unwrap_or_default()); - if let Some(exclude) = exclude { - for exclude in exclude { - println!("Exclude: {}", exclude); - } + // create the config_directories table + create_db_config_direcories_table(&db_path, backup_dirs)?; + + // create the config_files tables + // exclude files if they are within the directories that are being backed up + create_db_config_files_table(&db_path, file.unwrap_or_default())?; + + if let Some(exclude) = exclude { + for exclude in exclude { + println!("Exclude: {}", exclude); } } } @@ -41,31 +37,222 @@ pub fn handle(action: Action) -> Result<()> { Ok(()) } -fn create_db_tables(db_path: PathBuf) -> Result<()> { +fn create_db_tables(db_path: &PathBuf) -> Result<()> { + let conn = Connection::open(db_path)?; + + // table to store unique file content, using content hash to avoid duplicates + conn.execute( + "CREATE TABLE IF NOT EXISTS Files ( + file_id INTEGER PRIMARY KEY, + hash TEXT NOT NULL UNIQUE +)", + [], + )?; + + // table to store directory paths + conn.execute( + "CREATE TABLE IF NOT EXISTS Paths( + path_id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE +)", + [], + )?; + + // table to store files with version tracking + conn.execute( + "CREATE TABLE IF NOT EXISTS FileNames ( + name_id INTEGER PRIMARY KEY, + path_id INTEGER NOT NULL, -- Foreign key referencing Paths table + name TEXT NOT NULL, -- Name of the file in the Path + file_id INTEGER NOT NULL, -- Foreign key referencing Files for content hash + first_version INTEGER NOT NULL, -- The version in which this file path first appeared + last_version INTEGER, -- The last version this file path was valid (NULL if still valid) + is_deleted BOOLEAN DEFAULT 0, -- 1 if the file was deleted in this version, 0 otherwise + + FOREIGN KEY (path_id) REFERENCES Paths(path_id), + FOREIGN KEY (file_id) REFERENCES Files(file_id), + + UNIQUE(path_id, name, first_version) -- Ensure unique entries by path and version +)", + [], + )?; + + // Table to track each backup version + conn.execute( + "CREATE TABLE IF NOT EXISTS BackupVersions ( + version_id INTEGER PRIMARY KEY, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -- Timestamp when the backup was created +)", + [], + )?; + + // Index for efficient file retrieval by version + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_files_version ON FileNames (first_version, last_version, is_deleted)", + [], + )?; + + Ok(()) +} + +// extract the parent directory of each path and return only the unique parent directories +fn get_unique_dir_parents(mut dirs: Vec) -> Vec { + // Sort the input directories lexicographically (shorter paths come first for easier comparison) + dirs.sort(); + + // Filter out subdirectories or descendants + let mut result = Vec::new(); + for dir in dirs { + // Only add the directory if it is not a descendant of any directory already in the result + if !result.iter().any(|parent| dir.starts_with(parent)) { + result.push(dir); + } + } + + result +} + +fn create_db_config_direcories_table(db_path: &PathBuf, dirs: Vec) -> Result<()> { let conn = Connection::open(db_path)?; - // create the tables + // Table to track each backup version conn.execute( - "CREATE TABLE IF NOT EXISTS Directory ( + "CREATE TABLE IF NOT EXISTS config_directories ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - parent_id INTEGER, - FOREIGN KEY (parent_id) REFERENCES Directory (id) + path TEXT NOT NULL UNIQUE )", [], )?; + // Prepare the insert statement + let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_directories (path) VALUES (?1)")?; + + // Insert each directory into the database + for dir in dirs { + stmt.execute(params![dir.to_string_lossy().to_string()])?; + } + + Ok(()) +} + +fn create_db_config_files_table(db_path: &PathBuf, files: Vec) -> Result<()> { + let conn = Connection::open(db_path)?; + + // Table to track each backup version conn.execute( - "CREATE TABLE IF NOT EXISTS File ( + "CREATE TABLE IF NOT EXISTS config_files ( id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - size INTEGER NOT NULL, - directory_id INTEGER, - hash TEXT NOT NULL, - FOREIGN KEY (directory_id) REFERENCES Directory (id) + path TEXT NOT NULL UNIQUE )", [], )?; + // Prepare the insert statement + let mut stmt = conn.prepare("INSERT OR IGNORE INTO config_files (path) VALUES (?1)")?; + + // Get all directory paths from config_directories table + let mut dirs_stmt = conn.prepare("SELECT path FROM config_directories")?; + let dirs_iter = dirs_stmt.query_map([], |row| row.get::<_, String>(0))?; + + // Collect all directory paths + let dirs: Vec = dirs_iter.filter_map(|result| result.ok()).collect(); + + // Insert files only if they are not children of any of the directories + for file in files { + let file_path = file.to_string_lossy().to_string(); + + // Check if file is a child of any of the directories + let is_child = dirs.iter().any(|dir| { + let dir_path = Path::new(dir); + file.starts_with(dir_path) + }); + + // Only insert file if it is not a child of any directory + if !is_child { + stmt.execute(params![file_path])?; + } + } + Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_unique_dir_parents() { + let dirs = vec![ + PathBuf::from("/a/b/c"), + PathBuf::from("/a/b/d"), + PathBuf::from("/a/b/c/d"), + PathBuf::from("/a/b"), + PathBuf::from("/b"), + PathBuf::from("/b/c"), + PathBuf::from("/b/cc"), + PathBuf::from("/b/d"), + ]; + + let result = get_unique_dir_parents(dirs); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], PathBuf::from("/a/b")); + assert_eq!(result[1], PathBuf::from("/b")); + } + + // test the create_config_directories_table function + #[test] + fn test_create_db_config_directoris_and_files_table() { + let temp_dir = tempfile::tempdir().unwrap(); + + let db_path = temp_dir.path().join("test.db"); + + let dirs = vec![ + PathBuf::from("/a/b/c"), + PathBuf::from("/a/b/d"), + PathBuf::from("/a/b/c/d"), + PathBuf::from("/a/b"), + PathBuf::from("/b"), + PathBuf::from("/b/c"), + PathBuf::from("/b/cc"), + PathBuf::from("/b/d"), + ]; + + create_db_tables(&db_path).unwrap(); + + let backup_dirs = get_unique_dir_parents(dirs); + + create_db_config_direcories_table(&db_path, backup_dirs).unwrap(); + + let conn = Connection::open(&db_path).unwrap(); + + let mut stmt = conn.prepare("SELECT path FROM config_directories").unwrap(); + + let dirs_iter = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + + let result: Vec = dirs_iter.filter_map(|result| result.ok()).collect(); + + assert_eq!(result.len(), 2); + assert!(result.contains(&"/a/b".to_string())); + assert!(result.contains(&"/b".to_string())); + + let files = vec![ + PathBuf::from("/a/b/c/file1.txt"), + PathBuf::from("/a/b/c/d/file2.txt"), + PathBuf::from("/a/file3.txt"), + PathBuf::from("/z/file4.txt"), + ]; + + create_db_config_files_table(&db_path, files).unwrap(); + + let mut stmt = conn.prepare("SELECT path FROM config_files").unwrap(); + + let files_iter = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + + let result: Vec = files_iter.filter_map(|result| result.ok()).collect(); + + assert_eq!(result.len(), 2); + assert!(result.contains(&"/a/file3.txt".to_string())); + assert!(result.contains(&"/z/file4.txt".to_string())); + } +} diff --git a/src/cli/actions/run.rs b/src/cli/actions/run.rs new file mode 100644 index 0000000..483135e --- /dev/null +++ b/src/cli/actions/run.rs @@ -0,0 +1,11 @@ +use crate::cli::actions::Action; +use anyhow::Result; + +/// Handle the create action +pub fn handle(action: Action) -> Result<()> { + if let Action::Run { name } = action { + println!("Running {}", name); + } + + Ok(()) +} diff --git a/src/cli/actions/show.rs b/src/cli/actions/show.rs new file mode 100644 index 0000000..876254f --- /dev/null +++ b/src/cli/actions/show.rs @@ -0,0 +1,76 @@ +use crate::cli::{actions::Action, globals::GlobalArgs}; +use anyhow::{anyhow, Result}; +use std::{fs, path::PathBuf}; + +/// Handle the create action +pub fn handle(action: Action, globals: GlobalArgs) -> Result<()> { + if matches!(action, Action::Show) { + let home_dir = globals.home; + + list_db_files(home_dir)?; + } + + Ok(()) +} + +fn list_db_files(dir: PathBuf) -> Result<()> { + if !dir.is_dir() { + return Err(anyhow!("Directory does not exist")); + }; + + let mut found = false; + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(extension) = path.extension() { + if extension == "db" { + if let Some(file_name) = path.file_stem() { + // `file_stem` gives the file name without the extension + println!("{}", file_name.to_string_lossy()); + found = true; + } + } + } + } + } + + if !found { + println!("No configurations found."); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use tempfile::tempdir; + + #[test] + fn test_list_db_files() { + let dir = tempdir().unwrap(); + let file = dir.path().join("test.db"); + File::create(&file).unwrap(); + + let result = list_db_files(dir.path().to_path_buf()); + assert!(result.is_ok()); + } + + #[test] + fn test_list_db_files_no_dir() { + let dir = PathBuf::from("/tmp-non-existent"); + let result = list_db_files(dir); + assert!(result.is_err()); + } + + #[test] + fn test_list_db_files_no_files() { + let dir = tempdir().unwrap(); + let result = list_db_files(dir.path().to_path_buf()); + assert!(result.is_ok()); + } +} diff --git a/src/cli/commands/edit.rs b/src/cli/commands/cmd_edit.rs similarity index 100% rename from src/cli/commands/edit.rs rename to src/cli/commands/cmd_edit.rs diff --git a/src/cli/commands/new.rs b/src/cli/commands/cmd_new.rs similarity index 64% rename from src/cli/commands/new.rs rename to src/cli/commands/cmd_new.rs index 48f9bba..9625d45 100644 --- a/src/cli/commands/new.rs +++ b/src/cli/commands/cmd_new.rs @@ -4,11 +4,15 @@ use std::{fs, path::PathBuf}; // alpahnumeric validator pub fn validator_is_alphanumeric() -> ValueParser { ValueParser::from(move |s: &str| -> std::result::Result { - if s.chars().all(|c| c.is_ascii_alphanumeric()) { + if s == "_" { + return Err("The name cannot be just an underscore".to_string()); + } + + if s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return Ok(s.to_string()); } - Err("Only [a-Z0-9] alphanumeric characters are allowed".to_string()) + Err("Only alphanumeric characters and underscore are allowed".to_string()) }) } @@ -71,3 +75,37 @@ pub fn command() -> Command { .help("Exclude a file or directory from the backup"), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validator_is_alphanumeric() { + let test_cases = vec![ + ("backup", "test", true), + ("backup", "test123", true), + ("backup", "12345", true), + ("backup", "test!", false), + ("backup", "test 123", false), + ("backup", "test@test", false), + ("backup", "n~", false), + ("backup", "n", true), + ("backup", "_", false), + ("backup", "_A", true), + ("backup", "Z_", true), + ]; + + for (c, name, should_succeed) in test_cases { + let cmd = command(); + + let m = cmd.try_get_matches_from(vec![c, name]); + + if should_succeed { + assert!(m.is_ok()) + } else { + assert!(m.is_err()); + } + } + } +} diff --git a/src/cli/commands/cmd_run.rs b/src/cli/commands/cmd_run.rs new file mode 100644 index 0000000..7ce9f55 --- /dev/null +++ b/src/cli/commands/cmd_run.rs @@ -0,0 +1,9 @@ +use clap::{Arg, Command}; + +pub fn command() -> Command { + Command::new("run").about("Run backup").arg( + Arg::new("name") + .help("Name of the backup. Use \"show\" to see current configurations") + .required(true), + ) +} diff --git a/src/cli/commands/cmd_show.rs b/src/cli/commands/cmd_show.rs new file mode 100644 index 0000000..09407b3 --- /dev/null +++ b/src/cli/commands/cmd_show.rs @@ -0,0 +1,5 @@ +use clap::Command; + +pub fn command() -> Command { + Command::new("show").about("Show available backup configurations") +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 76689ea..d38da23 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,5 +1,7 @@ -pub mod edit; -pub mod new; +pub mod cmd_edit; +pub mod cmd_new; +pub mod cmd_run; +pub mod cmd_show; use clap::{ builder::styling::{AnsiColor, Effects, Styles}, @@ -28,8 +30,10 @@ pub fn new(config_path: PathBuf) -> Command { .default_value(config_path.into_os_string()) .global(true), ) - .subcommand(new::command()) - .subcommand(edit::command()) + .subcommand(cmd_edit::command()) + .subcommand(cmd_new::command()) + .subcommand(cmd_run::command()) + .subcommand(cmd_show::command()) } #[cfg(test)] diff --git a/src/cli/dispatch/new.rs b/src/cli/dispatch/cmd_new.rs similarity index 100% rename from src/cli/dispatch/new.rs rename to src/cli/dispatch/cmd_new.rs diff --git a/src/cli/dispatch/cmd_run.rs b/src/cli/dispatch/cmd_run.rs new file mode 100644 index 0000000..60f0507 --- /dev/null +++ b/src/cli/dispatch/cmd_run.rs @@ -0,0 +1,12 @@ +use crate::cli::actions::Action; +use anyhow::Result; +use clap::ArgMatches; + +pub fn dispatch(matches: &ArgMatches) -> Result { + Ok(Action::Run { + name: matches + .get_one("name") + .map(|s: &String| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("Name required"))?, + }) +} diff --git a/src/cli/dispatch/cmd_show.rs b/src/cli/dispatch/cmd_show.rs new file mode 100644 index 0000000..e306f7b --- /dev/null +++ b/src/cli/dispatch/cmd_show.rs @@ -0,0 +1,6 @@ +use crate::cli::actions::Action; +use anyhow::Result; + +pub const fn dispatch() -> Result { + Ok(Action::Show {}) +} diff --git a/src/cli/dispatch/mod.rs b/src/cli/dispatch/mod.rs index 192a442..21fa2ca 100644 --- a/src/cli/dispatch/mod.rs +++ b/src/cli/dispatch/mod.rs @@ -1,4 +1,6 @@ -pub mod new; +pub mod cmd_new; +pub mod cmd_run; +pub mod cmd_show; use crate::cli::actions::Action; use anyhow::{Context, Result}; @@ -12,7 +14,9 @@ pub fn handler(matches: &clap::ArgMatches) -> Result { }; match matches.subcommand_name() { - Some("new") => new::dispatch(sub_m("new")?), + Some("new") => cmd_new::dispatch(sub_m("new")?), + Some("show") => cmd_show::dispatch(), + Some("run") => cmd_run::dispatch(sub_m("run")?), _ => todo!(), } diff --git a/src/cli/globals.rs b/src/cli/globals.rs new file mode 100644 index 0000000..95c4ede --- /dev/null +++ b/src/cli/globals.rs @@ -0,0 +1,16 @@ +use std::path::{Path, PathBuf}; + +// Define the global arguments +#[derive(Debug, Clone, Default)] +pub struct GlobalArgs { + pub home: PathBuf, +} + +impl GlobalArgs { + #[must_use] + pub fn new(home_dir: &Path) -> Self { + Self { + home: home_dir.to_path_buf(), + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3a17327..8751274 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,5 @@ pub mod actions; +pub mod globals; mod start; pub use self::start::start; diff --git a/src/cli/start.rs b/src/cli/start.rs index a83fff6..f1e73db 100644 --- a/src/cli/start.rs +++ b/src/cli/start.rs @@ -1,4 +1,4 @@ -use crate::cli::{actions::Action, commands, dispatch::handler}; +use crate::cli::{actions::Action, commands, dispatch::handler, globals::GlobalArgs}; use anyhow::{Context, Result}; use std::{fs, path::PathBuf}; @@ -15,11 +15,24 @@ pub fn get_config_path() -> Result { } /// Start the CLI -pub fn start() -> Result { +pub fn start() -> Result<(Action, GlobalArgs)> { let config_path = get_config_path()?; + let global_args = GlobalArgs::new(&config_path); + let matches = commands::new(config_path).get_matches(); let action = handler(&matches)?; - Ok(action) + Ok((action, global_args)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_config_path() { + let config_path = get_config_path().unwrap(); + assert_eq!(config_path, dirs::home_dir().unwrap().join(".backup")); + } }