Add VirtualFs tag-mode WebDAV + MyFiles UI + Admin WebDAV endpoint
- VirtualFs: SQLite-backed virtual folders (tag mode), 16 unit tests - MyFiles module: API endpoints + Web UI for folder/tag management - Admin WebDAV: /admin-webdav/*path with Basic Auth + URI prefix rewrite - CLI: webdav-folder/tag/untag/list/start --virtual-mode commands - Deployed and tested on M5Max48: PROPFIND, PUT, GET, DELETE all working
This commit is contained in:
@@ -9,6 +9,7 @@ pub mod smb_fs;
|
||||
#[cfg(feature = "smb-server")]
|
||||
pub mod smb_server_backend;
|
||||
pub mod util;
|
||||
pub mod virtual_fs;
|
||||
#[cfg(feature = "async-vfs")]
|
||||
pub mod async_fs;
|
||||
#[cfg(feature = "async-vfs")]
|
||||
|
||||
737
markbase-core/src/vfs/virtual_fs.rs
Normal file
737
markbase-core/src/vfs/virtual_fs.rs
Normal file
@@ -0,0 +1,737 @@
|
||||
use super::local_fs::LocalFs;
|
||||
use super::open_flags::OpenFlags;
|
||||
use super::{VfsBackend, VfsDirEntry, VfsError, VfsFile, VfsStat};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS virtual_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
UNIQUE(filename, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_file_tags_tag ON file_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_tags_filename ON file_tags(filename);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webdav_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
|
||||
|
||||
pub struct VirtualFs {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
backend: Box<dyn VfsBackend>,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
fn normalize_folder(folder: &str) -> String {
|
||||
let f = folder.trim_start_matches('/');
|
||||
format!("/{}", f.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
fn folder_to_tag(folder: &str) -> String {
|
||||
folder.trim_matches('/').to_string()
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl VirtualFs {
|
||||
pub fn new(db_path: &str, root: PathBuf) -> Result<Self, VfsError> {
|
||||
let conn = Connection::open(db_path).map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
let backend = Box::new(LocalFs::new());
|
||||
Ok(Self {
|
||||
db: Arc::new(Mutex::new(conn)),
|
||||
backend,
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_folders(&self) -> Vec<String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = match db.prepare("SELECT folder FROM virtual_folders ORDER BY folder") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.ok();
|
||||
rows.map(|r| r.filter_map(|e| e.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn files_with_tag(&self, tag: &str) -> Vec<String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = match db.prepare("SELECT filename FROM file_tags WHERE tag = ?1 ORDER BY filename") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map(params![tag], |row| row.get::<_, String>(0))
|
||||
.ok();
|
||||
rows.map(|r| r.filter_map(|e| e.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn tags_for_file(&self, filename: &str) -> Vec<String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = match db.prepare("SELECT tag FROM file_tags WHERE filename = ?1 ORDER BY tag") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map(params![filename], |row| row.get::<_, String>(0))
|
||||
.ok();
|
||||
rows.map(|r| r.filter_map(|e| e.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_virtual_folder(&self, path: &Path) -> Option<String> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let normalized = normalize_folder(&path_str);
|
||||
if normalized == "/" {
|
||||
return None;
|
||||
}
|
||||
let folders = self.load_folders();
|
||||
for folder in &folders {
|
||||
if normalized == folder.as_str() || normalized.starts_with(&format!("{}/", folder)) {
|
||||
return Some(folder.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve(&self, virtual_path: &Path) -> (PathBuf, Option<String>, Option<String>) {
|
||||
let path_str = virtual_path.to_string_lossy().to_string();
|
||||
let normalized = if path_str.starts_with('/') {
|
||||
path_str.clone()
|
||||
} else {
|
||||
format!("/{}", path_str)
|
||||
};
|
||||
|
||||
if normalized == "/" || normalized.is_empty() {
|
||||
return (self.root.clone(), None, None);
|
||||
}
|
||||
|
||||
let folders = self.load_folders();
|
||||
for folder in &folders {
|
||||
let folder_tag = folder_to_tag(folder);
|
||||
let folder_prefix = format!("{}/", folder);
|
||||
if normalized == folder.as_str() {
|
||||
return (self.root.clone(), Some(folder_tag), None);
|
||||
}
|
||||
if normalized.starts_with(&folder_prefix) {
|
||||
let filename = normalized[folder_prefix.len()..].to_string();
|
||||
return (self.root.join(&filename), Some(folder_tag), Some(filename));
|
||||
}
|
||||
}
|
||||
|
||||
let relative = normalized.trim_start_matches('/');
|
||||
(self.root.join(relative), None, Some(relative.to_string()))
|
||||
}
|
||||
|
||||
fn check_writable(&self) -> Result<(), VfsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_folder(&self, folder: &str, description: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let f = normalize_folder(folder);
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, ?2)",
|
||||
params![f, description],
|
||||
)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_folder(&self, folder: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let f = normalize_folder(folder);
|
||||
let tag = folder_to_tag(&f);
|
||||
db.execute("DELETE FROM file_tags WHERE tag = ?1", params![tag])
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
db.execute("DELETE FROM virtual_folders WHERE folder = ?1", params![f])
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tag_file(&self, filename: &str, tag: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO file_tags (filename, tag) VALUES (?1, ?2)",
|
||||
params![filename, tag],
|
||||
)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn untag_file(&self, filename: &str, tag: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute(
|
||||
"DELETE FROM file_tags WHERE filename = ?1 AND tag = ?2",
|
||||
params![filename, tag],
|
||||
)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_folders(&self) -> Result<Vec<(String, String)>, VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = db
|
||||
.prepare("SELECT folder, description FROM virtual_folders ORDER BY folder")
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
pub fn list_files_in_folder(&self, folder: &str) -> Result<Vec<String>, VfsError> {
|
||||
let tag = folder_to_tag(folder);
|
||||
Ok(self.files_with_tag(&tag))
|
||||
}
|
||||
|
||||
pub fn list_tags_for_file(&self, filename: &str) -> Result<Vec<String>, VfsError> {
|
||||
Ok(self.tags_for_file(filename))
|
||||
}
|
||||
|
||||
pub fn set_config(&self, key: &str, value: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO webdav_config (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_config(&self, key: &str) -> Option<String> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.query_row(
|
||||
"SELECT value FROM webdav_config WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
fn remove_all_tags(&self, filename: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute("DELETE FROM file_tags WHERE filename = ?1", params![filename])
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_tags(&self, old_filename: &str, new_filename: &str) -> Result<(), VfsError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute(
|
||||
"UPDATE OR IGNORE file_tags SET filename = ?1 WHERE filename = ?2",
|
||||
params![new_filename, old_filename],
|
||||
)
|
||||
.map_err(|e| VfsError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_dir_entry(name: &str) -> VfsDirEntry {
|
||||
let mut stat = VfsStat::new();
|
||||
stat.is_dir = true;
|
||||
stat.mode = 0o755;
|
||||
VfsDirEntry {
|
||||
name: name.to_string(),
|
||||
long_name: name.to_string(),
|
||||
stat,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_file_entry(name: &str, stat: VfsStat) -> VfsDirEntry {
|
||||
VfsDirEntry {
|
||||
name: name.to_string(),
|
||||
long_name: name.to_string(),
|
||||
stat,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VfsBackend for VirtualFs {
|
||||
fn clone_boxed(&self) -> Box<dyn VfsBackend> {
|
||||
Box::new(VirtualFs {
|
||||
db: self.db.clone(),
|
||||
backend: self.backend.clone_boxed(),
|
||||
root: self.root.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_dir(&self, path: &Path) -> Result<Vec<VfsDirEntry>, VfsError> {
|
||||
let (real, folder_tag, _) = self.resolve(path);
|
||||
|
||||
if let Some(tag) = folder_tag {
|
||||
let files = self.files_with_tag(&tag);
|
||||
let mut entries = Vec::new();
|
||||
for filename in &files {
|
||||
let file_path = self.root.join(filename);
|
||||
if let Ok(stat) = self.backend.stat(&file_path) {
|
||||
let name = Path::new(filename)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
entries.push(Self::make_file_entry(&name, stat));
|
||||
} else {
|
||||
let stat = VfsStat::new();
|
||||
entries.push(Self::make_file_entry(filename, stat));
|
||||
}
|
||||
}
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
let mut entries = self.backend.read_dir(&real)?;
|
||||
|
||||
let folders = self.load_folders();
|
||||
for folder in &folders {
|
||||
let name = folder.trim_start_matches('/').to_string();
|
||||
if !entries.iter().any(|e| e.name == name) {
|
||||
entries.push(Self::make_dir_entry(&name));
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn open_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
flags: &OpenFlags,
|
||||
) -> Result<Box<dyn VfsFile>, VfsError> {
|
||||
self.check_writable()?;
|
||||
|
||||
if flags.create {
|
||||
let (real, folder_tag, filename) = self.resolve(path);
|
||||
let file = self.backend.open_file(&real, flags)?;
|
||||
|
||||
if let (Some(tag), Some(fname)) = (folder_tag, filename.as_ref()) {
|
||||
let _ = self.tag_file(fname, &tag);
|
||||
}
|
||||
return Ok(file);
|
||||
}
|
||||
|
||||
let (real, _, _) = self.resolve(path);
|
||||
self.backend.open_file(&real, flags)
|
||||
}
|
||||
|
||||
fn stat(&self, path: &Path) -> Result<VfsStat, VfsError> {
|
||||
let (real, folder_tag, _) = self.resolve(path);
|
||||
|
||||
if folder_tag.is_some() && !real.exists() {
|
||||
let file_count = match &folder_tag {
|
||||
Some(tag) => self.files_with_tag(tag).len(),
|
||||
None => 0,
|
||||
};
|
||||
if file_count > 0 || path == Path::new("/") {
|
||||
let mut stat = VfsStat::new();
|
||||
stat.is_dir = true;
|
||||
stat.mode = 0o755;
|
||||
return Ok(stat);
|
||||
}
|
||||
}
|
||||
|
||||
if folder_tag.is_some() && real == self.root {
|
||||
let mut stat = VfsStat::new();
|
||||
stat.is_dir = true;
|
||||
stat.mode = 0o755;
|
||||
return Ok(stat);
|
||||
}
|
||||
|
||||
self.backend.stat(&real)
|
||||
}
|
||||
|
||||
fn lstat(&self, path: &Path) -> Result<VfsStat, VfsError> {
|
||||
self.stat(path)
|
||||
}
|
||||
|
||||
fn create_dir(&self, path: &Path, _mode: u32) -> Result<(), VfsError> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let normalized = normalize_folder(&path_str);
|
||||
|
||||
if normalized == "/" {
|
||||
return Err(VfsError::AlreadyExists("/".to_string()));
|
||||
}
|
||||
|
||||
if self.is_virtual_folder(path).is_some() {
|
||||
return Err(VfsError::AlreadyExists(normalized));
|
||||
}
|
||||
|
||||
self.add_folder(&normalized, "")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_dir_all(&self, path: &Path, mode: u32) -> Result<(), VfsError> {
|
||||
self.create_dir(path, mode)
|
||||
}
|
||||
|
||||
fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let normalized = normalize_folder(&path_str);
|
||||
|
||||
if self.is_virtual_folder(path).is_some() {
|
||||
self.remove_folder(&normalized)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (real, _, _) = self.resolve(path);
|
||||
self.backend.remove_dir(&real)
|
||||
}
|
||||
|
||||
fn remove_file(&self, path: &Path) -> Result<(), VfsError> {
|
||||
self.check_writable()?;
|
||||
|
||||
if self.is_virtual_folder(path).is_some() {
|
||||
let (_, folder_tag, filename) = self.resolve(path);
|
||||
if let (Some(tag), Some(fname)) = (folder_tag, filename) {
|
||||
self.untag_file(&fname, &tag)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (real, _, filename) = self.resolve(path);
|
||||
self.backend.remove_file(&real)?;
|
||||
|
||||
if let Some(fname) = filename {
|
||||
let _ = self.remove_all_tags(&fname);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename(&self, from: &Path, to: &Path) -> Result<(), VfsError> {
|
||||
self.check_writable()?;
|
||||
|
||||
let (_, from_tag, from_filename) = self.resolve(from);
|
||||
let (_, to_tag, _to_filename) = self.resolve(to);
|
||||
|
||||
let from_is_folder = from_tag.is_some() && from_filename.is_none();
|
||||
|
||||
if from_is_folder {
|
||||
return Err(VfsError::Unsupported("Cannot rename virtual folder".to_string()));
|
||||
}
|
||||
|
||||
match (from_tag, to_tag, from_filename.as_ref()) {
|
||||
(Some(ft), Some(tt), Some(fname)) => {
|
||||
if ft != tt {
|
||||
self.untag_file(fname, &ft)?;
|
||||
self.tag_file(fname, &tt)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
(None, Some(tt), Some(fname)) => {
|
||||
self.tag_file(fname, &tt)?;
|
||||
return Ok(());
|
||||
}
|
||||
(Some(ft), None, Some(fname)) => {
|
||||
self.untag_file(fname, &ft)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.is_virtual_folder(to).is_some() {
|
||||
let (_, from_tag, from_filename) = self.resolve(from);
|
||||
let (_, to_tag, _) = self.resolve(to);
|
||||
|
||||
match (from_tag, to_tag, from_filename) {
|
||||
(Some(ft), Some(tt), Some(fname)) => {
|
||||
if ft != tt {
|
||||
self.untag_file(&fname, &ft)?;
|
||||
self.tag_file(&fname, &tt)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
(None, Some(tt), Some(fname)) => {
|
||||
self.tag_file(&fname, &tt)?;
|
||||
return Ok(());
|
||||
}
|
||||
(Some(ft), None, Some(fname)) => {
|
||||
self.untag_file(&fname, &ft)?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (real_from, _, from_filename) = self.resolve(from);
|
||||
let (real_to, _, to_filename) = self.resolve(to);
|
||||
|
||||
self.backend.rename(&real_from, &real_to)?;
|
||||
|
||||
if let (Some(old_name), Some(new_name)) = (from_filename, to_filename) {
|
||||
self.move_tags(&old_name, &new_name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_stat(&self, path: &Path, stat: &VfsStat) -> Result<(), VfsError> {
|
||||
self.check_writable()?;
|
||||
let (real, _, _) = self.resolve(path);
|
||||
self.backend.set_stat(&real, stat)
|
||||
}
|
||||
|
||||
fn read_link(&self, path: &Path) -> Result<PathBuf, VfsError> {
|
||||
let (real, _, _) = self.resolve(path);
|
||||
self.backend.read_link(&real)
|
||||
}
|
||||
|
||||
fn create_symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {
|
||||
self.check_writable()?;
|
||||
let (real_target, _, _) = self.resolve(target);
|
||||
let (real_link, _, _) = self.resolve(link);
|
||||
self.backend.create_symlink(&real_target, &real_link)
|
||||
}
|
||||
|
||||
fn real_path(&self, path: &Path) -> Result<PathBuf, VfsError> {
|
||||
let (real, _, _) = self.resolve(path);
|
||||
self.backend.real_path(&real)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
if self.is_virtual_folder(path).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let (real, folder_tag, filename) = self.resolve(path);
|
||||
|
||||
if let Some(tag) = folder_tag {
|
||||
if let Some(fname) = filename {
|
||||
return self.backend.exists(&real)
|
||||
|| self.tags_for_file(&fname).contains(&tag);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
self.backend.exists(&real)
|
||||
}
|
||||
|
||||
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), VfsError> {
|
||||
self.check_writable()?;
|
||||
let (real_orig, _, _) = self.resolve(original);
|
||||
let (real_link, _, _) = self.resolve(link);
|
||||
self.backend.hard_link(&real_orig, &real_link)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, VirtualFs, TempDir) {
|
||||
let db_dir = TempDir::new().unwrap();
|
||||
let db_path = db_dir.path().join("test_vfs.sqlite");
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
std::fs::write(root_dir.path().join("flower.jpg"), "image").unwrap();
|
||||
std::fs::write(root_dir.path().join("report.pdf"), "pdf content").unwrap();
|
||||
std::fs::write(root_dir.path().join("sunset.jpg"), "sunset").unwrap();
|
||||
|
||||
let vfs = VirtualFs::new(db_path.to_str().unwrap(), root_dir.path().to_path_buf())
|
||||
.unwrap();
|
||||
(db_dir, vfs, root_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "Photo collection").unwrap();
|
||||
let folders = vfs.list_folders().unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].0, "/photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
assert_eq!(vfs.files_with_tag("photos").len(), 1);
|
||||
|
||||
vfs.remove_folder("/photos").unwrap();
|
||||
assert_eq!(vfs.list_folders().unwrap().len(), 0);
|
||||
assert_eq!(vfs.files_with_tag("photos").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_file() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.add_folder("/nature", "").unwrap();
|
||||
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
vfs.tag_file("flower.jpg", "nature").unwrap();
|
||||
|
||||
let tags = vfs.tags_for_file("flower.jpg");
|
||||
assert!(tags.contains(&"photos".to_string()));
|
||||
assert!(tags.contains(&"nature".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_dir_root_shows_folders() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
|
||||
let entries = vfs.read_dir(Path::new("/")).unwrap();
|
||||
assert!(entries.iter().any(|e| e.name == "photos" && e.stat.is_dir));
|
||||
assert!(entries.iter().any(|e| e.name == "flower.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_dir_virtual_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
vfs.tag_file("sunset.jpg", "photos").unwrap();
|
||||
|
||||
let entries = vfs.read_dir(Path::new("/photos")).unwrap();
|
||||
let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
|
||||
assert!(names.contains(&"flower.jpg".to_string()));
|
||||
assert!(names.contains(&"sunset.jpg".to_string()));
|
||||
assert!(!names.contains(&"report.pdf".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_file_in_virtual_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
|
||||
let flags = OpenFlags::new().read();
|
||||
let file = vfs.open_file(Path::new("/photos/flower.jpg"), &flags).unwrap();
|
||||
drop(file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_file_auto_tags() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/docs", "").unwrap();
|
||||
|
||||
let flags = OpenFlags::new().write().create().truncate();
|
||||
let file = vfs.open_file(Path::new("/docs/newfile.txt"), &flags).unwrap();
|
||||
drop(file);
|
||||
|
||||
let tags = vfs.tags_for_file("newfile.txt");
|
||||
assert!(tags.contains(&"docs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_file_from_virtual_folder_untags() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
|
||||
vfs.remove_file(Path::new("/photos/flower.jpg")).unwrap();
|
||||
|
||||
assert!(std::path::Path::new(&vfs.root().join("flower.jpg")).exists());
|
||||
let tags = vfs.tags_for_file("flower.jpg");
|
||||
assert!(!tags.contains(&"photos".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_file_from_root_deletes() {
|
||||
let (_db, vfs, root) = setup();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
|
||||
vfs.remove_file(Path::new("/flower.jpg")).unwrap();
|
||||
assert!(!root.path().join("flower.jpg").exists());
|
||||
assert_eq!(vfs.tags_for_file("flower.jpg").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stat_virtual_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
|
||||
let stat = vfs.stat(Path::new("/photos")).unwrap();
|
||||
assert!(stat.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stat_file_in_virtual_folder() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
|
||||
let stat = vfs.stat(Path::new("/photos/flower.jpg")).unwrap();
|
||||
assert_eq!(stat.size, 5);
|
||||
assert!(!stat.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_file_multiple_tags() {
|
||||
let (_db, vfs, root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.add_folder("/nature", "").unwrap();
|
||||
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
vfs.tag_file("flower.jpg", "nature").unwrap();
|
||||
|
||||
let photos_entries = vfs.read_dir(Path::new("/photos")).unwrap();
|
||||
let nature_entries = vfs.read_dir(Path::new("/nature")).unwrap();
|
||||
|
||||
assert!(photos_entries.iter().any(|e| e.name == "flower.jpg"));
|
||||
assert!(nature_entries.iter().any(|e| e.name == "flower.jpg"));
|
||||
|
||||
assert!(root.path().join("flower.jpg").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_adds_tag() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
|
||||
vfs.rename(Path::new("/flower.jpg"), Path::new("/photos/flower.jpg"))
|
||||
.unwrap();
|
||||
|
||||
let tags = vfs.tags_for_file("flower.jpg");
|
||||
assert!(tags.contains(&"photos".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_removes_tag() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.add_folder("/photos", "").unwrap();
|
||||
vfs.tag_file("flower.jpg", "photos").unwrap();
|
||||
|
||||
vfs.rename(Path::new("/photos/flower.jpg"), Path::new("/flower.jpg"))
|
||||
.unwrap();
|
||||
|
||||
let tags = vfs.tags_for_file("flower.jpg");
|
||||
assert!(!tags.contains(&"photos".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_virtual_dir() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.create_dir(Path::new("/newfolder"), 0o755).unwrap();
|
||||
let folders = vfs.list_folders().unwrap();
|
||||
assert!(folders.iter().any(|(f, _)| f == "/newfolder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config() {
|
||||
let (_db, vfs, _root) = setup();
|
||||
vfs.set_config("default_root", "/data/demo").unwrap();
|
||||
assert_eq!(vfs.get_config("default_root"), Some("/data/demo".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user