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..73d7d23 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, 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(), @@ -411,20 +440,38 @@ 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, - }; + let prio = match event.kind { + EventKind::Create(_) => EventPriority::Create, + EventKind::Remove(_) => EventPriority::Delete, + EventKind::Modify(_) => EventPriority::Modify, + EventKind::Access(_) => EventPriority::Access, + _ => EventPriority::Modify, + }; + + if matches!(event.kind, EventKind::Modify(ModifyKind::Name(_))) + && event.paths.len() >= 2 + { + let old_path = event.paths[0].clone(); + let new_path = event.paths[1].clone(); debouncer.add_event(ProcessedEvent { - path, + path: old_path.clone(), + old_path: Some(old_path), + new_path: Some(new_path), kind: event.kind, priority: prio, timestamp: Instant::now(), }); + } else { + 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) => { @@ -451,8 +498,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 +547,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()); + } + } }