From 33c3338144212db875f331063b6d7a65c9778166 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 18:30:34 +0200 Subject: [PATCH] feat(shares): scan a shared directory into an in-memory index Add a std-only shares module: Shares::scan walks the shared directory (skipping symlinks and unreadable entries) into a read-only snapshot keyed by peer-facing virtual path (root name + backslash-separated relative path). Exposes search (case-insensitive multi-term AND), get (O(1) by virtual path), and file/folder counts for the login handshake. Unit-tested against a temp tree. --- soulseek-rs-lib/src/lib.rs | 1 + soulseek-rs-lib/src/shares/mod.rs | 216 ++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 soulseek-rs-lib/src/shares/mod.rs diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index 0575565..3fc89e6 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -6,6 +6,7 @@ pub mod download_store; pub mod error; pub mod message; pub mod peer; +pub mod shares; pub mod types; #[macro_use] pub mod utils; diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs new file mode 100644 index 0000000..2e63878 --- /dev/null +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -0,0 +1,216 @@ +//! An in-memory index of the files we share with other peers. +//! +//! Scanning a directory produces a read-only [`Shares`] snapshot keyed by the +//! peer-facing *virtual path* (the shared directory's own name followed by the +//! backslash-separated relative path, matching the Soulseek wire convention). + +use std::collections::{HashMap, HashSet}; +use std::io; +use std::path::{Path, PathBuf}; + +/// One shared file: its peer-facing virtual path and where it lives on disk. +#[derive(Debug, Clone)] +pub struct SharedFile { + /// Backslash-separated path exposed to peers, e.g. `music\album\song.mp3`. + pub virtual_path: String, + /// The real filesystem path used to serve the bytes. + pub real_path: PathBuf, + pub size: u64, + /// Optional `(code, value)` audio attributes (empty for now). + pub attributes: Vec<(u32, u32)>, +} + +/// A read-only snapshot of the shared files, cheap to clone behind an `Arc`. +#[derive(Debug, Default)] +pub struct Shares { + files: Vec, + by_virtual: HashMap, + folder_count: usize, +} + +impl Shares { + /// An empty share set (nothing shared). + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Scan `root` recursively into a share index. Symlinks and unreadable + /// entries are skipped so a single bad entry never aborts the scan. + /// + /// # Errors + /// Returns an error only if `root` itself cannot be read. + pub fn scan(root: &Path) -> io::Result { + // Fail fast if the root is unreadable; deeper failures are skipped. + let _ = std::fs::read_dir(root)?; + + let root_name = root.file_name().map_or_else( + || "shared".to_string(), + |n| n.to_string_lossy().into_owned(), + ); + + let mut files = Vec::new(); + let mut folders_with_files: HashSet = HashSet::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { + continue; + }; + // `entry.metadata()` does not traverse the symlink itself. + if meta.file_type().is_symlink() { + continue; + } + let path = entry.path(); + if meta.is_dir() { + stack.push(path); + } else if meta.is_file() { + let virtual_path = + virtual_path_for(&root_name, root, &path); + files.push(SharedFile { + virtual_path, + real_path: path, + size: meta.len(), + attributes: Vec::new(), + }); + folders_with_files.insert(dir.clone()); + } + } + } + + let by_virtual = files + .iter() + .enumerate() + .map(|(i, f)| (f.virtual_path.clone(), i)) + .collect(); + + Ok(Self { + files, + by_virtual, + folder_count: folders_with_files.len(), + }) + } + + /// Files whose virtual path contains *every* whitespace-separated term of + /// `query` (case-insensitive). An empty query matches nothing. + #[must_use] + pub fn search(&self, query: &str) -> Vec<&SharedFile> { + let terms: Vec = query + .to_lowercase() + .split_whitespace() + .map(String::from) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + self.files + .iter() + .filter(|f| { + let haystack = f.virtual_path.to_lowercase(); + terms.iter().all(|t| haystack.contains(t.as_str())) + }) + .collect() + } + + /// Look up a shared file by its exact virtual path. + #[must_use] + pub fn get(&self, virtual_path: &str) -> Option<&SharedFile> { + self.by_virtual.get(virtual_path).map(|&i| &self.files[i]) + } + + /// All shared files, in scan order. + #[must_use] + pub fn files(&self) -> &[SharedFile] { + &self.files + } + + #[must_use] + pub const fn file_count(&self) -> u32 { + self.files.len() as u32 + } + + #[must_use] + pub const fn folder_count(&self) -> u32 { + self.folder_count as u32 + } +} + +/// Build the peer-facing virtual path for `path` under `root`: the root's own +/// name followed by the backslash-separated components relative to it. +fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String { + let rel = path.strip_prefix(root).unwrap_or(path); + let mut parts = vec![root_name.to_string()]; + for component in rel.components() { + parts.push(component.as_os_str().to_string_lossy().into_owned()); + } + parts.join("\\") +} + +#[cfg(test)] +mod tests { + use super::Shares; + + fn temp_tree() -> std::path::PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir() + .join(format!("soulseek-shares-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("album")).unwrap(); + std::fs::write(root.join("top.mp3"), b"aaaa").unwrap(); + std::fs::write(root.join("album").join("song one.flac"), b"bbbbbb") + .unwrap(); + std::fs::write(root.join("album").join("song two.flac"), b"cc") + .unwrap(); + root + } + + #[test] + fn scan_counts_files_and_folders() { + let root = temp_tree(); + let shares = Shares::scan(&root).unwrap(); + assert_eq!(shares.file_count(), 3); + // Two folders contain files: the root and `album`. + assert_eq!(shares.folder_count(), 2); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn nested_files_use_backslash_virtual_paths() { + let root = temp_tree(); + let base = root.file_name().unwrap().to_string_lossy().into_owned(); + let shares = Shares::scan(&root).unwrap(); + + let vpath = format!("{base}\\album\\song one.flac"); + let file = shares.get(&vpath).expect("nested file indexed"); + assert_eq!(file.size, 6); + assert!(shares.get("does\\not\\exist").is_none()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn search_is_case_insensitive_and_multi_term_and() { + let root = temp_tree(); + let shares = Shares::scan(&root).unwrap(); + + // Both album files match "SONG"; "song one" narrows to one. + assert_eq!(shares.search("SONG").len(), 2); + assert_eq!(shares.search("song ONE").len(), 1); + assert!(shares.search("nonexistent").is_empty()); + assert!(shares.search("").is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn empty_shares_have_no_files_or_folders() { + let shares = Shares::empty(); + assert_eq!(shares.file_count(), 0); + assert_eq!(shares.folder_count(), 0); + assert!(shares.search("anything").is_empty()); + } +}