diff --git a/cli-bin/docs/cli_cheatsheet.md b/cli-bin/docs/cli_cheatsheet.md index 402fe7f..c259009 100644 --- a/cli-bin/docs/cli_cheatsheet.md +++ b/cli-bin/docs/cli_cheatsheet.md @@ -22,3 +22,6 @@ | `event add` | — | | `event timeline` | — | | `backup run` | --dir, --prune, --verify, --file | +| `watch start` | --debounce-ms | +| `watch status` | — | +| `watch stop` | — | diff --git a/docs/roadmap.md b/docs/roadmap.md index b493c72..a76b52b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 | diff --git a/libmarlin/src/db/mod.rs b/libmarlin/src/db/mod.rs index 4ee4241..39a9996 100644 --- a/libmarlin/src/db/mod.rs +++ b/libmarlin/src/db/mod.rs @@ -387,6 +387,39 @@ pub fn take_dirty(conn: &Connection) -> Result> { 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::, _>>()? + }; + 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>(db_path: P) -> Result { diff --git a/libmarlin/src/watcher.rs b/libmarlin/src/watcher.rs index ed9089b..b50e1f3 100644 --- a/libmarlin/src/watcher.rs +++ b/libmarlin/src/watcher.rs @@ -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, + new_path: Option, 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 = 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() { diff --git a/libmarlin/src/watcher_tests.rs b/libmarlin/src/watcher_tests.rs index 53ceb82..445b84e 100644 --- a/libmarlin/src/watcher_tests.rs +++ b/libmarlin/src/watcher_tests.rs @@ -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()); + } + } }