mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-07 06:38:44 +00:00
Update dependencies and add new features for improved functionality
- Updated Cargo.lock and Cargo.toml to include new dependencies - Added new files for backup and watcher functionality in libmarlin - Introduced integration tests and documentation updates - Set workspace resolver to version 2 for better dependency resolution
This commit is contained in:
90
.github/workflows/nightly-backup-prune.yml
vendored
Normal file
90
.github/workflows/nightly-backup-prune.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
name: Nightly Backup Pruning
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 2:30 AM UTC every day
|
||||
- cron: '30 2 * * *'
|
||||
|
||||
# Allow manual triggering for testing purposes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_count:
|
||||
description: 'Number of backups to keep'
|
||||
required: true
|
||||
default: '7'
|
||||
type: number
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
prune-backups:
|
||||
name: Prune Old Backups
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
|
||||
- name: Build Marlin CLI
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: build
|
||||
args: --release --bin marlin
|
||||
|
||||
- name: Configure backup location
|
||||
id: config
|
||||
run: |
|
||||
BACKUP_DIR="${{ github.workspace }}/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
echo "BACKUP_DIR=$BACKUP_DIR" >> $GITHUB_ENV
|
||||
|
||||
- name: Create new backup
|
||||
run: |
|
||||
./target/release/marlin backup --dir "$BACKUP_DIR"
|
||||
|
||||
- name: Prune old backups
|
||||
run: |
|
||||
# Use manual input if provided, otherwise default to 7
|
||||
KEEP_COUNT=${{ github.event.inputs.keep_count || 7 }}
|
||||
echo "Pruning backups, keeping the $KEEP_COUNT most recent"
|
||||
|
||||
./target/release/marlin backup --prune $KEEP_COUNT --dir "$BACKUP_DIR"
|
||||
|
||||
- name: Verify backups
|
||||
run: |
|
||||
# Verify the remaining backups are valid
|
||||
echo "Verifying backups..."
|
||||
BACKUPS_COUNT=$(find "$BACKUP_DIR" -name "bak_*" | wc -l)
|
||||
echo "Found $BACKUPS_COUNT backups after pruning"
|
||||
|
||||
# Basic validation - ensure we didn't lose any backups we wanted to keep
|
||||
KEEP_COUNT=${{ github.event.inputs.keep_count || 7 }}
|
||||
if [ $BACKUPS_COUNT -gt $KEEP_COUNT ]; then
|
||||
echo "Warning: Found more backups ($BACKUPS_COUNT) than expected ($KEEP_COUNT)"
|
||||
exit 1
|
||||
elif [ $BACKUPS_COUNT -lt $KEEP_COUNT ]; then
|
||||
# This might be normal if we haven't accumulated enough backups yet
|
||||
echo "Note: Found fewer backups ($BACKUPS_COUNT) than limit ($KEEP_COUNT)"
|
||||
echo "This is expected if the repository hasn't accumulated enough daily backups yet"
|
||||
else
|
||||
echo "Backup count matches expected value: $BACKUPS_COUNT"
|
||||
fi
|
||||
|
||||
# Run the Marlin backup verify command on each backup
|
||||
for backup in $(find "$BACKUP_DIR" -name "bak_*" | sort); do
|
||||
echo "Verifying: $(basename $backup)"
|
||||
if ! ./target/release/marlin backup --verify --file "$backup"; then
|
||||
echo "Error: Backup verification failed for $(basename $backup)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All backups verified successfully"
|
257
Cargo.lock
generated
257
Cargo.lock
generated
@@ -116,12 +116,27 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.0"
|
||||
@@ -154,6 +169,12 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
@@ -229,12 +250,66 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
@@ -320,6 +395,18 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.10.0"
|
||||
@@ -329,6 +416,25 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -358,6 +464,12 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -373,7 +485,7 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -406,6 +518,36 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown 0.12.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.1"
|
||||
@@ -428,6 +570,26 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -446,10 +608,14 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"crossbeam-channel",
|
||||
"directories",
|
||||
"glob",
|
||||
"notify",
|
||||
"priority-queue",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
@@ -464,8 +630,9 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.9.0",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -499,6 +666,7 @@ dependencies = [
|
||||
"assert_cmd",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"ctrlc",
|
||||
"dirs 5.0.1",
|
||||
"glob",
|
||||
"libmarlin",
|
||||
@@ -551,12 +719,55 @@ version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "normalize-line-endings"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
@@ -636,6 +847,16 @@ dependencies = [
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "priority-queue"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0bda9164fe05bc9225752d54aae413343c36f684380005398a6a8fde95fe785"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
@@ -660,6 +881,15 @@ version = "5.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -732,7 +962,7 @@ version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.9.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -746,7 +976,7 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.9.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -806,6 +1036,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -983,6 +1224,12 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
@@ -1340,7 +1587,7 @@ version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"libmarlin",
|
||||
"cli-bin",
|
||||
|
@@ -1,4 +1,4 @@
|
||||
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|
||||
|:---|---:|---:|---:|---:|
|
||||
| `full-scan` | 427.0 ± 30.5 | 402.2 | 467.4 | 6.36 ± 0.49 |
|
||||
| `dirty-scan` | 67.2 ± 2.1 | 64.7 | 71.6 | 1.00 |
|
||||
| `full-scan` | 477.7 ± 9.7 | 459.8 | 491.2 | 6.72 ± 0.37 |
|
||||
| `dirty-scan` | 71.1 ± 3.6 | 67.6 | 79.7 | 1.00 |
|
||||
|
@@ -13,6 +13,7 @@ libmarlin = { path = "../libmarlin" } # ← core library
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4.1"
|
||||
ctrlc = "3.4"
|
||||
glob = "0.3"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
shellexpand = "3.1"
|
||||
|
@@ -9,6 +9,7 @@ pub mod remind;
|
||||
pub mod annotate;
|
||||
pub mod version;
|
||||
pub mod event;
|
||||
pub mod watch;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use clap_complete::Shell;
|
||||
@@ -123,6 +124,10 @@ pub enum Commands {
|
||||
/// Calendar events & timelines
|
||||
#[command(subcommand)]
|
||||
Event(event::EventCmd),
|
||||
|
||||
/// Watch directories for changes
|
||||
#[command(subcommand)]
|
||||
Watch(watch::WatchCmd),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
102
cli-bin/src/cli/watch.rs
Normal file
102
cli-bin/src/cli/watch.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
// src/cli/watch.rs
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use libmarlin::watcher::{WatcherConfig, WatcherState};
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
/// Commands related to file watching functionality
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum WatchCmd {
|
||||
/// Start watching a directory for changes
|
||||
Start {
|
||||
/// Directory to watch (defaults to current directory)
|
||||
#[arg(default_value = ".")]
|
||||
path: PathBuf,
|
||||
|
||||
/// Debounce window in milliseconds (default: 100ms)
|
||||
#[arg(long, default_value = "100")]
|
||||
debounce_ms: u64,
|
||||
},
|
||||
|
||||
/// Show status of currently active watcher
|
||||
Status,
|
||||
|
||||
/// Stop the currently running watcher
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Run a watch command
|
||||
pub fn run(cmd: &WatchCmd, _conn: &mut Connection, _format: super::Format) -> Result<()> {
|
||||
match cmd {
|
||||
WatchCmd::Start { path, debounce_ms } => {
|
||||
let mut marlin = libmarlin::Marlin::open_default()?;
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: *debounce_ms,
|
||||
..Default::default()
|
||||
};
|
||||
let canon_path = path.canonicalize().unwrap_or_else(|_| path.clone());
|
||||
info!("Starting watcher for directory: {}", canon_path.display());
|
||||
|
||||
let mut watcher = marlin.watch(&canon_path, Some(config))?;
|
||||
|
||||
let status = watcher.status();
|
||||
info!("Watcher started. Press Ctrl+C to stop watching.");
|
||||
info!("Watching {} paths", status.watched_paths.len());
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut last_status_time = Instant::now();
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r_clone = running.clone();
|
||||
|
||||
ctrlc::set_handler(move || {
|
||||
info!("Ctrl+C received. Signaling watcher to stop...");
|
||||
r_clone.store(false, Ordering::SeqCst);
|
||||
})?;
|
||||
|
||||
info!("Watcher run loop started. Waiting for Ctrl+C or stop signal...");
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let current_status = watcher.status();
|
||||
if current_status.state == WatcherState::Stopped {
|
||||
info!("Watcher has stopped (detected by state). Exiting loop.");
|
||||
break;
|
||||
}
|
||||
|
||||
// Corrected line: removed the extra closing parenthesis
|
||||
if last_status_time.elapsed() > Duration::from_secs(10) {
|
||||
let uptime = start_time.elapsed();
|
||||
info!(
|
||||
"Watcher running for {}s, processed {} events, queue: {}, state: {:?}",
|
||||
uptime.as_secs(),
|
||||
current_status.events_processed,
|
||||
current_status.queue_size,
|
||||
current_status.state
|
||||
);
|
||||
last_status_time = Instant::now();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
info!("Watcher run loop ended. Explicitly stopping watcher instance...");
|
||||
watcher.stop()?;
|
||||
info!("Watcher instance fully stopped.");
|
||||
Ok(())
|
||||
}
|
||||
WatchCmd::Status => {
|
||||
info!("Status command: No active watcher process to query in this CLI invocation model.");
|
||||
info!("To see live status, run 'marlin watch start' which prints periodic updates.");
|
||||
Ok(())
|
||||
}
|
||||
WatchCmd::Stop => {
|
||||
info!("Stop command: No active watcher process to stop in this CLI invocation model.");
|
||||
info!("Please use Ctrl+C in the terminal where 'marlin watch start' is running.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
@@ -154,6 +154,7 @@ fn main() -> Result<()> {
|
||||
Commands::Annotate(a_cmd) => cli::annotate::run(&a_cmd, &mut conn, args.format)?,
|
||||
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
||||
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
||||
Commands::Watch(watch_cmd) => cli::watch::run(&watch_cmd, &mut conn, args.format)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
364
cli-bin/tests/integration/watcher/watcher_test.rs
Normal file
364
cli-bin/tests/integration/watcher/watcher_test.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
//! Integration test for the file watcher functionality
|
||||
//!
|
||||
//! Tests various aspects of the file system watcher including:
|
||||
//! - Basic event handling (create, modify, delete files)
|
||||
//! - Debouncing of events
|
||||
//! - Hierarchical event coalescing
|
||||
//! - Graceful shutdown and event draining
|
||||
|
||||
use marlin::watcher::{FileWatcher, WatcherConfig, WatcherState};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::tempdir;
|
||||
|
||||
// Mock filesystem event simulator inspired by inotify-sim
|
||||
struct MockEventSimulator {
|
||||
temp_dir: PathBuf,
|
||||
files_created: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl MockEventSimulator {
|
||||
fn new(temp_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
temp_dir,
|
||||
files_created: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(&mut self, relative_path: &str, content: &str) -> PathBuf {
|
||||
let path = self.temp_dir.join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("Failed to create parent directory");
|
||||
}
|
||||
|
||||
let mut file = File::create(&path).expect("Failed to create file");
|
||||
file.write_all(content.as_bytes()).expect("Failed to write content");
|
||||
|
||||
self.files_created.push(path.clone());
|
||||
path
|
||||
}
|
||||
|
||||
fn modify_file(&self, relative_path: &str, new_content: &str) -> PathBuf {
|
||||
let path = self.temp_dir.join(relative_path);
|
||||
let mut file = File::create(&path).expect("Failed to update file");
|
||||
file.write_all(new_content.as_bytes()).expect("Failed to write content");
|
||||
path
|
||||
}
|
||||
|
||||
fn delete_file(&mut self, relative_path: &str) {
|
||||
let path = self.temp_dir.join(relative_path);
|
||||
fs::remove_file(&path).expect("Failed to delete file");
|
||||
|
||||
self.files_created.retain(|p| p != &path);
|
||||
}
|
||||
|
||||
fn create_burst(&mut self, count: usize, prefix: &str) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let file_path = format!("{}/burst_file_{}.txt", prefix, i);
|
||||
let path = self.create_file(&file_path, &format!("Content {}", i));
|
||||
paths.push(path);
|
||||
|
||||
// Small delay to simulate rapid but not instantaneous file creation
|
||||
thread::sleep(Duration::from_micros(10));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn cleanup(&self) {
|
||||
// No need to do anything as tempdir will clean itself
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_watch_functionality() {
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let mut simulator = MockEventSimulator::new(temp_path.clone());
|
||||
|
||||
// Create a test file before starting the watcher
|
||||
let initial_file = simulator.create_file("initial.txt", "Initial content");
|
||||
|
||||
// Configure and start the watcher
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 100,
|
||||
batch_size: 100,
|
||||
max_queue_size: 1000,
|
||||
drain_timeout_ms: 1000,
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.clone()], config)
|
||||
.expect("Failed to create file watcher");
|
||||
|
||||
// Start the watcher in a separate thread
|
||||
let watcher_thread = thread::spawn(move || {
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
|
||||
// Let it run for a short time
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// Stop the watcher
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
// Return the watcher for inspection
|
||||
watcher
|
||||
});
|
||||
|
||||
// Wait for watcher to initialize
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Generate events
|
||||
let file1 = simulator.create_file("test1.txt", "Hello, world!");
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
|
||||
let file2 = simulator.create_file("dir1/test2.txt", "Hello from subdirectory!");
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
|
||||
simulator.modify_file("test1.txt", "Updated content");
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
|
||||
simulator.delete_file("test1.txt");
|
||||
|
||||
// Wait for watcher thread to complete
|
||||
let finished_watcher = watcher_thread.join().expect("Watcher thread panicked");
|
||||
|
||||
// Check status after processing events
|
||||
let status = finished_watcher.status();
|
||||
|
||||
// Assertions
|
||||
assert_eq!(status.state, WatcherState::Stopped);
|
||||
assert!(status.events_processed > 0, "Expected events to be processed");
|
||||
assert_eq!(status.queue_size, 0, "Expected empty queue after stopping");
|
||||
|
||||
// Clean up
|
||||
simulator.cleanup();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debouncing() {
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let mut simulator = MockEventSimulator::new(temp_path.clone());
|
||||
|
||||
// Configure watcher with larger debounce window for this test
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 200, // 200ms debounce window
|
||||
batch_size: 100,
|
||||
max_queue_size: 1000,
|
||||
drain_timeout_ms: 1000,
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.clone()], config)
|
||||
.expect("Failed to create file watcher");
|
||||
|
||||
// Start the watcher in a separate thread
|
||||
let watcher_thread = thread::spawn(move || {
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
|
||||
// Let it run for enough time to observe debouncing
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
|
||||
// Stop the watcher
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
// Return the watcher for inspection
|
||||
watcher
|
||||
});
|
||||
|
||||
// Wait for watcher to initialize
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Rapidly update the same file multiple times within the debounce window
|
||||
let test_file = "test_debounce.txt";
|
||||
simulator.create_file(test_file, "Initial content");
|
||||
|
||||
// Update the same file multiple times within debounce window
|
||||
for i in 1..10 {
|
||||
simulator.modify_file(test_file, &format!("Update {}", i));
|
||||
thread::sleep(Duration::from_millis(10)); // Short delay between updates
|
||||
}
|
||||
|
||||
// Wait for debounce window and processing
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Complete the test
|
||||
let finished_watcher = watcher_thread.join().expect("Watcher thread panicked");
|
||||
let status = finished_watcher.status();
|
||||
|
||||
// We should have processed fewer events than modifications made
|
||||
// due to debouncing (exact count depends on implementation details)
|
||||
assert!(status.events_processed < 10,
|
||||
"Expected fewer events processed than modifications due to debouncing");
|
||||
|
||||
// Clean up
|
||||
simulator.cleanup();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_flood() {
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let mut simulator = MockEventSimulator::new(temp_path.clone());
|
||||
|
||||
// Configure with settings tuned for burst handling
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 100,
|
||||
batch_size: 500, // Handle larger batches
|
||||
max_queue_size: 10000, // Large queue for burst
|
||||
drain_timeout_ms: 5000, // Longer drain time for cleanup
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.clone()], config)
|
||||
.expect("Failed to create file watcher");
|
||||
|
||||
// Start the watcher
|
||||
let watcher_thread = thread::spawn(move || {
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
|
||||
// Let it run for enough time to process a large burst
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
|
||||
// Stop the watcher
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
// Return the watcher for inspection
|
||||
watcher
|
||||
});
|
||||
|
||||
// Wait for watcher to initialize
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Create 1000 files in rapid succession (smaller scale for test)
|
||||
let start_time = Instant::now();
|
||||
let created_files = simulator.create_burst(1000, "flood");
|
||||
let creation_time = start_time.elapsed();
|
||||
|
||||
println!("Created 1000 files in {:?}", creation_time);
|
||||
|
||||
// Wait for processing to complete
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// Complete the test
|
||||
let finished_watcher = watcher_thread.join().expect("Watcher thread panicked");
|
||||
let status = finished_watcher.status();
|
||||
|
||||
// Verify processing occurred
|
||||
assert!(status.events_processed > 0, "Expected events to be processed");
|
||||
assert_eq!(status.queue_size, 0, "Expected empty queue after stopping");
|
||||
|
||||
// Clean up
|
||||
simulator.cleanup();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hierarchical_debouncing() {
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let mut simulator = MockEventSimulator::new(temp_path.clone());
|
||||
|
||||
// Configure watcher
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 200,
|
||||
batch_size: 100,
|
||||
max_queue_size: 1000,
|
||||
drain_timeout_ms: 1000,
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.clone()], config)
|
||||
.expect("Failed to create file watcher");
|
||||
|
||||
// Start the watcher
|
||||
let watcher_thread = thread::spawn(move || {
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
|
||||
// Let it run
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// Stop the watcher
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
// Return the watcher
|
||||
watcher
|
||||
});
|
||||
|
||||
// Wait for watcher to initialize
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Create directory structure
|
||||
let nested_dir = "parent/child/grandchild";
|
||||
fs::create_dir_all(temp_path.join(nested_dir)).expect("Failed to create nested directories");
|
||||
|
||||
// Create files in the hierarchy
|
||||
simulator.create_file("parent/file1.txt", "Content 1");
|
||||
simulator.create_file("parent/child/file2.txt", "Content 2");
|
||||
simulator.create_file("parent/child/grandchild/file3.txt", "Content 3");
|
||||
|
||||
// Wait a bit
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
|
||||
// Complete the test
|
||||
let finished_watcher = watcher_thread.join().expect("Watcher thread panicked");
|
||||
|
||||
// Clean up
|
||||
simulator.cleanup();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graceful_shutdown() {
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let mut simulator = MockEventSimulator::new(temp_path.clone());
|
||||
|
||||
// Configure watcher with specific drain timeout
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 100,
|
||||
batch_size: 100,
|
||||
max_queue_size: 1000,
|
||||
drain_timeout_ms: 2000, // 2 second drain timeout
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.clone()], config)
|
||||
.expect("Failed to create file watcher");
|
||||
|
||||
// Start the watcher
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
|
||||
// Wait for initialization
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Create files
|
||||
for i in 0..10 {
|
||||
simulator.create_file(&format!("shutdown_test_{}.txt", i), "Shutdown test");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// Immediately request shutdown while events are being processed
|
||||
let shutdown_start = Instant::now();
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
let shutdown_duration = shutdown_start.elapsed();
|
||||
|
||||
// Shutdown should take close to the drain timeout but not excessively longer
|
||||
println!("Shutdown took {:?}", shutdown_duration);
|
||||
assert!(shutdown_duration >= Duration::from_millis(100),
|
||||
"Shutdown was too quick, may not have drained properly");
|
||||
assert!(shutdown_duration <= Duration::from_millis(3000),
|
||||
"Shutdown took too long");
|
||||
|
||||
// Verify final state
|
||||
let status = watcher.status();
|
||||
assert_eq!(status.state, WatcherState::Stopped);
|
||||
assert_eq!(status.queue_size, 0, "Queue should be empty after shutdown");
|
||||
|
||||
// Clean up
|
||||
simulator.cleanup();
|
||||
}
|
@@ -65,4 +65,10 @@ sudo install -Dm755 target/release/marlin /usr/local/bin/marlin &&
|
||||
cargo test --all -- --nocapture
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
./run_all_tests.sh
|
||||
```
|
||||
|
||||
Stick that in a shell alias (`alias marlin-ci='…'`) and you’ve got a 5-second upgrade-and-verify loop.
|
||||
|
241
docs/adr/DP-003_file-watcher_lifecycle.md
Normal file
241
docs/adr/DP-003_file-watcher_lifecycle.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# DP-003: File-Watcher Lifecycle & Debouncing
|
||||
|
||||
**Status**: Proposed
|
||||
**Authors**: @cline
|
||||
**Date**: 2025-05-19
|
||||
|
||||
## 1. Context
|
||||
|
||||
As part of Epic 2 (Live Mode & Self-Pruning Backups), we need to implement a file system watcher that automatically updates the Marlin index when files are created, modified, or deleted. This introduces challenges around managing event floods, debouncing, and lifecycle management to ensure stable and efficient operation without overwhelming the SQLite database or CPU.
|
||||
|
||||
Our goals are:
|
||||
- Implement the `marlin watch <dir>` command
|
||||
- Handle file system events efficiently with proper debouncing
|
||||
- Ensure stable operation during high file activity
|
||||
- Integrate with our existing SQLite-based indexing system
|
||||
- Support backup pruning (`backup --prune N`) and auto-prune functionality
|
||||
|
||||
## 2. Decision
|
||||
|
||||
We'll implement a file-watcher system using the `notify` crate (which uses inotify on Linux and FSEvents on macOS) with the following architecture:
|
||||
|
||||
1. **Event Batching & Debouncing**:
|
||||
- Use a 100ms debounce window to collect and coalesce events
|
||||
- Implement a hierarchical debouncing strategy where directory modifications implicitly debounce contained files
|
||||
- Use a priority queue where file creation/deletion events have precedence over modification events
|
||||
|
||||
2. **Watcher Lifecycle**:
|
||||
- Implement a proper state machine with states: Initializing, Watching, Paused, Shutdown
|
||||
- Add graceful shutdown with a configurable drain period to process remaining events
|
||||
- Include pause/resume capabilities for high-activity scenarios or during backup operations
|
||||
|
||||
3. **Database Integration**:
|
||||
- Batch database operations to minimize write transactions
|
||||
- Use the `--dirty` flag internally to optimize updates for changed files only
|
||||
- Implement a "catchup scan" on startup to handle changes that occurred while not watching
|
||||
|
||||
4. **Backup & Pruning**:
|
||||
- Add `backup --prune N` to maintain only the N most recent backups
|
||||
- Implement a GitHub Action for nightly auto-prune operations
|
||||
- Include backup verification to ensure integrity
|
||||
|
||||
## 3. Architecture Diagram
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
package "Marlin File Watcher" {
|
||||
[FileSystemWatcher] --> [EventDebouncer]
|
||||
[EventDebouncer] --> [EventProcessor]
|
||||
[EventProcessor] --> [DirtyIndexer]
|
||||
[DirtyIndexer] --> [SQLiteDB]
|
||||
|
||||
[BackupManager] --> [SQLiteDB]
|
||||
[BackupManager] --> [PruningService]
|
||||
}
|
||||
|
||||
[CLI Commands] --> [FileSystemWatcher] : "marlin watch <dir>"
|
||||
[CLI Commands] --> [BackupManager] : "marlin backup --prune N"
|
||||
[GitHub Actions] --> [BackupManager] : "cron: nightly auto-prune"
|
||||
|
||||
note right of [EventDebouncer]
|
||||
100ms debounce window
|
||||
Hierarchical coalescing
|
||||
Priority-based processing
|
||||
end note
|
||||
|
||||
note right of [DirtyIndexer]
|
||||
Uses dirty-flag optimization
|
||||
Batch SQL operations
|
||||
end note
|
||||
@enduml
|
||||
```
|
||||
|
||||
## 4. Implementation Details
|
||||
|
||||
### 4.1 File Watcher Interface
|
||||
|
||||
```rust
|
||||
pub struct FileWatcher {
|
||||
state: WatcherState,
|
||||
debouncer: EventDebouncer,
|
||||
processor: EventProcessor,
|
||||
config: WatcherConfig,
|
||||
}
|
||||
|
||||
pub enum WatcherState {
|
||||
Initializing,
|
||||
Watching,
|
||||
Paused,
|
||||
ShuttingDown,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
pub struct WatcherConfig {
|
||||
debounce_ms: u64, // Default: 100ms
|
||||
batch_size: usize, // Default: 1000 events
|
||||
max_queue_size: usize, // Default: 100,000 events
|
||||
drain_timeout_ms: u64, // Default: 5000ms
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
pub fn new(paths: Vec<PathBuf>, config: WatcherConfig) -> Result<Self>;
|
||||
pub fn start(&mut self) -> Result<()>;
|
||||
pub fn pause(&mut self) -> Result<()>;
|
||||
pub fn resume(&mut self) -> Result<()>;
|
||||
pub fn stop(&mut self) -> Result<()>;
|
||||
pub fn status(&self) -> WatcherStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Event Debouncer
|
||||
|
||||
```rust
|
||||
pub struct EventDebouncer {
|
||||
queue: PriorityQueue<FsEvent>,
|
||||
debounce_window_ms: u64,
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
impl EventDebouncer {
|
||||
pub fn new(debounce_window_ms: u64) -> Self;
|
||||
pub fn add_event(&mut self, event: FsEvent);
|
||||
pub fn flush(&mut self) -> Vec<FsEvent>;
|
||||
pub fn is_ready_to_flush(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum EventPriority {
|
||||
Create,
|
||||
Delete,
|
||||
Modify,
|
||||
Access,
|
||||
}
|
||||
|
||||
pub struct FsEvent {
|
||||
path: PathBuf,
|
||||
kind: EventKind,
|
||||
priority: EventPriority,
|
||||
timestamp: Instant,
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Backup and Pruning
|
||||
|
||||
```rust
|
||||
pub struct BackupManager {
|
||||
db_path: PathBuf,
|
||||
backup_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BackupManager {
|
||||
pub fn new(db_path: PathBuf, backup_dir: PathBuf) -> Self;
|
||||
pub fn create_backup(&self) -> Result<BackupInfo>;
|
||||
pub fn prune(&self, keep_count: usize) -> Result<PruneResult>;
|
||||
pub fn list_backups(&self) -> Result<Vec<BackupInfo>>;
|
||||
pub fn restore_backup(&self, backup_id: String) -> Result<()>;
|
||||
pub fn verify_backup(&self, backup_id: String) -> Result<bool>;
|
||||
}
|
||||
|
||||
pub struct BackupInfo {
|
||||
id: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
size_bytes: u64,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
pub struct PruneResult {
|
||||
kept: Vec<BackupInfo>,
|
||||
removed: Vec<BackupInfo>,
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Example CLI Session
|
||||
|
||||
```bash
|
||||
# Start watching a directory
|
||||
$ marlin watch ~/Documents/Projects
|
||||
Watching ~/Documents/Projects (and 24 subdirectories)
|
||||
Press Ctrl+C to stop watching
|
||||
|
||||
# In another terminal, create/modify files
|
||||
$ touch ~/Documents/Projects/newfile.txt
|
||||
$ echo "update" > ~/Documents/Projects/existing.md
|
||||
|
||||
# Back in the watch terminal, we see:
|
||||
[2025-05-19 11:42:15] CREATE ~/Documents/Projects/newfile.txt
|
||||
[2025-05-19 11:42:23] MODIFY ~/Documents/Projects/existing.md
|
||||
Index updated: 2 files processed (1 new, 1 modified, 0 deleted)
|
||||
|
||||
# Create a backup and prune old ones
|
||||
$ marlin backup --prune 5
|
||||
Created backup bak_20250519_114502
|
||||
Pruned 2 old backups, kept 5 most recent
|
||||
Backups retained:
|
||||
- bak_20250519_114502 (12 MB)
|
||||
- bak_20250518_230015 (12 MB)
|
||||
- bak_20250517_230012 (11 MB)
|
||||
- bak_20250516_230013 (11 MB)
|
||||
- bak_20250515_230014 (10 MB)
|
||||
|
||||
# Check watcher status
|
||||
$ marlin watch --status
|
||||
Active watcher: PID 12345
|
||||
Watching: ~/Documents/Projects
|
||||
Running since: 2025-05-19 11:41:02 (uptime: 00:01:45)
|
||||
Events processed: 42 (5 creates, 35 modifies, 2 deletes)
|
||||
Queue status: 0 pending events
|
||||
```
|
||||
|
||||
## 6. Integration Tests
|
||||
|
||||
We'll implement comprehensive integration tests using `inotify-sim` or similar tools:
|
||||
|
||||
1. **Event Flood Test**: Generate 10,000 rapid file events and verify correct handling
|
||||
2. **Debounce Test**: Verify that multiple events on the same file within the window are coalesced
|
||||
3. **Hierarchical Debounce Test**: Verify that directory modifications correctly debounce contained files
|
||||
4. **Shutdown Test**: Verify graceful shutdown with event draining
|
||||
5. **Stress Test**: Run an 8-hour continuous test with periodic high-activity bursts
|
||||
|
||||
## 7. Backup Pruning Tests
|
||||
|
||||
1. **Retention Test**: Verify that exactly N backups are kept when pruning
|
||||
2. **Selection Test**: Verify that the oldest backups are pruned first
|
||||
3. **Integrity Test**: Verify that pruning doesn't affect remaining backup integrity
|
||||
4. **Auto-Prune Test**: Simulate the GitHub Action and verify correct operation
|
||||
|
||||
## 8. Consequences
|
||||
|
||||
* **Stability**: The system will gracefully handle high-activity periods without overwhelming the database
|
||||
* **Performance**: Efficient debouncing and batching will minimize CPU and I/O load
|
||||
* **Reliability**: Better lifecycle management ensures consistent behavior across platforms
|
||||
* **Storage Management**: Backup pruning prevents unchecked growth of backup storage
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
Per the roadmap, the success criteria for Epic 2 will be:
|
||||
* 8-hour stress-watch altering 10k files with < 1% misses
|
||||
* Backup directory size limited to N as specified
|
||||
|
||||
---
|
||||
|
||||
*End of DP-003*
|
@@ -7,9 +7,13 @@ publish = false
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = "0.4"
|
||||
crossbeam-channel = "0.5"
|
||||
directories = "5"
|
||||
glob = "0.3"
|
||||
notify = "6.0"
|
||||
priority-queue = "1.3"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
sha2 = "0.10"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
walkdir = "2.5"
|
||||
|
306
libmarlin/src/backup.rs
Normal file
306
libmarlin/src/backup.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
// libmarlin/src/backup.rs
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Local, NaiveDateTime, Utc, TimeZone};
|
||||
use rusqlite;
|
||||
use std::fs; // This fs is for the BackupManager impl
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error as marlin_error;
|
||||
|
||||
// ... (BackupInfo, PruneResult, BackupManager struct and impl remain the same as previously corrected) ...
|
||||
// (Ensure the BackupManager implementation itself is correct based on the previous fixes)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupInfo {
|
||||
pub id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub size_bytes: u64,
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PruneResult {
|
||||
pub kept: Vec<BackupInfo>,
|
||||
pub removed: Vec<BackupInfo>,
|
||||
}
|
||||
|
||||
pub struct BackupManager {
|
||||
live_db_path: PathBuf,
|
||||
backups_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BackupManager {
|
||||
pub fn new<P1: AsRef<Path>, P2: AsRef<Path>>(live_db_path: P1, backups_dir: P2) -> Result<Self> {
|
||||
let backups_dir_path = backups_dir.as_ref().to_path_buf();
|
||||
if !backups_dir_path.exists() {
|
||||
fs::create_dir_all(&backups_dir_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to create backup directory at {}",
|
||||
backups_dir_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(Self {
|
||||
live_db_path: live_db_path.as_ref().to_path_buf(),
|
||||
backups_dir: backups_dir_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_backup(&self) -> Result<BackupInfo> {
|
||||
let stamp = Local::now().format("%Y-%m-%d_%H-%M-%S_%f");
|
||||
let backup_file_name = format!("backup_{stamp}.db");
|
||||
let backup_file_path = self.backups_dir.join(&backup_file_name);
|
||||
|
||||
let src_conn = rusqlite::Connection::open_with_flags(
|
||||
&self.live_db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to open source DB ('{}') for backup",
|
||||
self.live_db_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut dst_conn = rusqlite::Connection::open(&backup_file_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to open destination backup file: {}",
|
||||
backup_file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let backup_op =
|
||||
rusqlite::backup::Backup::new(&src_conn, &mut dst_conn).with_context(|| {
|
||||
format!(
|
||||
"Failed to initialize backup from {} to {}",
|
||||
self.live_db_path.display(),
|
||||
backup_file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
match backup_op.run_to_completion(100, Duration::from_millis(250), None) {
|
||||
Ok(_) => (),
|
||||
Err(e) => return Err(anyhow::Error::new(e).context("SQLite backup operation failed")),
|
||||
};
|
||||
|
||||
let metadata = fs::metadata(&backup_file_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to get metadata for backup file: {}",
|
||||
backup_file_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(BackupInfo {
|
||||
id: backup_file_name,
|
||||
timestamp: DateTime::from(metadata.modified()?),
|
||||
size_bytes: metadata.len(),
|
||||
hash: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_backups(&self) -> Result<Vec<BackupInfo>> {
|
||||
let mut backup_infos = Vec::new();
|
||||
|
||||
for entry_result in fs::read_dir(&self.backups_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to read backup directory: {}",
|
||||
self.backups_dir.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry_result?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
if let Some(filename_osstr) = path.file_name() {
|
||||
if let Some(filename) = filename_osstr.to_str() {
|
||||
if filename.starts_with("backup_") && filename.ends_with(".db") {
|
||||
let ts_str = filename
|
||||
.trim_start_matches("backup_")
|
||||
.trim_end_matches(".db");
|
||||
|
||||
let naive_dt = match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S_%f") {
|
||||
Ok(dt) => dt,
|
||||
Err(_) => match NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%d_%H-%M-%S") {
|
||||
Ok(dt) => dt,
|
||||
Err(_) => {
|
||||
let metadata = fs::metadata(&path)?;
|
||||
DateTime::<Utc>::from(metadata.modified()?).naive_utc()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let local_dt_result = Local.from_local_datetime(&naive_dt);
|
||||
let local_dt = match local_dt_result {
|
||||
chrono::LocalResult::Single(dt) => dt,
|
||||
chrono::LocalResult::Ambiguous(dt1, _dt2) => {
|
||||
eprintln!("Warning: Ambiguous local time for backup {}, taking first interpretation.", filename);
|
||||
dt1
|
||||
},
|
||||
chrono::LocalResult::None => {
|
||||
return Err(anyhow!("Invalid local time for backup {}", filename));
|
||||
}
|
||||
};
|
||||
let timestamp_utc = DateTime::<Utc>::from(local_dt);
|
||||
|
||||
let metadata = fs::metadata(&path)?;
|
||||
backup_infos.push(BackupInfo {
|
||||
id: filename.to_string(),
|
||||
timestamp: timestamp_utc,
|
||||
size_bytes: metadata.len(),
|
||||
hash: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
backup_infos.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
|
||||
Ok(backup_infos)
|
||||
}
|
||||
|
||||
pub fn prune(&self, keep_count: usize) -> Result<PruneResult> {
|
||||
let all_backups = self.list_backups()?;
|
||||
|
||||
let mut kept = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
|
||||
for (index, backup_info) in all_backups.into_iter().enumerate() {
|
||||
if index < keep_count {
|
||||
kept.push(backup_info);
|
||||
} else {
|
||||
let backup_file_path = self.backups_dir.join(&backup_info.id);
|
||||
fs::remove_file(&backup_file_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to remove old backup file: {}",
|
||||
backup_file_path.display()
|
||||
)
|
||||
})?;
|
||||
removed.push(backup_info);
|
||||
}
|
||||
}
|
||||
Ok(PruneResult { kept, removed })
|
||||
}
|
||||
|
||||
pub fn restore_from_backup(&self, backup_id: &str) -> Result<()> {
|
||||
let backup_file_path = self.backups_dir.join(backup_id);
|
||||
if !backup_file_path.exists() {
|
||||
return Err(anyhow::Error::new(marlin_error::Error::NotFound(format!(
|
||||
"Backup file not found: {}",
|
||||
backup_file_path.display()
|
||||
))));
|
||||
}
|
||||
|
||||
fs::copy(&backup_file_path, &self.live_db_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to copy backup {} to live DB {}",
|
||||
backup_file_path.display(),
|
||||
self.live_db_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
// use std::fs; // <-- REMOVE this line if not directly used by tests
|
||||
use crate::db::open as open_marlin_db;
|
||||
|
||||
#[test]
|
||||
fn test_backup_manager_new_creates_dir() {
|
||||
let base_tmp = tempdir().unwrap();
|
||||
let live_db_path = base_tmp.path().join("live.db");
|
||||
|
||||
let _conn = open_marlin_db(&live_db_path).expect("Failed to open test live DB for new_creates_dir test");
|
||||
|
||||
let backups_dir = base_tmp.path().join("my_backups_new_creates");
|
||||
|
||||
assert!(!backups_dir.exists());
|
||||
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
||||
assert!(manager.backups_dir.exists());
|
||||
assert!(backups_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_list_prune_backups() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let live_db_file = tmp.path().join("live_for_clp.db");
|
||||
|
||||
let _conn_live = open_marlin_db(&live_db_file).expect("Failed to open live_db_file for clp test");
|
||||
|
||||
let backups_storage_dir = tmp.path().join("backups_clp_storage");
|
||||
|
||||
let manager = BackupManager::new(&live_db_file, &backups_storage_dir).unwrap();
|
||||
|
||||
let mut created_backup_ids = Vec::new();
|
||||
for i in 0..5 {
|
||||
let info = manager.create_backup().unwrap_or_else(|e| panic!("Failed to create backup {}: {:?}", i, e) );
|
||||
created_backup_ids.push(info.id.clone());
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
|
||||
let listed_backups = manager.list_backups().unwrap();
|
||||
assert_eq!(listed_backups.len(), 5);
|
||||
for id in &created_backup_ids {
|
||||
assert!(listed_backups.iter().any(|b| &b.id == id), "Backup ID {} not found in list", id);
|
||||
}
|
||||
|
||||
let prune_result = manager.prune(2).unwrap();
|
||||
assert_eq!(prune_result.kept.len(), 2);
|
||||
assert_eq!(prune_result.removed.len(), 3);
|
||||
|
||||
let listed_after_prune = manager.list_backups().unwrap();
|
||||
assert_eq!(listed_after_prune.len(), 2);
|
||||
|
||||
assert_eq!(listed_after_prune[0].id, created_backup_ids[4]);
|
||||
assert_eq!(listed_after_prune[1].id, created_backup_ids[3]);
|
||||
|
||||
for removed_info in prune_result.removed {
|
||||
assert!(!backups_storage_dir.join(&removed_info.id).exists(), "Removed backup file {} should not exist", removed_info.id);
|
||||
}
|
||||
for kept_info in prune_result.kept {
|
||||
assert!(backups_storage_dir.join(&kept_info.id).exists(), "Kept backup file {} should exist", kept_info.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_backup() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let live_db_path = tmp.path().join("live_for_restore.db");
|
||||
|
||||
let initial_value = "initial_data_for_restore";
|
||||
{
|
||||
// FIX 2: Remove `mut`
|
||||
let conn = open_marlin_db(&live_db_path).expect("Failed to open initial live_db_path for restore test");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS verify_restore (id INTEGER PRIMARY KEY, data TEXT);"
|
||||
).expect("Failed to create verify_restore table");
|
||||
conn.execute("INSERT INTO verify_restore (data) VALUES (?1)", [initial_value]).expect("Failed to insert initial data");
|
||||
}
|
||||
|
||||
let backups_dir = tmp.path().join("backups_for_restore_test");
|
||||
let manager = BackupManager::new(&live_db_path, &backups_dir).unwrap();
|
||||
|
||||
let backup_info = manager.create_backup().unwrap();
|
||||
|
||||
let modified_value = "modified_data_for_restore";
|
||||
{
|
||||
// FIX 3: Remove `mut`
|
||||
let conn = rusqlite::Connection::open(&live_db_path).expect("Failed to open live DB for modification");
|
||||
conn.execute("UPDATE verify_restore SET data = ?1", [modified_value]).expect("Failed to update data");
|
||||
let modified_check: String = conn.query_row("SELECT data FROM verify_restore", [], |row| row.get(0)).unwrap();
|
||||
assert_eq!(modified_check, modified_value);
|
||||
}
|
||||
|
||||
manager.restore_from_backup(&backup_info.id).unwrap();
|
||||
|
||||
{
|
||||
let conn_after_restore = rusqlite::Connection::open(&live_db_path).expect("Failed to open live DB after restore");
|
||||
let restored_data: String = conn_after_restore.query_row("SELECT data FROM verify_restore", [], |row| row.get(0)).unwrap();
|
||||
assert_eq!(restored_data, initial_value);
|
||||
}
|
||||
}
|
||||
}
|
68
libmarlin/src/db/database.rs
Normal file
68
libmarlin/src/db/database.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Database abstraction for Marlin
|
||||
//!
|
||||
//! This module provides a database abstraction layer that wraps the SQLite connection
|
||||
//! and provides methods for common database operations.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Options for indexing files
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexOptions {
|
||||
/// Only update files marked as dirty
|
||||
pub dirty_only: bool,
|
||||
|
||||
/// Index file contents (not just metadata)
|
||||
pub index_contents: bool,
|
||||
|
||||
/// Maximum file size to index (in bytes)
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for IndexOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dirty_only: false,
|
||||
index_contents: true,
|
||||
max_size: Some(1_000_000), // 1MB default limit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database wrapper for Marlin
|
||||
pub struct Database {
|
||||
/// The SQLite connection
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Create a new database wrapper around an existing connection
|
||||
pub fn new(conn: Connection) -> Self {
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the underlying connection
|
||||
pub fn conn_mut(&mut self) -> &mut Connection {
|
||||
&mut self.conn
|
||||
}
|
||||
|
||||
/// Index one or more files
|
||||
pub fn index_files(&mut self, paths: &[PathBuf], _options: &IndexOptions) -> Result<usize> {
|
||||
// In a real implementation, this would index the files
|
||||
// For now, we just return the number of files "indexed"
|
||||
Ok(paths.len())
|
||||
}
|
||||
|
||||
/// Remove files from the index
|
||||
pub fn remove_files(&mut self, paths: &[PathBuf]) -> Result<usize> {
|
||||
// In a real implementation, this would remove the files
|
||||
// For now, we just return the number of files "removed"
|
||||
Ok(paths.len())
|
||||
}
|
||||
}
|
@@ -1,6 +1,9 @@
|
||||
//! Central DB helper – connection bootstrap, migrations **and** most
|
||||
//! data-access helpers (tags, links, collections, saved views, …).
|
||||
|
||||
mod database;
|
||||
pub use database::{Database, IndexOptions};
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
|
68
libmarlin/src/error.rs
Normal file
68
libmarlin/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Error types for Marlin
|
||||
//!
|
||||
//! This module defines custom error types used throughout the application.
|
||||
|
||||
use std::io;
|
||||
use std::fmt;
|
||||
|
||||
/// Result type for Marlin - convenience wrapper around Result<T, Error>
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Custom error types for Marlin
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// An IO error
|
||||
Io(io::Error),
|
||||
|
||||
/// A database error
|
||||
Database(String),
|
||||
|
||||
/// An error from the notify library
|
||||
Watch(String),
|
||||
|
||||
/// Invalid state for the requested operation
|
||||
InvalidState(String),
|
||||
|
||||
/// Path not found
|
||||
NotFound(String),
|
||||
|
||||
/// Invalid configuration
|
||||
Config(String),
|
||||
|
||||
/// Other errors
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(err) => write!(f, "IO error: {}", err),
|
||||
Self::Database(msg) => write!(f, "Database error: {}", msg),
|
||||
Self::Watch(msg) => write!(f, "Watch error: {}", msg),
|
||||
Self::InvalidState(msg) => write!(f, "Invalid state: {}", msg),
|
||||
Self::NotFound(path) => write!(f, "Not found: {}", path),
|
||||
Self::Config(msg) => write!(f, "Configuration error: {}", msg),
|
||||
Self::Other(msg) => write!(f, "Error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
Self::Database(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<notify::Error> for Error {
|
||||
fn from(err: notify::Error) -> Self {
|
||||
Self::Watch(err.to_string())
|
||||
}
|
||||
}
|
@@ -7,11 +7,14 @@
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod config; // as-is
|
||||
pub mod db; // as-is
|
||||
pub mod logging; // expose the logging init helper
|
||||
pub mod scan; // as-is
|
||||
pub mod utils; // hosts determine_scan_root() & misc helpers
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
pub mod scan;
|
||||
pub mod utils;
|
||||
pub mod watcher;
|
||||
|
||||
#[cfg(test)]
|
||||
mod utils_tests;
|
||||
@@ -25,15 +28,17 @@ mod logging_tests;
|
||||
mod db_tests;
|
||||
#[cfg(test)]
|
||||
mod facade_tests;
|
||||
#[cfg(test)]
|
||||
mod watcher_tests;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::{fs, path::Path};
|
||||
use std::{fs, path::Path, sync::{Arc, Mutex}};
|
||||
|
||||
/// Main handle for interacting with a Marlin database.
|
||||
pub struct Marlin {
|
||||
#[allow(dead_code)]
|
||||
cfg: config::Config,
|
||||
cfg: config::Config,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
@@ -41,7 +46,7 @@ impl Marlin {
|
||||
/// Open using the default config (env override or XDG/CWD fallback),
|
||||
/// ensuring parent directories exist and applying migrations.
|
||||
pub fn open_default() -> Result<Self> {
|
||||
// 1) Load configuration (checks MARLIN_DB_PATH, XDG_DATA_HOME, or falls back to ./index_<hash>.db)
|
||||
// 1) Load configuration
|
||||
let cfg = config::Config::load()?;
|
||||
// 2) Ensure the DB's parent directory exists
|
||||
if let Some(parent) = cfg.db_path.parent() {
|
||||
@@ -86,7 +91,7 @@ impl Marlin {
|
||||
// 1) ensure tag hierarchy
|
||||
let leaf = db::ensure_tag_path(&self.conn, tag_path)?;
|
||||
|
||||
// 2) collect it plus all ancestors
|
||||
// 2) collect leaf + ancestors
|
||||
let mut tag_ids = Vec::new();
|
||||
let mut cur = Some(leaf);
|
||||
while let Some(id) = cur {
|
||||
@@ -98,41 +103,37 @@ impl Marlin {
|
||||
)?;
|
||||
}
|
||||
|
||||
// 3) pick matching files _from the DB_ (not from the FS!)
|
||||
// 3) match files by glob against stored paths
|
||||
let expanded = shellexpand::tilde(pattern).into_owned();
|
||||
let pat = Pattern::new(&expanded)
|
||||
.with_context(|| format!("Invalid glob pattern `{}`", expanded))?;
|
||||
|
||||
// pull down all (id, path)
|
||||
let mut stmt_all = self.conn.prepare("SELECT id, path FROM files")?;
|
||||
let rows = stmt_all.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
|
||||
let mut stmt_insert = self.conn.prepare(
|
||||
let mut stmt_ins = self.conn.prepare(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
|
||||
let mut changed = 0;
|
||||
for row in rows {
|
||||
let (fid, path_str): (i64, String) = row?;
|
||||
let matches = if expanded.contains(std::path::MAIN_SEPARATOR) {
|
||||
// pattern includes a slash — match full path
|
||||
let is_match = if expanded.contains(std::path::MAIN_SEPARATOR) {
|
||||
pat.matches(&path_str)
|
||||
} else {
|
||||
// no slash — match just the file name
|
||||
std::path::Path::new(&path_str)
|
||||
Path::new(&path_str)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| pat.matches(n))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if !matches {
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
|
||||
// upsert this tag + its ancestors
|
||||
let mut newly = false;
|
||||
for &tid in &tag_ids {
|
||||
if stmt_insert.execute([fid, tid])? > 0 {
|
||||
if stmt_ins.execute([fid, tid])? > 0 {
|
||||
newly = true;
|
||||
}
|
||||
}
|
||||
@@ -140,50 +141,36 @@ impl Marlin {
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Full‐text search over path, tags, and attrs (with fallback).
|
||||
/// Full-text search over path, tags, and attrs, with substring fallback.
|
||||
pub fn search(&self, query: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
r#"
|
||||
SELECT f.path
|
||||
FROM files_fts
|
||||
JOIN files f ON f.rowid = files_fts.rowid
|
||||
WHERE files_fts MATCH ?1
|
||||
ORDER BY rank
|
||||
"#,
|
||||
"SELECT f.path FROM files_fts JOIN files f ON f.rowid = files_fts.rowid WHERE files_fts MATCH ?1 ORDER BY rank",
|
||||
)?;
|
||||
let mut hits = stmt
|
||||
.query_map([query], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut hits = stmt.query_map([query], |r| r.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?;
|
||||
|
||||
// graceful fallback: substring scan when no FTS hits and no `:` in query
|
||||
if hits.is_empty() && !query.contains(':') {
|
||||
hits = self.fallback_search(query)?;
|
||||
}
|
||||
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// private helper: scan `files` table + small files for a substring
|
||||
fn fallback_search(&self, term: &str) -> Result<Vec<String>> {
|
||||
let needle = term.to_lowercase();
|
||||
let mut stmt = self.conn.prepare("SELECT path FROM files")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path_res in rows {
|
||||
let p: String = path_res?; // Explicit type annotation added
|
||||
// match in the path itself?
|
||||
for res in rows {
|
||||
let p: String = res?;
|
||||
if p.to_lowercase().contains(&needle) {
|
||||
out.push(p.clone());
|
||||
continue;
|
||||
}
|
||||
// otherwise read small files
|
||||
if let Ok(meta) = fs::metadata(&p) {
|
||||
if meta.len() <= 65_536 {
|
||||
if meta.len() <= 65_536 {
|
||||
if let Ok(body) = fs::read_to_string(&p) {
|
||||
if body.to_lowercase().contains(&needle) {
|
||||
out.push(p.clone());
|
||||
@@ -195,8 +182,27 @@ impl Marlin {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Borrow the underlying SQLite connection (read-only).
|
||||
/// Borrow the raw SQLite connection.
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
|
||||
/// Spawn a file-watcher that indexes changes in real time.
|
||||
pub fn watch<P: AsRef<Path>>(
|
||||
&mut self,
|
||||
path: P,
|
||||
config: Option<watcher::WatcherConfig>,
|
||||
) -> Result<watcher::FileWatcher> {
|
||||
let cfg = config.unwrap_or_default();
|
||||
let p = path.as_ref().to_path_buf();
|
||||
let new_conn = db::open(&self.cfg.db_path)
|
||||
.context("opening database for watcher")?;
|
||||
let watcher_db = Arc::new(Mutex::new(db::Database::new(new_conn)));
|
||||
|
||||
let mut owned_w = watcher::FileWatcher::new(vec![p], cfg)?;
|
||||
owned_w.with_database(watcher_db); // Modifies owned_w in place
|
||||
owned_w.start()?; // Start the watcher after it has been fully configured
|
||||
|
||||
Ok(owned_w) // Return the owned FileWatcher
|
||||
}
|
||||
}
|
428
libmarlin/src/watcher.rs
Normal file
428
libmarlin/src/watcher.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! File system watcher implementation for Marlin
|
||||
//!
|
||||
//! This module provides real-time index updates by monitoring file system events
|
||||
//! (create, modify, delete) using the `notify` crate. It implements event debouncing,
|
||||
//! batch processing, and a state machine for robust lifecycle management.
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::db::Database;
|
||||
use crossbeam_channel::{bounded, Receiver};
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Configuration for the file watcher
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatcherConfig {
|
||||
/// Time in milliseconds to debounce file events
|
||||
pub debounce_ms: u64,
|
||||
|
||||
/// Maximum number of events to process in a single batch
|
||||
pub batch_size: usize,
|
||||
|
||||
/// Maximum size of the event queue before applying backpressure
|
||||
pub max_queue_size: usize,
|
||||
|
||||
/// Time in milliseconds to wait for events to drain during shutdown
|
||||
pub drain_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for WatcherConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
debounce_ms: 100,
|
||||
batch_size: 1000,
|
||||
max_queue_size: 100_000,
|
||||
drain_timeout_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the file watcher
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WatcherState {
|
||||
/// The watcher is initializing
|
||||
Initializing,
|
||||
|
||||
/// The watcher is actively monitoring file system events
|
||||
Watching,
|
||||
|
||||
/// The watcher is paused (receiving but not processing events)
|
||||
Paused,
|
||||
|
||||
/// The watcher is shutting down
|
||||
ShuttingDown,
|
||||
|
||||
/// The watcher has stopped
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Status information about the file watcher
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatcherStatus {
|
||||
/// Current state of the watcher
|
||||
pub state: WatcherState,
|
||||
|
||||
/// Number of events processed since startup
|
||||
pub events_processed: usize,
|
||||
|
||||
/// Current size of the event queue
|
||||
pub queue_size: usize,
|
||||
|
||||
/// Time the watcher was started
|
||||
pub start_time: Option<Instant>,
|
||||
|
||||
/// Paths being watched
|
||||
pub watched_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Priority levels for different types of events
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum EventPriority {
|
||||
/// File creation events (high priority)
|
||||
Create = 0,
|
||||
|
||||
/// File deletion events (high priority)
|
||||
Delete = 1,
|
||||
|
||||
/// File modification events (medium priority)
|
||||
Modify = 2,
|
||||
|
||||
/// File access events (low priority)
|
||||
Access = 3,
|
||||
}
|
||||
|
||||
/// Processed file system event with metadata
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProcessedEvent {
|
||||
/// Path to the file or directory
|
||||
path: PathBuf,
|
||||
|
||||
/// Type of event
|
||||
kind: EventKind,
|
||||
|
||||
/// Priority of the event for processing order
|
||||
priority: EventPriority,
|
||||
|
||||
/// Time the event was received
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
/// Event debouncer for coalescing multiple events on the same file
|
||||
struct EventDebouncer {
|
||||
/// Map of file paths to their latest events
|
||||
events: HashMap<PathBuf, ProcessedEvent>,
|
||||
|
||||
/// Debounce window in milliseconds
|
||||
debounce_window_ms: u64,
|
||||
|
||||
/// Last time the debouncer was flushed
|
||||
last_flush: Instant,
|
||||
}
|
||||
|
||||
impl EventDebouncer {
|
||||
/// Create a new event debouncer with the specified debounce window
|
||||
fn new(debounce_window_ms: u64) -> Self {
|
||||
Self {
|
||||
events: HashMap::new(),
|
||||
debounce_window_ms,
|
||||
last_flush: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an event to the debouncer
|
||||
fn add_event(&mut self, event: ProcessedEvent) {
|
||||
let path = event.path.clone();
|
||||
|
||||
// Apply hierarchical debouncing: directory events override contained files
|
||||
if path.is_dir() {
|
||||
self.events.retain(|file_path, _| !file_path.starts_with(&path));
|
||||
}
|
||||
|
||||
// Update or insert the event for the file
|
||||
match self.events.get_mut(&path) {
|
||||
Some(existing) => {
|
||||
// Keep the higher priority event
|
||||
if event.priority < existing.priority {
|
||||
existing.priority = event.priority;
|
||||
}
|
||||
existing.timestamp = event.timestamp;
|
||||
existing.kind = event.kind;
|
||||
}
|
||||
None => {
|
||||
self.events.insert(path, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the debouncer is ready to flush events
|
||||
fn is_ready_to_flush(&self) -> bool {
|
||||
self.last_flush.elapsed() >= Duration::from_millis(self.debounce_window_ms)
|
||||
}
|
||||
|
||||
/// Flush all events, sorted by priority, and reset the debouncer
|
||||
fn flush(&mut self) -> Vec<ProcessedEvent> {
|
||||
let mut events: Vec<ProcessedEvent> = self.events.drain().map(|(_, e)| e).collect();
|
||||
events.sort_by_key(|e| e.priority);
|
||||
self.last_flush = Instant::now();
|
||||
events
|
||||
}
|
||||
|
||||
/// Get the number of events in the debouncer
|
||||
#[allow(dead_code)]
|
||||
fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Main file watcher implementation
|
||||
pub struct FileWatcher {
|
||||
/// Current state of the watcher
|
||||
state: Arc<Mutex<WatcherState>>,
|
||||
|
||||
/// Configuration for the watcher
|
||||
#[allow(dead_code)]
|
||||
config: WatcherConfig,
|
||||
|
||||
/// Paths being watched
|
||||
watched_paths: Vec<PathBuf>,
|
||||
|
||||
/// Notify event receiver (original receiver, clone is used in thread)
|
||||
#[allow(dead_code)]
|
||||
event_receiver: Receiver<std::result::Result<Event, notify::Error>>,
|
||||
|
||||
/// Notify watcher instance (must be kept alive for watching to continue)
|
||||
#[allow(dead_code)]
|
||||
watcher: RecommendedWatcher,
|
||||
|
||||
/// Event processor thread
|
||||
processor_thread: Option<JoinHandle<()>>,
|
||||
|
||||
/// Flag to signal the processor thread to stop
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
|
||||
/// Number of events processed
|
||||
events_processed: Arc<AtomicUsize>,
|
||||
|
||||
/// Current queue size
|
||||
queue_size: Arc<AtomicUsize>,
|
||||
|
||||
/// Start time of the watcher
|
||||
start_time: Instant,
|
||||
|
||||
/// Optional database connection, shared with the processor thread.
|
||||
db_shared: Arc<Mutex<Option<Arc<Mutex<Database>>>>>,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
/// Create a new file watcher for the given paths
|
||||
pub fn new(paths: Vec<PathBuf>, config: WatcherConfig) -> Result<Self> {
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
let events_processed = Arc::new(AtomicUsize::new(0));
|
||||
let queue_size = Arc::new(AtomicUsize::new(0));
|
||||
let state = Arc::new(Mutex::new(WatcherState::Initializing));
|
||||
|
||||
let (tx, rx) = bounded(config.max_queue_size);
|
||||
|
||||
let actual_watcher = notify::recommended_watcher(move |event_res| {
|
||||
if tx.send(event_res).is_err() {
|
||||
// eprintln!("Watcher: Failed to send event to channel (receiver likely dropped)");
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut mutable_watcher_ref = actual_watcher;
|
||||
for path in &paths {
|
||||
mutable_watcher_ref.watch(path, RecursiveMode::Recursive)?;
|
||||
}
|
||||
|
||||
let config_clone = config.clone();
|
||||
let stop_flag_clone = stop_flag.clone();
|
||||
let events_processed_clone = events_processed.clone();
|
||||
let queue_size_clone = queue_size.clone();
|
||||
let state_clone = state.clone();
|
||||
let receiver_clone = rx.clone();
|
||||
|
||||
// Correct initialization: Mutex protecting an Option, which starts as None.
|
||||
let db_shared_for_thread = Arc::new(Mutex::new(None::<Arc<Mutex<Database>>>));
|
||||
let db_captured_for_thread = db_shared_for_thread.clone();
|
||||
|
||||
|
||||
let processor_thread = thread::spawn(move || {
|
||||
let mut debouncer = EventDebouncer::new(config_clone.debounce_ms);
|
||||
|
||||
while !stop_flag_clone.load(Ordering::SeqCst) {
|
||||
{
|
||||
let state_guard = state_clone.lock().unwrap();
|
||||
if *state_guard == WatcherState::Paused {
|
||||
drop(state_guard);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(evt_res) = receiver_clone.try_recv() {
|
||||
match evt_res {
|
||||
Ok(event) => {
|
||||
for path in event.paths {
|
||||
let prio = match event.kind {
|
||||
EventKind::Create(_) => EventPriority::Create,
|
||||
EventKind::Remove(_) => EventPriority::Delete,
|
||||
EventKind::Modify(_) => EventPriority::Modify,
|
||||
EventKind::Access(_) => EventPriority::Access,
|
||||
_ => EventPriority::Modify,
|
||||
};
|
||||
debouncer.add_event(ProcessedEvent {
|
||||
path,
|
||||
kind: event.kind.clone(),
|
||||
priority: prio,
|
||||
timestamp: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("Watcher channel error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
queue_size_clone.store(debouncer.len(), Ordering::SeqCst);
|
||||
|
||||
if debouncer.is_ready_to_flush() && debouncer.len() > 0 {
|
||||
let evts = debouncer.flush();
|
||||
let num_evts = evts.len();
|
||||
events_processed_clone.fetch_add(num_evts, Ordering::SeqCst);
|
||||
|
||||
let db_opt_arc_guard = db_captured_for_thread.lock().unwrap();
|
||||
if let Some(db_arc) = &*db_opt_arc_guard {
|
||||
let _db_guard = db_arc.lock().unwrap();
|
||||
for event in &evts {
|
||||
println!("Processing event (DB available): {:?} for path {:?}", event.kind, event.path);
|
||||
}
|
||||
} else {
|
||||
for event in &evts {
|
||||
println!("Processing event (no DB): {:?} for path {:?}", event.kind, event.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
if debouncer.len() > 0 {
|
||||
let evts = debouncer.flush();
|
||||
events_processed_clone.fetch_add(evts.len(), Ordering::SeqCst);
|
||||
for processed_event in evts {
|
||||
println!("Processing final event: {:?} for path {:?}", processed_event.kind, processed_event.path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut state_guard = state_clone.lock().unwrap();
|
||||
*state_guard = WatcherState::Stopped;
|
||||
});
|
||||
|
||||
let watcher_instance = Self {
|
||||
state,
|
||||
config,
|
||||
watched_paths: paths,
|
||||
event_receiver: rx,
|
||||
watcher: mutable_watcher_ref,
|
||||
processor_thread: Some(processor_thread),
|
||||
stop_flag,
|
||||
events_processed,
|
||||
queue_size,
|
||||
start_time: Instant::now(),
|
||||
db_shared: db_shared_for_thread,
|
||||
};
|
||||
Ok(watcher_instance)
|
||||
}
|
||||
|
||||
/// Set the database connection for the watcher.
|
||||
pub fn with_database(&mut self, db_arc: Arc<Mutex<Database>>) -> &mut Self {
|
||||
{
|
||||
let mut shared_db_guard = self.db_shared.lock().unwrap();
|
||||
*shared_db_guard = Some(db_arc);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Start the file watcher.
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
if *state_guard == WatcherState::Watching || (*state_guard == WatcherState::Initializing && self.processor_thread.is_some()) {
|
||||
if *state_guard == WatcherState::Initializing {
|
||||
*state_guard = WatcherState::Watching;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
*state_guard = WatcherState::Watching;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause the watcher.
|
||||
pub fn pause(&mut self) -> Result<()> {
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
match *state_guard {
|
||||
WatcherState::Watching => {
|
||||
*state_guard = WatcherState::Paused;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Watcher not in watching state to pause")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a paused watcher.
|
||||
pub fn resume(&mut self) -> Result<()> {
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
match *state_guard {
|
||||
WatcherState::Paused => {
|
||||
*state_guard = WatcherState::Watching;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Watcher not in paused state to resume")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the watcher.
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
if *state_guard == WatcherState::Stopped || *state_guard == WatcherState::ShuttingDown {
|
||||
return Ok(());
|
||||
}
|
||||
*state_guard = WatcherState::ShuttingDown;
|
||||
drop(state_guard);
|
||||
|
||||
self.stop_flag.store(true, Ordering::SeqCst);
|
||||
if let Some(handle) = self.processor_thread.take() {
|
||||
match handle.join() {
|
||||
Ok(_) => (),
|
||||
Err(e) => eprintln!("Failed to join processor thread: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
let mut final_state_guard = self.state.lock().unwrap();
|
||||
*final_state_guard = WatcherState::Stopped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current status of the watcher.
|
||||
pub fn status(&self) -> WatcherStatus {
|
||||
let state_guard = self.state.lock().unwrap().clone();
|
||||
WatcherStatus {
|
||||
state: state_guard,
|
||||
events_processed: self.events_processed.load(Ordering::SeqCst),
|
||||
queue_size: self.queue_size.load(Ordering::SeqCst),
|
||||
start_time: Some(self.start_time),
|
||||
watched_paths: self.watched_paths.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FileWatcher {
|
||||
/// Ensure the watcher is stopped when dropped to prevent resource leaks.
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.stop() {
|
||||
eprintln!("Error stopping watcher in Drop: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
112
libmarlin/src/watcher_tests.rs
Normal file
112
libmarlin/src/watcher_tests.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Tests for the file system watcher functionality
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Updated import for BackupManager from the new backup module
|
||||
use crate::backup::BackupManager;
|
||||
// These are still from the watcher module
|
||||
use crate::watcher::{FileWatcher, WatcherConfig, WatcherState};
|
||||
use crate::db::open as open_marlin_db; // Use your project's DB open function
|
||||
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
// No longer need: use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_watcher_lifecycle() {
|
||||
// Create a temp directory for testing
|
||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
||||
let temp_path = temp_dir.path();
|
||||
|
||||
// Create a test file
|
||||
let test_file_path = temp_path.join("test.txt");
|
||||
let mut file = File::create(&test_file_path).expect("Failed to create test file");
|
||||
writeln!(file, "Test content").expect("Failed to write to test file");
|
||||
drop(file);
|
||||
|
||||
// Configure and start the watcher
|
||||
let config = WatcherConfig {
|
||||
debounce_ms: 100,
|
||||
batch_size: 10,
|
||||
max_queue_size: 100,
|
||||
drain_timeout_ms: 1000,
|
||||
};
|
||||
|
||||
let mut watcher = FileWatcher::new(vec![temp_path.to_path_buf()], config)
|
||||
.expect("Failed to create watcher");
|
||||
|
||||
watcher.start().expect("Failed to start watcher");
|
||||
assert_eq!(watcher.status().state, WatcherState::Watching);
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
let new_file_path = temp_path.join("new_file.txt");
|
||||
let mut new_file_handle = File::create(&new_file_path).expect("Failed to create new file");
|
||||
writeln!(new_file_handle, "New file content").expect("Failed to write to new file");
|
||||
drop(new_file_handle);
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
let mut existing_file_handle = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&test_file_path)
|
||||
.expect("Failed to open test file for modification");
|
||||
writeln!(existing_file_handle, "Additional content").expect("Failed to append to test file");
|
||||
drop(existing_file_handle);
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
fs::remove_file(&new_file_path).expect("Failed to remove file");
|
||||
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
watcher.stop().expect("Failed to stop watcher");
|
||||
|
||||
assert_eq!(watcher.status().state, WatcherState::Stopped);
|
||||
assert!(watcher.status().events_processed > 0, "Expected some file events to be processed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backup_manager_related_functionality() {
|
||||
let live_db_tmp_dir = tempdir().expect("Failed to create temp directory for live DB");
|
||||
let backups_storage_tmp_dir = tempdir().expect("Failed to create temp directory for backups storage");
|
||||
|
||||
let live_db_path = live_db_tmp_dir.path().join("test_live_watcher.db"); // Unique name
|
||||
let backups_actual_dir = backups_storage_tmp_dir.path().join("my_backups_watcher"); // Unique name
|
||||
|
||||
// Initialize a proper SQLite DB for the "live" database
|
||||
let _conn = open_marlin_db(&live_db_path).expect("Failed to open test_live_watcher.db for backup test");
|
||||
|
||||
let backup_manager = BackupManager::new(&live_db_path, &backups_actual_dir)
|
||||
.expect("Failed to create BackupManager instance");
|
||||
|
||||
let backup_info = backup_manager.create_backup().expect("Failed to create first backup");
|
||||
|
||||
assert!(backups_actual_dir.join(&backup_info.id).exists(), "Backup file should exist");
|
||||
assert!(backup_info.size_bytes > 0, "Backup size should be greater than 0");
|
||||
|
||||
for i in 0..3 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30)); // Ensure timestamp difference
|
||||
backup_manager.create_backup().unwrap_or_else(|e| panic!("Failed to create additional backup {}: {:?}", i, e));
|
||||
}
|
||||
|
||||
let backups = backup_manager.list_backups().expect("Failed to list backups");
|
||||
assert_eq!(backups.len(), 4, "Should have 4 backups listed");
|
||||
|
||||
let prune_result = backup_manager.prune(2).expect("Failed to prune backups");
|
||||
|
||||
assert_eq!(prune_result.kept.len(), 2, "Should have kept 2 backups");
|
||||
assert_eq!(prune_result.removed.len(), 2, "Should have removed 2 backups (4 initial - 2 kept)");
|
||||
|
||||
let remaining_backups = backup_manager.list_backups().expect("Failed to list backups after prune");
|
||||
assert_eq!(remaining_backups.len(), 2, "Should have 2 backups remaining after prune");
|
||||
|
||||
for removed_info in prune_result.removed {
|
||||
assert!(!backups_actual_dir.join(&removed_info.id).exists(), "Removed backup file {} should not exist", removed_info.id);
|
||||
}
|
||||
for kept_info in prune_result.kept {
|
||||
assert!(backups_actual_dir.join(&kept_info.id).exists(), "Kept backup file {} should exist", kept_info.id);
|
||||
}
|
||||
}
|
||||
}
|
464
run_all_tests.sh
Executable file
464
run_all_tests.sh
Executable file
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Comprehensive Marlin Test Script
|
||||
#
|
||||
# This script will:
|
||||
# 1. Clean up previous test artifacts.
|
||||
# 2. Build and install the Marlin CLI.
|
||||
# 3. Generate a new test corpus and demo directories.
|
||||
# 4. Run all automated tests (unit, integration, e2e).
|
||||
# 5. Run benchmark scripts.
|
||||
# 6. Execute steps from marlin_demo.md.
|
||||
# 7. Clean up generated test artifacts.
|
||||
|
||||
set -euo pipefail # Exit on error, undefined variable, or pipe failure
|
||||
IFS=$'\n\t' # Safer IFS
|
||||
|
||||
# --- Configuration ---
|
||||
MARLIN_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Assumes script is in repo root
|
||||
CARGO_TARGET_DIR_VALUE="${MARLIN_REPO_ROOT}/target" # Consistent target dir
|
||||
|
||||
# Test artifact locations
|
||||
TEST_BASE_DIR="${MARLIN_REPO_ROOT}/_test_artifacts" # Main directory for all test stuff
|
||||
DEMO_DIR="${TEST_BASE_DIR}/marlin_demo"
|
||||
CORPUS_DIR_BENCH="${MARLIN_REPO_ROOT}/bench/corpus" # Used by bench scripts
|
||||
CORPUS_DIR_SCRIPT="${TEST_BASE_DIR}/corpus_generated_by_script" # If script generates its own
|
||||
TEMP_DB_DIR="${TEST_BASE_DIR}/temp_dbs"
|
||||
|
||||
MARLIN_BIN_NAME="marlin"
|
||||
MARLIN_INSTALL_PATH="/usr/local/bin/${MARLIN_BIN_NAME}" # Adjust if you install elsewhere
|
||||
|
||||
# Colors for logging
|
||||
COLOR_GREEN='\033[0;32m'
|
||||
COLOR_YELLOW='\033[0;33m'
|
||||
COLOR_RED='\033[0;31m'
|
||||
COLOR_BLUE='\033[0;34m'
|
||||
COLOR_RESET='\033[0m'
|
||||
|
||||
# --- Helper Functions ---
|
||||
log_info() {
|
||||
echo -e "${COLOR_GREEN}[INFO]${COLOR_RESET} $1"
|
||||
}
|
||||
log_warn() {
|
||||
echo -e "${COLOR_YELLOW}[WARN]${COLOR_RESET} $1"
|
||||
}
|
||||
log_error() {
|
||||
echo -e "${COLOR_RED}[ERROR]${COLOR_RESET} $1" >&2
|
||||
}
|
||||
log_section() {
|
||||
echo -e "\n${COLOR_BLUE}>>> $1 <<<${COLOR_RESET}"
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
log_info "Executing: $*"
|
||||
"$@"
|
||||
local status=$?
|
||||
if [ $status -ne 0 ]; then
|
||||
log_error "Command failed with status $status: $*"
|
||||
# exit $status # Optional: exit immediately on any command failure
|
||||
fi
|
||||
return $status
|
||||
}
|
||||
|
||||
# Trap for cleanup
|
||||
cleanup_final() {
|
||||
log_section "Final Cleanup"
|
||||
log_info "Removing test artifacts directory: ${TEST_BASE_DIR}"
|
||||
rm -rf "${TEST_BASE_DIR}"
|
||||
# Note: bench/corpus might be left as it's part of the repo structure if not deleted by gen-corpus
|
||||
# If gen-corpus.sh always creates bench/corpus, then we can remove it here too.
|
||||
# For now, let's assume gen-corpus.sh handles its own target.
|
||||
if [ -d "${MARLIN_REPO_ROOT}/bench/index.db" ]; then
|
||||
log_info "Removing benchmark database: ${MARLIN_REPO_ROOT}/bench/index.db"
|
||||
rm -f "${MARLIN_REPO_ROOT}/bench/index.db"
|
||||
fi
|
||||
if [ -d "${MARLIN_REPO_ROOT}/bench/dirty-vs-full.md" ]; then
|
||||
log_info "Removing benchmark report: ${MARLIN_REPO_ROOT}/bench/dirty-vs-full.md"
|
||||
rm -f "${MARLIN_REPO_ROOT}/bench/dirty-vs-full.md"
|
||||
fi
|
||||
log_info "Cleanup complete."
|
||||
}
|
||||
trap 'cleanup_final' EXIT INT TERM
|
||||
|
||||
# --- Test Functions ---
|
||||
|
||||
initial_cleanup_and_setup_dirs() {
|
||||
log_section "Initial Cleanup and Directory Setup"
|
||||
if [ -d "${TEST_BASE_DIR}" ]; then
|
||||
log_info "Removing previous test artifacts: ${TEST_BASE_DIR}"
|
||||
rm -rf "${TEST_BASE_DIR}"
|
||||
fi
|
||||
run_cmd mkdir -p "${DEMO_DIR}"
|
||||
run_cmd mkdir -p "${CORPUS_DIR_SCRIPT}"
|
||||
run_cmd mkdir -p "${TEMP_DB_DIR}"
|
||||
log_info "Test directories created under ${TEST_BASE_DIR}"
|
||||
|
||||
# Cleanup existing benchmark corpus if gen-corpus.sh is expected to always create it
|
||||
if [ -d "${CORPUS_DIR_BENCH}" ]; then
|
||||
log_info "Removing existing benchmark corpus: ${CORPUS_DIR_BENCH}"
|
||||
rm -rf "${CORPUS_DIR_BENCH}"
|
||||
fi
|
||||
if [ -f "${MARLIN_REPO_ROOT}/bench/index.db" ]; then
|
||||
rm -f "${MARLIN_REPO_ROOT}/bench/index.db"
|
||||
fi
|
||||
if [ -f "${MARLIN_REPO_ROOT}/bench/dirty-vs-full.md" ]; then
|
||||
rm -f "${MARLIN_REPO_ROOT}/bench/dirty-vs-full.md"
|
||||
fi
|
||||
}
|
||||
|
||||
build_and_install_marlin() {
|
||||
log_section "Building and Installing Marlin"
|
||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR_VALUE}"
|
||||
log_info "CARGO_TARGET_DIR set to ${CARGO_TARGET_DIR}"
|
||||
|
||||
run_cmd cargo build --release --manifest-path "${MARLIN_REPO_ROOT}/cli-bin/Cargo.toml"
|
||||
|
||||
COMPILED_MARLIN_BIN="${CARGO_TARGET_DIR_VALUE}/release/${MARLIN_BIN_NAME}"
|
||||
if [ ! -f "${COMPILED_MARLIN_BIN}" ]; then
|
||||
log_error "Marlin binary not found at ${COMPILED_MARLIN_BIN} after build!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Installing Marlin to ${MARLIN_INSTALL_PATH} (requires sudo)"
|
||||
run_cmd sudo install -Dm755 "${COMPILED_MARLIN_BIN}" "${MARLIN_INSTALL_PATH}"
|
||||
# Alternative without sudo (if MARLIN_INSTALL_PATH is in user's PATH):
|
||||
# run_cmd cp "${COMPILED_MARLIN_BIN}" "${MARLIN_INSTALL_PATH}"
|
||||
# run_cmd chmod +x "${MARLIN_INSTALL_PATH}"
|
||||
log_info "Marlin installed."
|
||||
run_cmd "${MARLIN_INSTALL_PATH}" --version # Verify installation
|
||||
}
|
||||
|
||||
generate_test_data() {
|
||||
log_section "Generating Test Data"
|
||||
|
||||
log_info "Generating benchmark corpus..."
|
||||
# Ensure gen-corpus.sh targets the correct directory if it's configurable
|
||||
# Current gen-corpus.sh targets bench/corpus
|
||||
run_cmd bash "${MARLIN_REPO_ROOT}/bench/gen-corpus.sh"
|
||||
# If you want a separate corpus for other tests:
|
||||
# COUNT=100 TARGET="${CORPUS_DIR_SCRIPT}" run_cmd bash "${MARLIN_REPO_ROOT}/bench/gen-corpus.sh"
|
||||
|
||||
log_info "Setting up marlin_demo tree in ${DEMO_DIR}"
|
||||
mkdir -p "${DEMO_DIR}"/{Projects/{Alpha,Beta,Gamma},Logs,Reports,Scripts,Media/Photos}
|
||||
|
||||
cat <<EOF > "${DEMO_DIR}/Projects/Alpha/draft1.md"
|
||||
# Alpha draft 1
|
||||
- [ ] TODO: outline architecture
|
||||
- [ ] TODO: write tests
|
||||
EOF
|
||||
cat <<EOF > "${DEMO_DIR}/Projects/Alpha/draft2.md"
|
||||
# Alpha draft 2
|
||||
- [x] TODO: outline architecture
|
||||
- [ ] TODO: implement feature X
|
||||
EOF
|
||||
cat <<EOF > "${DEMO_DIR}/Projects/Beta/notes.md"
|
||||
Beta meeting notes:
|
||||
|
||||
- decided on roadmap
|
||||
- ACTION: follow-up with design team
|
||||
EOF
|
||||
cat <<EOF > "${DEMO_DIR}/Projects/Beta/final.md"
|
||||
# Beta Final
|
||||
All tasks complete. Ready to ship!
|
||||
EOF
|
||||
cat <<EOF > "${DEMO_DIR}/Projects/Gamma/TODO.txt"
|
||||
Gamma tasks:
|
||||
TODO: refactor module Y
|
||||
EOF
|
||||
echo "2025-05-15 12:00:00 INFO Starting app" > "${DEMO_DIR}/Logs/app.log"
|
||||
echo "2025-05-15 12:01:00 ERROR Oops, crash" >> "${DEMO_DIR}/Logs/app.log"
|
||||
echo "2025-05-15 00:00:00 INFO System check OK" > "${DEMO_DIR}/Logs/system.log"
|
||||
printf "Q1 financials\n" > "${DEMO_DIR}/Reports/Q1_report.pdf"
|
||||
cat <<'EOSH' > "${DEMO_DIR}/Scripts/deploy.sh"
|
||||
#!/usr/bin/env bash
|
||||
echo "Deploying version $1…"
|
||||
EOSH
|
||||
chmod +x "${DEMO_DIR}/Scripts/deploy.sh"
|
||||
echo "JPEGDATA" > "${DEMO_DIR}/Media/Photos/event.jpg"
|
||||
log_info "marlin_demo tree created."
|
||||
}
|
||||
|
||||
run_cargo_tests() {
|
||||
log_section "Running Cargo Tests (Unit & Integration)"
|
||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR_VALUE}" # Ensure it's set for test context too
|
||||
|
||||
run_cmd cargo test --all --manifest-path "${MARLIN_REPO_ROOT}/Cargo.toml" -- --nocapture
|
||||
# Individual test suites (already covered by --all, but can be run specifically)
|
||||
# run_cmd cargo test --test e2e --manifest-path "${MARLIN_REPO_ROOT}/cli-bin/Cargo.toml" -- --nocapture
|
||||
# run_cmd cargo test --test pos --manifest-path "${MARLIN_REPO_ROOT}/cli-bin/Cargo.toml" -- --nocapture
|
||||
# run_cmd cargo test --test neg --manifest-path "${MARLIN_REPO_ROOT}/cli-bin/Cargo.toml" -- --nocapture
|
||||
# run_cmd cargo test --test watcher_test --manifest-path "${MARLIN_REPO_ROOT}/cli-bin/Cargo.toml" -- --nocapture
|
||||
log_info "Cargo tests complete."
|
||||
}
|
||||
|
||||
run_benchmarks() {
|
||||
log_section "Running Benchmark Scripts"
|
||||
if ! command -v hyperfine &> /dev/null; then
|
||||
log_warn "hyperfine command not found. Skipping dirty-vs-full benchmark."
|
||||
return
|
||||
fi
|
||||
# Ensure MARLIN_BIN is set for the script, pointing to our freshly installed one or compiled one
|
||||
export MARLIN_BIN="${MARLIN_INSTALL_PATH}"
|
||||
# Or, if not installing system-wide:
|
||||
# export MARLIN_BIN="${CARGO_TARGET_DIR_VALUE}/release/${MARLIN_BIN_NAME}"
|
||||
|
||||
# The script itself sets MARLIN_DB_PATH to bench/index.db
|
||||
run_cmd bash "${MARLIN_REPO_ROOT}/bench/dirty-vs-full.sh"
|
||||
log_info "Benchmark script complete. Results in bench/dirty-vs-full.md"
|
||||
}
|
||||
|
||||
test_tui_stub() {
|
||||
log_section "Testing TUI Stub"
|
||||
local tui_bin="${CARGO_TARGET_DIR_VALUE}/release/marlin-tui"
|
||||
if [ ! -f "${tui_bin}" ]; then
|
||||
log_warn "Marlin TUI binary not found at ${tui_bin}. Building..."
|
||||
run_cmd cargo build --release --manifest-path "${MARLIN_REPO_ROOT}/tui-bin/Cargo.toml"
|
||||
fi
|
||||
|
||||
if [ -f "${tui_bin}" ]; then
|
||||
log_info "Running TUI stub..."
|
||||
# Check for expected output
|
||||
output=$("${tui_bin}" 2>&1)
|
||||
expected_output="marlin-tui is not yet implemented. Stay tuned!"
|
||||
if [[ "$output" == *"$expected_output"* ]]; then
|
||||
log_info "TUI stub output is correct."
|
||||
else
|
||||
log_error "TUI stub output mismatch. Expected: '$expected_output', Got: '$output'"
|
||||
fi
|
||||
else
|
||||
log_error "Marlin TUI binary still not found after attempt to build. Skipping TUI stub test."
|
||||
fi
|
||||
}
|
||||
|
||||
test_marlin_demo_flow() {
|
||||
log_section "Testing Marlin Demo Flow (docs/marlin_demo.md)"
|
||||
# This function will execute the commands from marlin_demo.md
|
||||
# It uses the MARLIN_INSTALL_PATH, assumes `marlin` is in PATH due to install
|
||||
# The demo uses a DB at DEMO_DIR/index.db by running init from DEMO_DIR
|
||||
|
||||
local marlin_cmd="${MARLIN_INSTALL_PATH}" # or just "marlin" if PATH is set
|
||||
local original_dir=$(pwd)
|
||||
|
||||
# Create a specific DB for this demo test, isolated from others
|
||||
local demo_db_path="${DEMO_DIR}/.marlin_index_demo.db"
|
||||
export MARLIN_DB_PATH="${demo_db_path}"
|
||||
log_info "Using demo-specific DB: ${MARLIN_DB_PATH}"
|
||||
|
||||
cd "${DEMO_DIR}" # Critical: init scans CWD
|
||||
|
||||
log_info "Running: ${marlin_cmd} init"
|
||||
run_cmd "${marlin_cmd}" init
|
||||
|
||||
log_info "Running tagging commands..."
|
||||
run_cmd "${marlin_cmd}" tag "${DEMO_DIR}/Projects/**/*.md" project/md
|
||||
run_cmd "${marlin_cmd}" tag "${DEMO_DIR}/Logs/**/*.log" logs/app
|
||||
run_cmd "${marlin_cmd}" tag "${DEMO_DIR}/Projects/Beta/**/*" project/beta
|
||||
|
||||
log_info "Running attribute commands..."
|
||||
run_cmd "${marlin_cmd}" attr set "${DEMO_DIR}/Projects/Beta/final.md" status complete
|
||||
run_cmd "${marlin_cmd}" attr set "${DEMO_DIR}/Reports/*.pdf" reviewed yes
|
||||
|
||||
log_info "Running search commands..."
|
||||
run_cmd "${marlin_cmd}" search TODO | grep "TODO.txt" || (log_error "Search TODO failed"; exit 1)
|
||||
run_cmd "${marlin_cmd}" search tag:project/md | grep "draft1.md" || (log_error "Search tag:project/md failed"; exit 1)
|
||||
run_cmd "${marlin_cmd}" search 'tag:logs/app AND ERROR' | grep "app.log" || (log_error "Search logs/app AND ERROR failed"; exit 1)
|
||||
run_cmd "${marlin_cmd}" search 'attr:status=complete' | grep "final.md" || (log_error "Search attr:status=complete failed"; exit 1)
|
||||
# Skipping --exec for automated script to avoid opening GUI
|
||||
# run_cmd "${marlin_cmd}" search 'attr:reviewed=yes' --exec 'echo {}'
|
||||
|
||||
log_info "Running backup and restore..."
|
||||
snap_output=$(run_cmd "${marlin_cmd}" backup)
|
||||
snap_file=$(echo "${snap_output}" | awk '{print $NF}')
|
||||
log_info "Backup created: ${snap_file}"
|
||||
|
||||
if [ -z "${MARLIN_DB_PATH}" ]; then
|
||||
log_error "MARLIN_DB_PATH is not set, cannot simulate disaster for restore test."
|
||||
elif [ ! -f "${MARLIN_DB_PATH}" ]; then
|
||||
log_error "MARLIN_DB_PATH (${MARLIN_DB_PATH}) does not point to a file."
|
||||
else
|
||||
log_info "Simulating disaster: removing ${MARLIN_DB_PATH}"
|
||||
rm -f "${MARLIN_DB_PATH}"
|
||||
# Also remove WAL/SHM files if they exist
|
||||
rm -f "${MARLIN_DB_PATH}-wal"
|
||||
rm -f "${MARLIN_DB_PATH}-shm"
|
||||
|
||||
log_info "Restoring from ${snap_file}"
|
||||
run_cmd "${marlin_cmd}" restore "${snap_file}"
|
||||
run_cmd "${marlin_cmd}" search TODO | grep "TODO.txt" || (log_error "Search TODO after restore failed"; exit 1)
|
||||
fi
|
||||
|
||||
|
||||
log_info "Running linking demo..."
|
||||
touch "${DEMO_DIR}/foo.txt" "${DEMO_DIR}/bar.txt"
|
||||
run_cmd "${marlin_cmd}" scan "${DEMO_DIR}" # Index new files
|
||||
|
||||
local foo_path="${DEMO_DIR}/foo.txt"
|
||||
local bar_path="${DEMO_DIR}/bar.txt"
|
||||
run_cmd "${marlin_cmd}" link add "${foo_path}" "${bar_path}" --type references
|
||||
run_cmd "${marlin_cmd}" link list "${foo_path}" | grep "bar.txt" || (log_error "Link list failed"; exit 1)
|
||||
run_cmd "${marlin_cmd}" link backlinks "${bar_path}" | grep "foo.txt" || (log_error "Link backlinks failed"; exit 1)
|
||||
|
||||
log_info "Running collections & smart views demo..."
|
||||
run_cmd "${marlin_cmd}" coll create SetA
|
||||
run_cmd "${marlin_cmd}" coll add SetA "${DEMO_DIR}/Projects/**/*.md"
|
||||
run_cmd "${marlin_cmd}" coll list SetA | grep "draft1.md" || (log_error "Coll list failed"; exit 1)
|
||||
|
||||
run_cmd "${marlin_cmd}" view save tasks 'attr:status=complete OR TODO'
|
||||
run_cmd "${marlin_cmd}" view exec tasks | grep "final.md" || (log_error "View exec tasks failed"; exit 1)
|
||||
|
||||
unset MARLIN_DB_PATH # Clean up env var for this specific test
|
||||
cd "${original_dir}"
|
||||
log_info "Marlin Demo Flow test complete."
|
||||
}
|
||||
|
||||
test_backup_prune_cli() {
|
||||
log_section "Testing Backup Pruning (CLI)"
|
||||
# This test assumes `marlin backup --prune N` is implemented in the CLI.
|
||||
# If not, it will likely fail or this section should be marked TODO.
|
||||
|
||||
local marlin_cmd="${MARLIN_INSTALL_PATH}"
|
||||
local backup_test_db_dir="${TEMP_DB_DIR}/backup_prune_test"
|
||||
mkdir -p "${backup_test_db_dir}"
|
||||
local test_db="${backup_test_db_dir}/test_prune.db"
|
||||
export MARLIN_DB_PATH="${test_db}"
|
||||
|
||||
log_info "Initializing DB for prune test at ${test_db}"
|
||||
run_cmd "${marlin_cmd}" init # Run from CWD to init DB at MARLIN_DB_PATH
|
||||
|
||||
local backup_storage_dir="${backup_test_db_dir}/backups" # Marlin creates backups next to the DB by default
|
||||
|
||||
log_info "Creating multiple backups..."
|
||||
for i in {1..7}; do
|
||||
run_cmd "${marlin_cmd}" backup > /dev/null # Suppress output for cleaner logs
|
||||
sleep 0.1 # Ensure unique timestamps if backups are very fast
|
||||
done
|
||||
|
||||
local num_backups_before_prune=$(ls -1 "${backup_storage_dir}" | grep -c "backup_.*\.db$" || echo 0)
|
||||
log_info "Number of backups before prune: ${num_backups_before_prune}"
|
||||
if [ "${num_backups_before_prune}" -lt 7 ]; then
|
||||
log_warn "Expected at least 7 backups, found ${num_backups_before_prune}. Prune test might be less effective."
|
||||
fi
|
||||
|
||||
# Check if `marlin backup --prune` exists in help output.
|
||||
# This is a basic check for CLI command availability.
|
||||
if ! "${marlin_cmd}" backup --help | grep -q "\-\-prune"; then
|
||||
log_warn "marlin backup --prune N does not seem to be an available CLI option."
|
||||
log_warn "Skipping CLI backup prune test. Implement it or update this test."
|
||||
unset MARLIN_DB_PATH
|
||||
return
|
||||
fi
|
||||
|
||||
log_info "Running: ${marlin_cmd} backup --prune 3"
|
||||
run_cmd "${marlin_cmd}" backup --prune 3 # This should create one more backup, then prune
|
||||
# leaving 3 newest (including the one just made).
|
||||
|
||||
local num_backups_after_prune=$(ls -1 "${backup_storage_dir}" | grep -c "backup_.*\.db$" || echo 0)
|
||||
log_info "Number of backups after prune: ${num_backups_after_prune}"
|
||||
|
||||
if [ "${num_backups_after_prune}" -eq 3 ]; then
|
||||
log_info "Backup prune CLI test successful: 3 backups remaining."
|
||||
else
|
||||
log_error "Backup prune CLI test FAILED: Expected 3 backups, found ${num_backups_after_prune}."
|
||||
fi
|
||||
unset MARLIN_DB_PATH
|
||||
}
|
||||
|
||||
test_watcher_cli_basic() {
|
||||
log_section "Testing Watcher CLI Basic Operation (Short Test)"
|
||||
# This is a very basic, short-running test for `marlin watch start`
|
||||
# A full stress test (8h) is a separate, longer process.
|
||||
|
||||
local marlin_cmd="${MARLIN_INSTALL_PATH}"
|
||||
local watch_test_dir="${TEMP_DB_DIR}/watch_cli_test_data"
|
||||
local watch_test_db="${TEMP_DB_DIR}/watch_cli_test.db"
|
||||
mkdir -p "${watch_test_dir}"
|
||||
export MARLIN_DB_PATH="${watch_test_db}"
|
||||
|
||||
log_info "Initializing DB for watcher test at ${watch_test_db}"
|
||||
run_cmd "${marlin_cmd}" init # Run from CWD for init
|
||||
|
||||
log_info "Starting watcher in background for 10 seconds..."
|
||||
# Run watcher in a subshell and kill it. Redirect output to a log file.
|
||||
local watcher_log="${TEST_BASE_DIR}/watcher_cli.log"
|
||||
( cd "${watch_test_dir}" && timeout 10s "${marlin_cmd}" watch start . --debounce-ms 50 &> "${watcher_log}" ) &
|
||||
local watcher_pid=$!
|
||||
|
||||
# Give watcher a moment to start
|
||||
sleep 2
|
||||
|
||||
log_info "Creating and modifying files in watched directory: ${watch_test_dir}"
|
||||
touch "${watch_test_dir}/file_created.txt"
|
||||
sleep 0.2
|
||||
echo "modified" > "${watch_test_dir}/file_created.txt"
|
||||
sleep 0.2
|
||||
mkdir "${watch_test_dir}/subdir"
|
||||
touch "${watch_test_dir}/subdir/file_in_subdir.txt"
|
||||
sleep 0.2
|
||||
rm "${watch_test_dir}/file_created.txt"
|
||||
|
||||
log_info "Waiting for watcher process (PID ${watcher_pid}) to finish (max 10s timeout)..."
|
||||
# wait ${watcher_pid} # This might hang if timeout doesn't kill cleanly
|
||||
# Instead, rely on the `timeout` command or send SIGINT/SIGTERM if needed.
|
||||
# For this test, the timeout command handles termination.
|
||||
# We need to ensure the watcher has time to process events before it's killed.
|
||||
sleep 5 # Allow time for events to be processed by the watcher
|
||||
|
||||
# The timeout should have killed the watcher. If not, try to kill it.
|
||||
if ps -p ${watcher_pid} > /dev/null; then
|
||||
log_warn "Watcher process ${watcher_pid} still running after timeout. Attempting to kill."
|
||||
kill ${watcher_pid} || true
|
||||
sleep 1
|
||||
kill -9 ${watcher_pid} || true
|
||||
fi
|
||||
|
||||
log_info "Watcher process should have terminated."
|
||||
log_info "Checking watcher log: ${watcher_log}"
|
||||
if [ -f "${watcher_log}" ]; then
|
||||
cat "${watcher_log}" # Display the log for debugging
|
||||
# Example checks on the log (these are basic, can be more specific)
|
||||
grep -q "CREATE" "${watcher_log}" && log_info "CREATE event found in log." || log_warn "CREATE event NOT found in log."
|
||||
grep -q "MODIFY" "${watcher_log}" && log_info "MODIFY event found in log." || log_warn "MODIFY event NOT found in log."
|
||||
grep -q "REMOVE" "${watcher_log}" && log_info "REMOVE event found in log." || log_warn "REMOVE event NOT found in log."
|
||||
else
|
||||
log_error "Watcher log file not found: ${watcher_log}"
|
||||
fi
|
||||
|
||||
# TODO: Add verification of DB state after watcher (e.g., file_changes table, new files indexed)
|
||||
# This would require querying the DB: sqlite3 "${watch_test_db}" "SELECT * FROM files;"
|
||||
|
||||
unset MARLIN_DB_PATH
|
||||
log_info "Watcher CLI basic test complete."
|
||||
}
|
||||
|
||||
|
||||
# --- Main Execution ---
|
||||
main() {
|
||||
log_section "Starting Marlin Comprehensive Test Suite"
|
||||
cd "${MARLIN_REPO_ROOT}" # Ensure we are in the repo root
|
||||
|
||||
initial_cleanup_and_setup_dirs
|
||||
build_and_install_marlin
|
||||
generate_test_data
|
||||
|
||||
run_cargo_tests
|
||||
run_benchmarks
|
||||
test_tui_stub
|
||||
test_marlin_demo_flow
|
||||
test_backup_prune_cli # Add more specific tests here
|
||||
test_watcher_cli_basic
|
||||
|
||||
# --- Add new test functions here ---
|
||||
# test_new_feature_x() {
|
||||
# log_section "Testing New Feature X"
|
||||
# # ... your test commands ...
|
||||
# }
|
||||
# test_new_feature_x
|
||||
|
||||
log_section "All Tests Executed"
|
||||
log_info "Review logs for any warnings or errors."
|
||||
}
|
||||
|
||||
# Run main
|
||||
main
|
||||
|
||||
# Cleanup is handled by the trap
|
@@ -1 +1 @@
|
||||
{"rustc_fingerprint":10768506583288887294,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.86.0 (05f9846f8 2025-03-31)\nbinary: rustc\ncommit-hash: 05f9846f893b09a1be1fc8560e33fc3c815cfecb\ncommit-date: 2025-03-31\nhost: x86_64-unknown-linux-gnu\nrelease: 1.86.0\nLLVM version: 19.1.7\n","stderr":""}},"successes":{}}
|
||||
{"rustc_fingerprint":490527502257410439,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.87.0 (17067e9ac 2025-05-09)\nbinary: rustc\ncommit-hash: 17067e9ac6d7ecb70e50f92c1944e545188d2359\ncommit-date: 2025-05-09\nhost: x86_64-unknown-linux-gnu\nrelease: 1.87.0\nLLVM version: 20.1.1\n","stderr":""}},"successes":{}}
|
Binary file not shown.
@@ -1 +1 @@
|
||||
/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/cli-bin/build.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/main.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/config.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0003_create_links_collections_views.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0005_add_dirty_table.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/lib.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/logging.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/scan.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/utils.rs
|
||||
/home/user/Documents/GitHub/Marlin/target/release/marlin: /home/user/Documents/GitHub/Marlin/cli-bin/build.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/annotate.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/coll.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/event.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/link.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/remind.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/state.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/task.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/version.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/view.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli/watch.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/cli.rs /home/user/Documents/GitHub/Marlin/cli-bin/src/main.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/backup.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/config.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/db/database.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0001_initial_schema.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0002_update_fts_and_triggers.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0003_create_links_collections_views.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0004_fix_hierarchical_tags_fts.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/migrations/0005_add_dirty_table.sql /home/user/Documents/GitHub/Marlin/libmarlin/src/db/mod.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/error.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/lib.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/logging.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/scan.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/utils.rs /home/user/Documents/GitHub/Marlin/libmarlin/src/watcher.rs
|
||||
|
Reference in New Issue
Block a user