mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-07 06:38:44 +00:00
Handle rename events
This commit is contained in:
@@ -22,3 +22,6 @@
|
||||
| `event add` | — |
|
||||
| `event timeline` | — |
|
||||
| `backup run` | --dir, --prune, --verify, --file |
|
||||
| `watch start` | --debounce-ms |
|
||||
| `watch status` | — |
|
||||
| `watch stop` | — |
|
||||
|
@@ -46,7 +46,7 @@
|
||||
| Tarpaulin coverage gate | S0 | — | – |
|
||||
| Watch mode (FS events) | Epic 1 | `marlin watch .` | DP‑002 |
|
||||
| Backup auto‑prune | Epic 1 | `backup --prune N` | – |
|
||||
| Rename/move tracking | Epic 2 | automatic path update | Spec‑RMH |
|
||||
| ~~Rename/move tracking~~ | Epic 2 | automatic path update | Spec‑RMH |
|
||||
| Dirty‑scan | Epic 2 | `scan --dirty` | DP‑002 |
|
||||
| Grep snippets | Phase 3 | `search -C3 …` | DP‑004 |
|
||||
| Hash / dedupe | Phase 4 | `scan --rehash` | DP‑005 |
|
||||
|
@@ -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> {
|
||||
|
@@ -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() {
|
||||
|
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user