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:
thePR0M3TH3AN
2025-05-19 18:14:42 -04:00
parent 6125acb4d1
commit 2f97bd8c3f
23 changed files with 2567 additions and 50 deletions

View 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
View File

@@ -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]]

View File

@@ -1,4 +1,5 @@
[workspace]
resolver = "2"
members = [
"libmarlin",
"cli-bin",

View File

@@ -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 |

View File

@@ -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"

View File

@@ -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
View 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(())
}
}
}

View File

@@ -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(())

View 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();
}

View File

@@ -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 youve got a 5-second upgrade-and-verify loop.

View 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*

View File

@@ -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
View 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);
}
}
}

View 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())
}
}

View File

@@ -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
View 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())
}
}

View File

@@ -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)
}
/// Fulltext 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
View 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);
}
}
}

View 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
View 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

View File

@@ -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.

View File

@@ -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