//! 로컬 파일시스템 백엔드 — OS가 마운트한 네트워크 드라이브(UNC, Z:\, /Volumes)도 //! 이 백엔드로 처리된다. use crate::{Caps, Result, VEntry, VfsError, VfsProvider, VfsRead}; use std::ops::Range; use std::path::Path; use tokio::io::{AsyncReadExt, AsyncSeekExt}; pub struct LocalFs; impl LocalFs { pub fn new() -> Self { LocalFs } } impl Default for LocalFs { fn default() -> Self { Self::new() } } fn entry_from_meta(name: String, meta: &std::fs::Metadata) -> VEntry { VEntry { name, is_dir: meta.is_dir(), size: meta.len(), mtime_ms: meta .modified() .map(crate::system_time_to_ms) .unwrap_or(0), } } #[async_trait::async_trait] impl VfsProvider for LocalFs { fn capabilities(&self) -> Caps { Caps::RANGE_READ | Caps::SERVER_MOVE | Caps::NATIVE_TRASH | Caps::MTIME_WRITE | Caps::PARALLEL_READS | Caps::WATCHABLE } async fn list_dir(&self, path: &str) -> Result> { let mut rd = tokio::fs::read_dir(path).await?; let mut out = Vec::new(); while let Some(e) = rd.next_entry().await? { let meta = match e.metadata().await { Ok(m) => m, Err(_) => continue, // 접근 불가 항목은 건너뜀 }; out.push(entry_from_meta(e.file_name().to_string_lossy().into_owned(), &meta)); } Ok(out) } async fn stat(&self, path: &str) -> Result { let meta = tokio::fs::metadata(path).await?; let name = Path::new(path) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); Ok(entry_from_meta(name, &meta)) } async fn open_range(&self, path: &str, range: Option>) -> Result { let mut file = tokio::fs::File::open(path).await?; match range { None => Ok(Box::new(file)), Some(r) => { file.seek(std::io::SeekFrom::Start(r.start)).await?; let len = r.end.saturating_sub(r.start); Ok(Box::new(file.take(len))) } } } async fn rename(&self, from: &str, to: &str) -> Result<()> { tokio::fs::rename(from, to).await.map_err(Into::into) } async fn delete(&self, path: &str) -> Result<()> { // 주의: 하드 삭제. 휴지통 삭제는 fileops 계층(trash 크레이트)이 담당하고, // VFS delete는 앱 레벨 휴지통 rename 이후의 퍼지 등 내부 용도로만 쓴다. let meta = tokio::fs::metadata(path).await?; if meta.is_dir() { return Err(VfsError::Protocol("디렉터리 삭제는 지원하지 않음".into())); } tokio::fs::remove_file(path).await.map_err(Into::into) } async fn mkdir(&self, path: &str) -> Result<()> { tokio::fs::create_dir_all(path).await.map_err(Into::into) } } #[cfg(test)] mod tests { use super::*; use tokio::io::AsyncReadExt; #[tokio::test] async fn range_read_returns_exact_window() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("a.bin"); std::fs::write(&p, b"0123456789").unwrap(); let fs = LocalFs::new(); let mut r = fs .open_range(p.to_str().unwrap(), Some(2..6)) .await .unwrap(); let mut buf = Vec::new(); r.read_to_end(&mut buf).await.unwrap(); assert_eq!(&buf, b"2345"); } #[tokio::test] async fn list_dir_and_stat() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("한글파일.jpg"), b"xx").unwrap(); std::fs::create_dir(dir.path().join("sub")).unwrap(); let fs = LocalFs::new(); let entries = fs.list_dir(dir.path().to_str().unwrap()).await.unwrap(); assert_eq!(entries.len(), 2); let file = entries.iter().find(|e| !e.is_dir).unwrap(); assert_eq!(file.name, "한글파일.jpg"); assert_eq!(file.size, 2); } }