2026-03-13 17:50:28 +00:00
|
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
2026-03-13 18:35:26 +00:00
|
|
|
use std::os::unix::fs::MetadataExt;
|
2026-03-13 17:50:28 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
|
|
|
|
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
|
|
|
|
use tokio::sync::{broadcast, RwLock};
|
|
|
|
|
use tracing::{debug, info, warn};
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
use furumi_common::proto::{AttrResponse, ChangeEvent, ChangeKind, DirEntry};
|
2026-03-13 17:50:28 +00:00
|
|
|
|
|
|
|
|
/// How many directory levels to pre-walk on startup.
|
|
|
|
|
const INITIAL_DEPTH: u32 = 3;
|
|
|
|
|
|
|
|
|
|
/// Broadcast channel capacity — clients that fall behind lose events and rely on TTL.
|
|
|
|
|
const BROADCAST_CAPACITY: usize = 256;
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
// ── Types ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// One directory in the snapshot: its entries and the attr for each child.
|
|
|
|
|
/// `children` and `child_attrs` are parallel slices (same index = same file).
|
|
|
|
|
/// `dir_attr` is the attr of the directory itself.
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct SnapDir {
|
|
|
|
|
pub children: Vec<DirEntry>,
|
|
|
|
|
pub child_attrs: Vec<AttrResponse>,
|
|
|
|
|
pub dir_attr: AttrResponse,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
// ── WatchedTree ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Maintains an in-memory snapshot of the directory tree and broadcasts
|
|
|
|
|
/// change events to connected clients via inotify.
|
|
|
|
|
pub struct WatchedTree {
|
2026-03-13 18:35:26 +00:00
|
|
|
/// Virtual-path → (entries + attrs).
|
|
|
|
|
snapshot: Arc<RwLock<HashMap<String, SnapDir>>>,
|
2026-03-13 17:50:28 +00:00
|
|
|
change_tx: broadcast::Sender<ChangeEvent>,
|
2026-03-13 18:00:43 +00:00
|
|
|
/// Kept alive to continue watching. Shared with the event handler so it can
|
|
|
|
|
/// add new watches when directories are created at runtime.
|
|
|
|
|
_watcher: Arc<Mutex<RecommendedWatcher>>,
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WatchedTree {
|
|
|
|
|
pub async fn new(root: PathBuf) -> anyhow::Result<Self> {
|
2026-03-13 18:35:26 +00:00
|
|
|
let snapshot: Arc<RwLock<HashMap<String, SnapDir>>> =
|
2026-03-13 17:50:28 +00:00
|
|
|
Arc::new(RwLock::new(HashMap::new()));
|
|
|
|
|
let (change_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
|
|
|
|
|
2026-03-13 18:23:26 +00:00
|
|
|
info!("WatchedTree: walking '{}' (depth {})…", root.display(), INITIAL_DEPTH);
|
|
|
|
|
let t = std::time::Instant::now();
|
2026-03-13 18:00:43 +00:00
|
|
|
let watched_dirs = walk_tree(&root, INITIAL_DEPTH, &snapshot).await;
|
2026-03-13 18:23:26 +00:00
|
|
|
let snap_len = snapshot.read().await.len();
|
2026-03-13 18:35:26 +00:00
|
|
|
let total_entries: usize = snapshot.read().await.values().map(|d| d.children.len()).sum();
|
2026-03-13 17:50:28 +00:00
|
|
|
info!(
|
2026-03-13 18:35:26 +00:00
|
|
|
"WatchedTree: snapshot ready — {} dirs, {} entries, {} watches, took {:.1}s",
|
2026-03-13 18:23:26 +00:00
|
|
|
snap_len,
|
2026-03-13 18:35:26 +00:00
|
|
|
total_entries,
|
2026-03-13 18:00:43 +00:00
|
|
|
watched_dirs.len(),
|
2026-03-13 18:23:26 +00:00
|
|
|
t.elapsed().as_secs_f32(),
|
2026-03-13 17:50:28 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Bridge notify's sync callback → async tokio task.
|
|
|
|
|
let (notify_tx, mut notify_rx) =
|
|
|
|
|
tokio::sync::mpsc::unbounded_channel::<notify::Result<notify::Event>>();
|
2026-03-13 18:00:43 +00:00
|
|
|
let watcher = Arc::new(Mutex::new(RecommendedWatcher::new(
|
2026-03-13 17:50:28 +00:00
|
|
|
move |res| {
|
|
|
|
|
let _ = notify_tx.send(res);
|
|
|
|
|
},
|
|
|
|
|
Config::default(),
|
2026-03-13 18:00:43 +00:00
|
|
|
)?));
|
|
|
|
|
|
|
|
|
|
// Add one non-recursive inotify watch per directory in the snapshot.
|
|
|
|
|
{
|
|
|
|
|
let mut w = watcher.lock().unwrap();
|
|
|
|
|
for dir_abs in &watched_dirs {
|
|
|
|
|
if let Err(e) = w.watch(dir_abs, RecursiveMode::NonRecursive) {
|
|
|
|
|
warn!("watch failed for {:?}: {}", dir_abs, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-13 17:50:28 +00:00
|
|
|
|
|
|
|
|
let snapshot_bg = Arc::clone(&snapshot);
|
|
|
|
|
let root_bg = root.clone();
|
|
|
|
|
let tx_bg = change_tx.clone();
|
2026-03-13 18:00:43 +00:00
|
|
|
let watcher_bg = Arc::clone(&watcher);
|
2026-03-13 17:50:28 +00:00
|
|
|
tokio::spawn(async move {
|
|
|
|
|
while let Some(res) = notify_rx.recv().await {
|
|
|
|
|
match res {
|
2026-03-13 18:00:43 +00:00
|
|
|
Ok(event) => {
|
|
|
|
|
handle_fs_event(event, &root_bg, &snapshot_bg, &tx_bg, &watcher_bg).await
|
|
|
|
|
}
|
2026-03-13 17:50:28 +00:00
|
|
|
Err(e) => warn!("notify error: {}", e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
snapshot,
|
|
|
|
|
change_tx,
|
2026-03-13 18:00:43 +00:00
|
|
|
_watcher: watcher,
|
2026-03-13 17:50:28 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
/// Returns all snapshot entries within `depth` levels of `base`.
|
|
|
|
|
pub async fn get_snapshot(&self, base: &str, depth: u32) -> Vec<(String, SnapDir)> {
|
2026-03-13 17:50:28 +00:00
|
|
|
let snap = self.snapshot.read().await;
|
|
|
|
|
snap.iter()
|
2026-03-13 18:00:43 +00:00
|
|
|
.filter(|(path, _)| path_depth_from(base, path).map_or(false, |d| d <= depth))
|
2026-03-13 18:35:26 +00:00
|
|
|
.map(|(path, snap_dir)| (path.clone(), snap_dir.clone()))
|
2026-03-13 17:50:28 +00:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn subscribe(&self) -> broadcast::Receiver<ChangeEvent> {
|
|
|
|
|
self.change_tx.subscribe()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
fn metadata_to_attr(meta: &std::fs::Metadata) -> AttrResponse {
|
|
|
|
|
AttrResponse {
|
|
|
|
|
size: meta.len(),
|
|
|
|
|
mode: meta.mode(),
|
|
|
|
|
mtime: meta.mtime() as u64,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// BFS walk: reads filesystem, stores entries + attrs in snapshot.
|
|
|
|
|
/// Returns the list of absolute paths walked (for inotify setup).
|
2026-03-13 17:50:28 +00:00
|
|
|
async fn walk_tree(
|
|
|
|
|
root: &Path,
|
|
|
|
|
max_depth: u32,
|
2026-03-13 18:35:26 +00:00
|
|
|
snapshot: &Arc<RwLock<HashMap<String, SnapDir>>>,
|
2026-03-13 18:00:43 +00:00
|
|
|
) -> Vec<PathBuf> {
|
|
|
|
|
let mut walked: Vec<PathBuf> = Vec::new();
|
2026-03-13 17:50:28 +00:00
|
|
|
let mut queue: VecDeque<(PathBuf, String, u32)> = VecDeque::new();
|
|
|
|
|
queue.push_back((root.to_path_buf(), "/".to_string(), 0));
|
|
|
|
|
|
|
|
|
|
while let Some((abs_path, virt_path, depth)) = queue.pop_front() {
|
2026-03-13 18:35:26 +00:00
|
|
|
// Stat the directory itself.
|
|
|
|
|
let dir_attr = match std::fs::metadata(&abs_path) {
|
|
|
|
|
Ok(m) => metadata_to_attr(&m),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("walk_tree: cannot stat {:?}: {}", abs_path, e);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
let mut dir = match tokio::fs::read_dir(&abs_path).await {
|
|
|
|
|
Ok(d) => d,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!("walk_tree: cannot read {:?}: {}", abs_path, e);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
let mut children: Vec<DirEntry> = Vec::new();
|
|
|
|
|
let mut child_attrs: Vec<AttrResponse> = Vec::new();
|
2026-03-13 17:50:28 +00:00
|
|
|
|
|
|
|
|
while let Ok(Some(entry)) = dir.next_entry().await {
|
2026-03-13 18:35:26 +00:00
|
|
|
let Ok(ft) = entry.file_type().await else { continue };
|
|
|
|
|
let type_val = if ft.is_dir() { 4 } else if ft.is_file() { 8 } else { continue };
|
2026-03-13 17:50:28 +00:00
|
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
// Stat the child.
|
|
|
|
|
let attr = match entry.metadata().await {
|
|
|
|
|
Ok(m) => metadata_to_attr(&m),
|
|
|
|
|
Err(_) => AttrResponse { size: 0, mode: 0, mtime: 0 },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Skip hidden directories to avoid exploding the watch list.
|
2026-03-13 18:00:43 +00:00
|
|
|
if ft.is_dir() && name.starts_with('.') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
if ft.is_dir() && depth < max_depth {
|
|
|
|
|
let child_virt = child_virt_path(&virt_path, &name);
|
|
|
|
|
queue.push_back((entry.path(), child_virt, depth + 1));
|
|
|
|
|
}
|
2026-03-13 18:35:26 +00:00
|
|
|
|
|
|
|
|
children.push(DirEntry { name, r#type: type_val });
|
|
|
|
|
child_attrs.push(attr);
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
snapshot.write().await.insert(virt_path, SnapDir { children, child_attrs, dir_attr });
|
2026-03-13 18:00:43 +00:00
|
|
|
walked.push(abs_path);
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
2026-03-13 18:00:43 +00:00
|
|
|
|
|
|
|
|
walked
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-reads a single directory and updates its snapshot entry.
|
|
|
|
|
async fn refresh_dir(
|
|
|
|
|
abs_path: &Path,
|
|
|
|
|
virt_path: &str,
|
2026-03-13 18:35:26 +00:00
|
|
|
snapshot: &Arc<RwLock<HashMap<String, SnapDir>>>,
|
2026-03-13 17:50:28 +00:00
|
|
|
) {
|
2026-03-13 18:35:26 +00:00
|
|
|
let dir_attr = match std::fs::metadata(abs_path) {
|
|
|
|
|
Ok(m) => metadata_to_attr(&m),
|
|
|
|
|
Err(_) => {
|
|
|
|
|
snapshot.write().await.remove(virt_path);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
let mut dir = match tokio::fs::read_dir(abs_path).await {
|
|
|
|
|
Ok(d) => d,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
snapshot.write().await.remove(virt_path);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 18:35:26 +00:00
|
|
|
let mut children: Vec<DirEntry> = Vec::new();
|
|
|
|
|
let mut child_attrs: Vec<AttrResponse> = Vec::new();
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
while let Ok(Some(entry)) = dir.next_entry().await {
|
2026-03-13 18:35:26 +00:00
|
|
|
let Ok(ft) = entry.file_type().await else { continue };
|
2026-03-13 17:50:28 +00:00
|
|
|
let type_val = if ft.is_dir() { 4 } else if ft.is_file() { 8 } else { continue };
|
2026-03-13 18:35:26 +00:00
|
|
|
let attr = match entry.metadata().await {
|
|
|
|
|
Ok(m) => metadata_to_attr(&m),
|
|
|
|
|
Err(_) => AttrResponse { size: 0, mode: 0, mtime: 0 },
|
|
|
|
|
};
|
|
|
|
|
children.push(DirEntry {
|
2026-03-13 17:50:28 +00:00
|
|
|
name: entry.file_name().to_string_lossy().into_owned(),
|
|
|
|
|
r#type: type_val,
|
|
|
|
|
});
|
2026-03-13 18:35:26 +00:00
|
|
|
child_attrs.push(attr);
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
2026-03-13 18:35:26 +00:00
|
|
|
|
|
|
|
|
snapshot.write().await.insert(virt_path.to_string(), SnapDir { children, child_attrs, dir_attr });
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_fs_event(
|
|
|
|
|
event: notify::Event,
|
|
|
|
|
root: &Path,
|
2026-03-13 18:35:26 +00:00
|
|
|
snapshot: &Arc<RwLock<HashMap<String, SnapDir>>>,
|
2026-03-13 17:50:28 +00:00
|
|
|
tx: &broadcast::Sender<ChangeEvent>,
|
2026-03-13 18:00:43 +00:00
|
|
|
watcher: &Arc<Mutex<RecommendedWatcher>>,
|
2026-03-13 17:50:28 +00:00
|
|
|
) {
|
|
|
|
|
use notify::EventKind;
|
|
|
|
|
|
|
|
|
|
let proto_kind = match &event.kind {
|
|
|
|
|
EventKind::Create(_) => ChangeKind::Created,
|
|
|
|
|
EventKind::Remove(_) => ChangeKind::Deleted,
|
|
|
|
|
EventKind::Modify(_) => ChangeKind::Modified,
|
|
|
|
|
_ => return,
|
|
|
|
|
};
|
|
|
|
|
let kind_i32 = proto_kind as i32;
|
|
|
|
|
|
2026-03-13 18:00:43 +00:00
|
|
|
if matches!(event.kind, EventKind::Create(_)) {
|
|
|
|
|
for path in &event.paths {
|
2026-03-13 18:35:26 +00:00
|
|
|
if path.is_dir()
|
|
|
|
|
&& !path.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.'))
|
|
|
|
|
{
|
2026-03-13 18:00:43 +00:00
|
|
|
let mut w = watcher.lock().unwrap();
|
|
|
|
|
if let Err(e) = w.watch(path, RecursiveMode::NonRecursive) {
|
|
|
|
|
warn!("failed to add watch for new dir {:?}: {}", path, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 17:50:28 +00:00
|
|
|
let mut parents: HashSet<PathBuf> = HashSet::new();
|
|
|
|
|
for path in &event.paths {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
if parent.starts_with(root) {
|
|
|
|
|
parents.insert(parent.to_path_buf());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for parent_abs in parents {
|
|
|
|
|
let virt = abs_to_virt(root, &parent_abs);
|
|
|
|
|
debug!("snapshot refresh: {}", virt);
|
|
|
|
|
refresh_dir(&parent_abs, &virt, snapshot).await;
|
|
|
|
|
let _ = tx.send(ChangeEvent { path: virt, kind: kind_i32 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn abs_to_virt(root: &Path, abs: &Path) -> String {
|
|
|
|
|
match abs.strip_prefix(root) {
|
|
|
|
|
Ok(rel) if rel.as_os_str().is_empty() => "/".to_string(),
|
|
|
|
|
Ok(rel) => format!("/{}", rel.to_string_lossy()),
|
|
|
|
|
Err(_) => "/".to_string(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn child_virt_path(parent: &str, name: &str) -> String {
|
2026-03-13 18:35:26 +00:00
|
|
|
if parent == "/" { format!("/{}", name) } else { format!("{}/{}", parent, name) }
|
2026-03-13 17:50:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn path_depth_from(base: &str, path: &str) -> Option<u32> {
|
|
|
|
|
if base == "/" {
|
2026-03-13 18:35:26 +00:00
|
|
|
if path == "/" { return Some(0); }
|
2026-03-13 17:50:28 +00:00
|
|
|
let trimmed = path.trim_start_matches('/');
|
|
|
|
|
Some(trimmed.matches('/').count() as u32 + 1)
|
|
|
|
|
} else {
|
2026-03-13 18:35:26 +00:00
|
|
|
if path == base { return Some(0); }
|
2026-03-13 17:50:28 +00:00
|
|
|
let prefix = format!("{}/", base);
|
|
|
|
|
path.strip_prefix(prefix.as_str())
|
|
|
|
|
.map(|rest| rest.matches('/').count() as u32 + 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_path_depth_from_root() {
|
|
|
|
|
assert_eq!(path_depth_from("/", "/"), Some(0));
|
|
|
|
|
assert_eq!(path_depth_from("/", "/a"), Some(1));
|
|
|
|
|
assert_eq!(path_depth_from("/", "/a/b"), Some(2));
|
|
|
|
|
assert_eq!(path_depth_from("/", "/a/b/c"), Some(3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_path_depth_from_subdir() {
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/movies"), Some(0));
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/movies/action"), Some(1));
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/movies/action/marvel"), Some(2));
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/music"), None);
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/movies-extra"), None);
|
|
|
|
|
assert_eq!(path_depth_from("/movies", "/"), None);
|
|
|
|
|
}
|
|
|
|
|
}
|