Handle rename events

This commit is contained in:
thePR0M3TH3AN
2025-05-22 16:41:50 -04:00
parent 30922c70c0
commit 6b9d684b15
5 changed files with 367 additions and 24 deletions

View File

@@ -22,3 +22,6 @@
| `event add` | — |
| `event timeline` | — |
| `backup run` | --dir, --prune, --verify, --file |
| `watch start` | --debounce-ms |
| `watch status` | — |
| `watch stop` | — |

View File

@@ -46,7 +46,7 @@
| Tarpaulin coverage gate | S0 | | |
| Watch mode (FS events) | Epic1 | `marlin watch .` | DP002 |
| Backup autoprune | Epic1 | `backup --prune N` | |
| Rename/move tracking | Epic2 | automatic path update | SpecRMH |
| ~~Rename/move tracking~~ | Epic2 | automatic path update | SpecRMH |
| Dirtyscan | Epic2 | `scan --dirty` | DP002 |
| Grep snippets | Phase3 | `search -C3 …` | DP004 |
| Hash / dedupe | Phase4 | `scan --rehash` | DP005 |

View File

@@ -387,6 +387,39 @@ pub fn take_dirty(conn: &Connection) -> Result<Vec<i64>> {
Ok(ids)
}
/* ─── rename helpers ────────────────────────────────────────────── */
pub fn update_file_path(conn: &Connection, old_path: &str, new_path: &str) -> Result<()> {
let file_id: i64 = conn.query_row("SELECT id FROM files WHERE path = ?1", [old_path], |r| {
r.get(0)
})?;
conn.execute(
"UPDATE files SET path = ?1 WHERE id = ?2",
params![new_path, file_id],
)?;
mark_dirty(conn, file_id)?;
Ok(())
}
pub fn rename_directory(conn: &mut Connection, old_dir: &str, new_dir: &str) -> Result<()> {
let like_pattern = format!("{}/%", old_dir.trim_end_matches('/'));
let ids = {
let mut stmt = conn.prepare("SELECT id FROM files WHERE path LIKE ?1")?;
let rows = stmt.query_map([&like_pattern], |r| r.get::<_, i64>(0))?;
rows.collect::<StdResult<Vec<_>, _>>()?
};
let tx = conn.transaction()?;
tx.execute(
"UPDATE files SET path = REPLACE(path, ?1, ?2) WHERE path LIKE ?3",
params![old_dir, new_dir, like_pattern],
)?;
for fid in ids {
mark_dirty(&tx, fid)?;
}
tx.commit()?;
Ok(())
}
/* ─── backup / restore helpers ────────────────────────────────────── */
pub fn backup<P: AsRef<Path>>(db_path: P) -> Result<PathBuf> {

View File

@@ -6,10 +6,13 @@
//! (create, modify, delete) using the `notify` crate. It implements event debouncing,
//! batch processing, and a state machine for robust lifecycle management.
use crate::db::Database;
use crate::db::{self, Database};
use anyhow::{Context, Result};
use crossbeam_channel::{bounded, Receiver};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait};
use notify::{
event::{ModifyKind, RemoveKind, RenameMode},
Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcherTrait,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -76,6 +79,8 @@ enum EventPriority {
#[derive(Debug, Clone)]
struct ProcessedEvent {
path: PathBuf,
old_path: Option<PathBuf>,
new_path: Option<PathBuf>,
kind: EventKind,
priority: EventPriority,
timestamp: Instant,
@@ -151,6 +156,8 @@ mod event_debouncer_tests {
let path1 = PathBuf::from("file1.txt");
debouncer.add_event(ProcessedEvent {
path: path1.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
@@ -178,6 +185,8 @@ mod event_debouncer_tests {
let t1 = Instant::now();
debouncer.add_event(ProcessedEvent {
path: path1.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: t1,
@@ -186,6 +195,8 @@ mod event_debouncer_tests {
let t2 = Instant::now();
debouncer.add_event(ProcessedEvent {
path: path1.clone(),
old_path: None,
new_path: None,
kind: EventKind::Modify(ModifyKind::Data(DataChange::Any)),
priority: EventPriority::Modify,
timestamp: t2,
@@ -216,6 +227,8 @@ mod event_debouncer_tests {
debouncer_h.add_event(ProcessedEvent {
path: p_file.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
@@ -224,6 +237,8 @@ mod event_debouncer_tests {
debouncer_h.add_event(ProcessedEvent {
path: p_dir.clone(),
old_path: None,
new_path: None,
kind: EventKind::Remove(RemoveKind::Folder),
priority: EventPriority::Delete,
timestamp: Instant::now(),
@@ -248,12 +263,16 @@ mod event_debouncer_tests {
debouncer.add_event(ProcessedEvent {
path: path1.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: path2.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
@@ -273,18 +292,24 @@ mod event_debouncer_tests {
debouncer.add_event(ProcessedEvent {
path: path1,
old_path: None,
new_path: None,
kind: EventKind::Modify(ModifyKind::Name(RenameMode::To)),
priority: EventPriority::Modify,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: path2,
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: path3,
old_path: None,
new_path: None,
kind: EventKind::Remove(RemoveKind::File),
priority: EventPriority::Delete,
timestamp: Instant::now(),
@@ -316,12 +341,16 @@ mod event_debouncer_tests {
debouncer.add_event(ProcessedEvent {
path: dir.clone(),
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::Folder),
priority: EventPriority::Create,
timestamp: Instant::now(),
});
debouncer.add_event(ProcessedEvent {
path: file,
old_path: None,
new_path: None,
kind: EventKind::Create(CreateKind::File),
priority: EventPriority::Create,
timestamp: Instant::now(),
@@ -386,6 +415,8 @@ impl FileWatcher {
let processor_thread = thread::spawn(move || {
let mut debouncer = EventDebouncer::new(config_clone.debounce_ms);
let mut rename_cache: HashMap<usize, PathBuf> = HashMap::new();
let mut pending_remove: Option<(PathBuf, Instant)> = None;
while !stop_flag_clone.load(Ordering::Relaxed) {
let current_state = match state_clone.lock() {
@@ -396,7 +427,9 @@ impl FileWatcher {
}
};
if current_state == WatcherState::Paused {
if current_state == WatcherState::Paused
|| current_state == WatcherState::Initializing
{
thread::sleep(Duration::from_millis(100));
continue;
}
@@ -411,20 +444,145 @@ impl FileWatcher {
received_in_batch += 1;
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,
priority: prio,
timestamp: Instant::now(),
});
let prio = match event.kind {
EventKind::Create(_) => EventPriority::Create,
EventKind::Remove(_) => EventPriority::Delete,
EventKind::Modify(_) => EventPriority::Modify,
EventKind::Access(_) => EventPriority::Access,
_ => EventPriority::Modify,
};
match event.kind {
EventKind::Remove(_) if event.paths.len() == 1 => {
pending_remove = Some((event.paths[0].clone(), Instant::now()));
}
EventKind::Create(_) if event.paths.len() == 1 => {
if let Some((old_p, ts)) = pending_remove.take() {
if Instant::now().duration_since(ts)
<= Duration::from_millis(500)
{
let new_p = event.paths[0].clone();
debouncer.add_event(ProcessedEvent {
path: old_p.clone(),
old_path: Some(old_p),
new_path: Some(new_p),
kind: EventKind::Modify(ModifyKind::Name(
RenameMode::Both,
)),
priority: prio,
timestamp: Instant::now(),
});
continue;
} else {
debouncer.add_event(ProcessedEvent {
path: old_p.clone(),
old_path: None,
new_path: None,
kind: EventKind::Remove(RemoveKind::Any),
priority: EventPriority::Delete,
timestamp: ts,
});
}
}
for path in event.paths {
debouncer.add_event(ProcessedEvent {
path,
old_path: None,
new_path: None,
kind: event.kind,
priority: prio,
timestamp: Instant::now(),
});
}
}
EventKind::Modify(ModifyKind::Name(mode)) => match mode {
RenameMode::Both => {
if event.paths.len() >= 2 {
let old_path = event.paths[0].clone();
let new_path = event.paths[1].clone();
debouncer.add_event(ProcessedEvent {
path: old_path.clone(),
old_path: Some(old_path),
new_path: Some(new_path),
kind: EventKind::Modify(ModifyKind::Name(
RenameMode::Both,
)),
priority: prio,
timestamp: Instant::now(),
});
}
}
RenameMode::From => {
if let (Some(trk), Some(p)) =
(event.tracker(), event.paths.first())
{
rename_cache.insert(trk, p.clone());
}
for path in event.paths {
debouncer.add_event(ProcessedEvent {
path,
old_path: None,
new_path: None,
kind: event.kind,
priority: prio,
timestamp: Instant::now(),
});
}
}
RenameMode::To => {
if let (Some(trk), Some(new_p)) =
(event.tracker(), event.paths.first())
{
if let Some(old_p) = rename_cache.remove(&trk) {
debouncer.add_event(ProcessedEvent {
path: old_p.clone(),
old_path: Some(old_p),
new_path: Some(new_p.clone()),
kind: EventKind::Modify(ModifyKind::Name(
RenameMode::Both,
)),
priority: prio,
timestamp: Instant::now(),
});
continue;
}
}
for path in event.paths {
debouncer.add_event(ProcessedEvent {
path,
old_path: None,
new_path: None,
kind: event.kind,
priority: prio,
timestamp: Instant::now(),
});
}
}
_ => {
for path in event.paths {
debouncer.add_event(ProcessedEvent {
path,
old_path: None,
new_path: None,
kind: event.kind,
priority: prio,
timestamp: Instant::now(),
});
}
}
},
_ => {
for path in event.paths {
debouncer.add_event(ProcessedEvent {
path,
old_path: None,
new_path: None,
kind: event.kind,
priority: prio,
timestamp: Instant::now(),
});
}
}
}
}
Err(e) => {
@@ -436,6 +594,21 @@ impl FileWatcher {
}
}
if let Some((old_p, ts)) = pending_remove.take() {
if Instant::now().duration_since(ts) > Duration::from_millis(500) {
debouncer.add_event(ProcessedEvent {
path: old_p.clone(),
old_path: None,
new_path: None,
kind: EventKind::Remove(RemoveKind::Any),
priority: EventPriority::Delete,
timestamp: ts,
});
} else {
pending_remove = Some((old_p, ts));
}
}
queue_size_clone.store(debouncer.len(), Ordering::SeqCst);
if debouncer.is_ready_to_flush() && debouncer.len() > 0 {
@@ -451,8 +624,32 @@ impl FileWatcher {
}
};
if let Some(db_mutex) = &*db_guard_option {
if let Ok(mut _db_instance_guard) = db_mutex.lock() {
if let Ok(mut db_inst) = db_mutex.lock() {
for event_item in &evts_to_process {
if let EventKind::Modify(ModifyKind::Name(_)) = event_item.kind {
if let (Some(ref old_p), Some(ref new_p)) =
(&event_item.old_path, &event_item.new_path)
{
let old_str = old_p.to_string_lossy();
let new_str = new_p.to_string_lossy();
let res = if new_p.is_dir() {
db::rename_directory(
db_inst.conn_mut(),
&old_str,
&new_str,
)
} else {
db::update_file_path(
db_inst.conn_mut(),
&old_str,
&new_str,
)
};
if let Err(e) = res {
eprintln!("DB rename error: {:?}", e);
}
}
}
info!(
"Processing event (DB available): {:?} for path {:?}",
event_item.kind, event_item.path
@@ -476,11 +673,38 @@ impl FileWatcher {
if debouncer.len() > 0 {
let final_evts = debouncer.flush();
events_processed_clone.fetch_add(final_evts.len(), Ordering::SeqCst);
for processed_event in final_evts {
info!(
"Processing final event: {:?} for path {:?}",
processed_event.kind, processed_event.path
);
if let Some(db_mutex) = &*db_captured_for_thread.lock().unwrap() {
if let Ok(mut db_inst) = db_mutex.lock() {
for processed_event in &final_evts {
if let EventKind::Modify(ModifyKind::Name(_)) = processed_event.kind {
if let (Some(ref old_p), Some(ref new_p)) =
(&processed_event.old_path, &processed_event.new_path)
{
let old_str = old_p.to_string_lossy();
let new_str = new_p.to_string_lossy();
let res = if new_p.is_dir() {
db::rename_directory(db_inst.conn_mut(), &old_str, &new_str)
} else {
db::update_file_path(db_inst.conn_mut(), &old_str, &new_str)
};
if let Err(e) = res {
eprintln!("DB rename error: {:?}", e);
}
}
}
info!(
"Processing final event: {:?} for path {:?}",
processed_event.kind, processed_event.path
);
}
}
} else {
for processed_event in final_evts {
info!(
"Processing final event: {:?} for path {:?}",
processed_event.kind, processed_event.path
);
}
}
}
if let Ok(mut final_state_guard) = state_clone.lock() {

View File

@@ -7,6 +7,7 @@ mod tests {
// These are still from the watcher module
use crate::db::open as open_marlin_db;
use crate::watcher::{FileWatcher, WatcherConfig, WatcherState}; // Use your project's DB open function
use crate::Marlin;
use std::fs::{self, File};
use std::io::Write;
@@ -144,4 +145,86 @@ mod tests {
);
}
}
#[test]
fn rename_file_updates_db() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let file = dir.join("a.txt");
fs::write(&file, b"hi").unwrap();
let db_path = dir.join("test.db");
let mut marlin = Marlin::open_at(&db_path).unwrap();
marlin.scan(&[dir]).unwrap();
let mut watcher = marlin
.watch(
dir,
Some(WatcherConfig {
debounce_ms: 50,
..Default::default()
}),
)
.unwrap();
thread::sleep(Duration::from_millis(100));
let new_file = dir.join("b.txt");
fs::rename(&file, &new_file).unwrap();
thread::sleep(Duration::from_millis(200));
watcher.stop().unwrap();
let count: i64 = marlin
.conn()
.query_row(
"SELECT COUNT(*) FROM files WHERE path = ?1",
[new_file.to_string_lossy()],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn rename_directory_updates_children() {
let tmp = tempdir().unwrap();
let dir = tmp.path();
let sub = dir.join("old");
fs::create_dir(&sub).unwrap();
let f1 = sub.join("one.txt");
fs::write(&f1, b"1").unwrap();
let f2 = sub.join("two.txt");
fs::write(&f2, b"2").unwrap();
let db_path = dir.join("test2.db");
let mut marlin = Marlin::open_at(&db_path).unwrap();
marlin.scan(&[dir]).unwrap();
let mut watcher = marlin
.watch(
dir,
Some(WatcherConfig {
debounce_ms: 50,
..Default::default()
}),
)
.unwrap();
thread::sleep(Duration::from_millis(100));
let new = dir.join("newdir");
fs::rename(&sub, &new).unwrap();
thread::sleep(Duration::from_millis(300));
watcher.stop().unwrap();
for fname in ["one.txt", "two.txt"] {
let p = new.join(fname);
let cnt: i64 = marlin
.conn()
.query_row(
"SELECT COUNT(*) FROM files WHERE path = ?1",
[p.to_string_lossy()],
|r| r.get(0),
)
.unwrap();
assert_eq!(cnt, 1, "{} missing", p.display());
}
}
}