feat(fuse): implement rm with virtual .trash/ directory
- Add trashed/original_path/trashed_at columns to files table - Implement FUSE unlink: moves files to /.trash/ preserving path structure - Implement FUSE rmdir: removes empty directories - Add trash CLI commands: list, restore, empty - Add SIGHUP handler for CLI-triggered restore - Fix upsert_file returning 0 on UPDATE (query actual ID) - Auto-clear trashed flag when moving files out of /.trash/
This commit is contained in:
@@ -145,9 +145,19 @@ impl Database {
|
||||
.map_err(|e| Error::Database(format!("upsert failed: {}", e)))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
debug!(id, vpath = virtual_path.as_str(), "Upserted file");
|
||||
let file_id = if id == 0 {
|
||||
conn.query_row(
|
||||
"SELECT id FROM files WHERE origin_id = ?1 AND real_path = ?2",
|
||||
params![&origin_id.0, real_path.to_string_lossy()],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("failed to get file id after upsert: {}", e)))?
|
||||
} else {
|
||||
id
|
||||
};
|
||||
debug!(id = file_id, vpath = virtual_path.as_str(), "Upserted file");
|
||||
|
||||
Ok(FileId(id))
|
||||
Ok(FileId(file_id))
|
||||
}
|
||||
|
||||
pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||
@@ -424,6 +434,140 @@ impl Database {
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn mark_trashed(&self, id: FileId, original_path: &VirtualPath) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = conn
|
||||
.execute(
|
||||
"UPDATE files SET trashed = 1, original_path = ?1, trashed_at = strftime('%s', 'now') WHERE id = ?2",
|
||||
params![original_path.as_str(), id.0],
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("mark_trashed failed: {}", e)))?;
|
||||
|
||||
if rows == 0 {
|
||||
return Err(Error::FileNotFound(format!("file id {} not found", id.0)));
|
||||
}
|
||||
debug!(
|
||||
id = id.0,
|
||||
original_path = original_path.as_str(),
|
||||
"marked file as trashed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unmark_trashed(&self, id: FileId) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE files SET trashed = 0, original_path = NULL, trashed_at = NULL WHERE id = ?1",
|
||||
params![id.0],
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("unmark_trashed failed: {}", e)))?;
|
||||
debug!(id = id.0, "unmarked file as trashed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_trashed(&self, filter: &TrashedFilter) -> Result<Vec<TrashedFile>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mut sql = String::from(
|
||||
"SELECT id, virtual_path, original_path, trashed_at, origin_id FROM files WHERE trashed = 1",
|
||||
);
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref origin) = filter.origin_id {
|
||||
sql.push_str(" AND origin_id = ?");
|
||||
params_vec.push(Box::new(origin.0.clone()));
|
||||
}
|
||||
|
||||
if let Some(ref prefix) = filter.path_prefix {
|
||||
sql.push_str(" AND original_path LIKE ?");
|
||||
params_vec.push(Box::new(format!("{}%", prefix)));
|
||||
}
|
||||
|
||||
if let Some(since) = filter.since {
|
||||
let cutoff = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
- since.as_secs() as i64;
|
||||
sql.push_str(" AND trashed_at >= ?");
|
||||
params_vec.push(Box::new(cutoff));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY trashed_at DESC");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let files: Vec<TrashedFile> = stmt
|
||||
.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(TrashedFile {
|
||||
file_id: FileId(row.get(0)?),
|
||||
current_path: VirtualPath::new(row.get::<_, String>(1)?),
|
||||
original_path: VirtualPath::new(row.get::<_, String>(2)?),
|
||||
trashed_at: row.get(3)?,
|
||||
origin_id: OriginId(row.get(4)?),
|
||||
})
|
||||
})
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn get_trashed_by_prefix(&self, prefix: &str) -> Result<Vec<TrashedFile>> {
|
||||
self.list_trashed(&TrashedFilter {
|
||||
path_prefix: Some(prefix.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_trashed(&self, path: &VirtualPath) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM files WHERE virtual_path = ?1 AND trashed = 1",
|
||||
params![path.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| Error::Database(format!("is_trashed query failed: {}", e)))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn purge_trashed(&self, filter: &TrashedFilter) -> Result<u64> {
|
||||
let trashed = self.list_trashed(filter)?;
|
||||
let count = trashed.len() as u64;
|
||||
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for file in trashed {
|
||||
conn.execute("DELETE FROM files WHERE id = ?1", params![file.file_id.0])
|
||||
.map_err(|e| Error::Database(format!("purge delete failed: {}", e)))?;
|
||||
}
|
||||
|
||||
debug!(count, "purged trashed files");
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrashedFile {
|
||||
pub file_id: FileId,
|
||||
pub current_path: VirtualPath,
|
||||
pub original_path: VirtualPath,
|
||||
pub trashed_at: i64,
|
||||
pub origin_id: OriginId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TrashedFilter {
|
||||
pub origin_id: Option<OriginId>,
|
||||
pub path_prefix: Option<String>,
|
||||
pub since: Option<Duration>,
|
||||
}
|
||||
|
||||
fn parse_audio_format(s: &str) -> AudioFormat {
|
||||
@@ -782,4 +926,124 @@ mod tests {
|
||||
let files = db.get_files_by_prefix("/Other/").unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_trashed() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Artist/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac"))
|
||||
.unwrap();
|
||||
|
||||
let trashed = db.list_trashed(&TrashedFilter::default()).unwrap();
|
||||
assert_eq!(trashed.len(), 1);
|
||||
assert_eq!(trashed[0].original_path.as_str(), "/Artist/Track.flac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unmark_trashed() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Artist/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.mark_trashed(id, &VirtualPath::new("/Artist/Track.flac"))
|
||||
.unwrap();
|
||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1);
|
||||
|
||||
db.unmark_trashed(id).unwrap();
|
||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_trashed_with_filter() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
let origin1 = OriginId::from("local1");
|
||||
let origin2 = OriginId::from("local2");
|
||||
|
||||
let id1 = db
|
||||
.upsert_file(
|
||||
&origin1,
|
||||
Path::new("/a.flac"),
|
||||
&VirtualPath::new("/Artist1/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let id2 = db
|
||||
.upsert_file(
|
||||
&origin2,
|
||||
Path::new("/b.flac"),
|
||||
&VirtualPath::new("/Artist2/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.mark_trashed(id1, &VirtualPath::new("/Artist1/Track.flac"))
|
||||
.unwrap();
|
||||
db.mark_trashed(id2, &VirtualPath::new("/Artist2/Track.flac"))
|
||||
.unwrap();
|
||||
|
||||
let all = db.list_trashed(&TrashedFilter::default()).unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
|
||||
let filtered = db
|
||||
.list_trashed(&TrashedFilter {
|
||||
origin_id: Some(origin1.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].origin_id, origin1);
|
||||
|
||||
let by_path = db.get_trashed_by_prefix("/Artist1").unwrap();
|
||||
assert_eq!(by_path.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trashed() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let id = db
|
||||
.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Track.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.mark_trashed(id, &VirtualPath::new("/Track.flac"))
|
||||
.unwrap();
|
||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 1);
|
||||
|
||||
let count = db.purge_trashed(&TrashedFilter::default()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(db.list_trashed(&TrashedFilter::default()).unwrap().len(), 0);
|
||||
assert_eq!(db.file_count().unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ mod prefetch;
|
||||
mod tree;
|
||||
|
||||
pub use artwork::{ArtworkCache, ArtworkError, CachedArtwork};
|
||||
pub use db::Database;
|
||||
pub use db::{Database, TrashedFile, TrashedFilter};
|
||||
pub use eviction::{EvictionError, EvictionPolicy, LruEviction};
|
||||
pub use metadata::MetadataCache;
|
||||
pub use patterns::{AccessContext, AccessPattern, PatternError, PatternStore};
|
||||
pub use prefetch::{PrefetchConfig, PrefetchEngine, PrefetchHandle};
|
||||
pub use tree::{
|
||||
DirNode, FileNode, Inode, RefreshPolicy, RenameError, TreeBuilder, VirtualNode, VirtualTree,
|
||||
ROOT_INODE,
|
||||
DirNode, FileNode, Inode, RefreshPolicy, RemoveError, RenameError, TreeBuilder, VirtualNode,
|
||||
VirtualTree, ROOT_INODE,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS files (
|
||||
chunk_manifest BLOB,
|
||||
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
original_path TEXT,
|
||||
trashed_at INTEGER,
|
||||
|
||||
UNIQUE(origin_id, real_path)
|
||||
);
|
||||
|
||||
@@ -64,3 +68,4 @@ CREATE TABLE IF NOT EXISTS directories (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(trashed) WHERE trashed = 1;
|
||||
|
||||
@@ -571,6 +571,207 @@ impl VirtualTree {
|
||||
);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn is_trash_path(path: &VirtualPath) -> bool {
|
||||
path.as_str().starts_with("/.trash") || path.as_str() == "/.trash"
|
||||
}
|
||||
|
||||
pub fn ensure_trash_dir(&mut self) -> Inode {
|
||||
let trash_path = VirtualPath::new("/.trash");
|
||||
if let Some(&inode) = self.path_to_inode.get(&trash_path) {
|
||||
return inode;
|
||||
}
|
||||
|
||||
let inode = self.alloc_inode();
|
||||
let dir_node = DirNode {
|
||||
inode,
|
||||
parent: ROOT_INODE,
|
||||
name: OsString::from(".trash"),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
};
|
||||
|
||||
self.nodes.insert(inode, VirtualNode::Directory(dir_node));
|
||||
self.path_to_inode.insert(trash_path, inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) {
|
||||
root.children.insert(OsString::from(".trash"), inode);
|
||||
}
|
||||
|
||||
debug!(inode, "created .trash directory");
|
||||
inode
|
||||
}
|
||||
|
||||
pub fn mkdir_p(&mut self, path: &VirtualPath) -> std::result::Result<Inode, RenameError> {
|
||||
if let Some(&existing) = self.path_to_inode.get(path) {
|
||||
if self
|
||||
.nodes
|
||||
.get(&existing)
|
||||
.map(|n| n.is_dir())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
return Err(RenameError::TargetExists);
|
||||
}
|
||||
|
||||
let components: Vec<&str> = path
|
||||
.as_str()
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let mut current_inode = ROOT_INODE;
|
||||
let mut current_path = String::from("/");
|
||||
|
||||
for component in &components {
|
||||
if !current_path.ends_with('/') {
|
||||
current_path.push('/');
|
||||
}
|
||||
current_path.push_str(component);
|
||||
|
||||
let vpath = VirtualPath::new(¤t_path);
|
||||
|
||||
if let Some(&existing) = self.path_to_inode.get(&vpath) {
|
||||
current_inode = existing;
|
||||
} else {
|
||||
let new_inode = self.alloc_inode();
|
||||
let name = OsString::from(*component);
|
||||
|
||||
let dir_node = DirNode {
|
||||
inode: new_inode,
|
||||
parent: current_inode,
|
||||
name: name.clone(),
|
||||
children: BTreeMap::new(),
|
||||
mtime: SystemTime::now(),
|
||||
};
|
||||
|
||||
self.nodes
|
||||
.insert(new_inode, VirtualNode::Directory(dir_node));
|
||||
self.path_to_inode.insert(vpath, new_inode);
|
||||
|
||||
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) {
|
||||
parent.children.insert(name, new_inode);
|
||||
}
|
||||
|
||||
current_inode = new_inode;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current_inode)
|
||||
}
|
||||
|
||||
pub fn remove_directory(&mut self, path: &VirtualPath) -> std::result::Result<(), RemoveError> {
|
||||
let inode = self
|
||||
.path_to_inode
|
||||
.get(path)
|
||||
.copied()
|
||||
.ok_or(RemoveError::NotFound)?;
|
||||
|
||||
let node = self.nodes.get(&inode).ok_or(RemoveError::NotFound)?;
|
||||
|
||||
match node {
|
||||
VirtualNode::File(_) => return Err(RemoveError::NotDirectory),
|
||||
VirtualNode::Directory(dir) => {
|
||||
if !dir.children.is_empty() {
|
||||
return Err(RemoveError::NotEmpty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let parent_path = std::path::Path::new(path.as_str())
|
||||
.parent()
|
||||
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| VirtualPath::new("/"));
|
||||
|
||||
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
|
||||
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) {
|
||||
let name = std::path::Path::new(path.as_str())
|
||||
.file_name()
|
||||
.map(|n| n.to_os_string())
|
||||
.unwrap_or_default();
|
||||
parent.children.remove(&name);
|
||||
}
|
||||
}
|
||||
|
||||
self.path_to_inode.remove(path);
|
||||
self.nodes.remove(&inode);
|
||||
|
||||
debug!(path = path.as_str(), inode, "removed directory");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_directory_recursive(
|
||||
&mut self,
|
||||
path: &VirtualPath,
|
||||
) -> std::result::Result<Vec<FileId>, RemoveError> {
|
||||
let inode = self
|
||||
.path_to_inode
|
||||
.get(path)
|
||||
.copied()
|
||||
.ok_or(RemoveError::NotFound)?;
|
||||
|
||||
if !self.nodes.get(&inode).map(|n| n.is_dir()).unwrap_or(false) {
|
||||
return Err(RemoveError::NotDirectory);
|
||||
}
|
||||
|
||||
let prefix = path.as_str();
|
||||
let paths_to_remove: Vec<(VirtualPath, Inode)> = self
|
||||
.path_to_inode
|
||||
.iter()
|
||||
.filter(|(p, _)| p.as_str().starts_with(prefix))
|
||||
.map(|(p, &i)| (p.clone(), i))
|
||||
.collect();
|
||||
|
||||
let mut removed_files = Vec::new();
|
||||
|
||||
for (p, ino) in &paths_to_remove {
|
||||
if let Some(VirtualNode::File(f)) = self.nodes.get(ino) {
|
||||
removed_files.push(f.file_id);
|
||||
}
|
||||
self.path_to_inode.remove(p);
|
||||
self.nodes.remove(ino);
|
||||
}
|
||||
|
||||
let parent_path = std::path::Path::new(path.as_str())
|
||||
.parent()
|
||||
.map(|p| VirtualPath::new(p.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| VirtualPath::new("/"));
|
||||
|
||||
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
|
||||
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(&parent_inode) {
|
||||
let name = std::path::Path::new(path.as_str())
|
||||
.file_name()
|
||||
.map(|n| n.to_os_string())
|
||||
.unwrap_or_default();
|
||||
parent.children.remove(&name);
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
path = path.as_str(),
|
||||
file_count = removed_files.len(),
|
||||
"removed directory recursively"
|
||||
);
|
||||
Ok(removed_files)
|
||||
}
|
||||
|
||||
pub fn is_directory_empty(&self, path: &VirtualPath) -> Option<bool> {
|
||||
let inode = self.path_to_inode.get(path)?;
|
||||
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(inode) {
|
||||
Some(dir.children.is_empty())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RemoveError {
|
||||
NotFound,
|
||||
NotEmpty,
|
||||
NotDirectory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -889,4 +1090,135 @@ mod tests {
|
||||
|
||||
assert_eq!(result, Err(RenameError::TargetExists));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_trash_path() {
|
||||
assert!(VirtualTree::is_trash_path(&VirtualPath::new("/.trash")));
|
||||
assert!(VirtualTree::is_trash_path(&VirtualPath::new(
|
||||
"/.trash/Artist/Track.flac"
|
||||
)));
|
||||
assert!(!VirtualTree::is_trash_path(&VirtualPath::new(
|
||||
"/Artist/Track.flac"
|
||||
)));
|
||||
assert!(!VirtualTree::is_trash_path(&VirtualPath::new(
|
||||
"/trash/Artist/Track.flac"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_trash_dir() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/.trash")).is_none());
|
||||
|
||||
let inode = tree.ensure_trash_dir();
|
||||
assert!(inode > ROOT_INODE);
|
||||
|
||||
let node = tree.get_by_path(&VirtualPath::new("/.trash"));
|
||||
assert!(node.is_some());
|
||||
assert!(node.unwrap().is_dir());
|
||||
|
||||
let inode2 = tree.ensure_trash_dir();
|
||||
assert_eq!(inode, inode2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mkdir_p() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap();
|
||||
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A")).is_some());
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A/B")).is_some());
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some());
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mkdir_p_partial_exists() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
tree.mkdir(&VirtualPath::new("/A")).unwrap();
|
||||
tree.mkdir(&VirtualPath::new("/A/B")).unwrap();
|
||||
|
||||
tree.mkdir_p(&VirtualPath::new("/A/B/C/D")).unwrap();
|
||||
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C")).is_some());
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/A/B/C/D")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_empty() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
tree.mkdir(&VirtualPath::new("/EmptyDir")).unwrap();
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_some());
|
||||
|
||||
tree.remove_directory(&VirtualPath::new("/EmptyDir"))
|
||||
.unwrap();
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/EmptyDir")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_not_empty() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Track.flac"));
|
||||
|
||||
let result = tree.remove_directory(&VirtualPath::new("/Artist"));
|
||||
assert_eq!(result, Err(RemoveError::NotEmpty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_not_found() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
let result = tree.remove_directory(&VirtualPath::new("/NonExistent"));
|
||||
assert_eq!(result, Err(RemoveError::NotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_is_file() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Track.flac"));
|
||||
|
||||
let result = tree.remove_directory(&VirtualPath::new("/Track.flac"));
|
||||
assert_eq!(result, Err(RemoveError::NotDirectory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_recursive() {
|
||||
let mut tree = VirtualTree::new();
|
||||
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac"));
|
||||
tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac"));
|
||||
tree.insert_file(&make_file_meta(3, "/Artist/Other/Track3.flac"));
|
||||
|
||||
let removed = tree
|
||||
.remove_directory_recursive(&VirtualPath::new("/Artist"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(removed.len(), 3);
|
||||
assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_directory_empty() {
|
||||
let mut tree = VirtualTree::new();
|
||||
|
||||
tree.mkdir(&VirtualPath::new("/Empty")).unwrap();
|
||||
assert_eq!(
|
||||
tree.is_directory_empty(&VirtualPath::new("/Empty")),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
tree.insert_file(&make_file_meta(1, "/NonEmpty/Track.flac"));
|
||||
assert_eq!(
|
||||
tree.is_directory_empty(&VirtualPath::new("/NonEmpty")),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tree.is_directory_empty(&VirtualPath::new("/NonExistent")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user