Implement Week 1 foundation: workspace, core types, FUSE skeleton, LocalOrigin

- musicfs-core: OriginId, FileId, VirtualPath, ContentHash, AudioMeta,
  FileMeta, EventBus with FileAccessed event (5 tests)
- musicfs-fuse: FUSE skeleton with EROFS handlers for write ops
- musicfs-origins: Origin trait with watch(), LocalOrigin impl (6 tests)
- flake.nix: Nix dev shell with rust toolchain, clang, lld, fuse3

All 11 tests pass. Build produces no warnings.
This commit is contained in:
Alexander
2026-05-12 18:01:47 +02:00
parent e08988f7f3
commit 76856b893a
35 changed files with 1933 additions and 0 deletions
@@ -0,0 +1,5 @@
mod local;
mod traits;
pub use local::LocalOrigin;
pub use traits::{Origin, OriginType, WatchCallback, WatchEvent, WatchHandle};
+200
View File
@@ -0,0 +1,200 @@
use crate::traits::{Origin, OriginType, WatchCallback, WatchHandle};
use async_trait::async_trait;
use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, Result};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncRead;
use tracing::debug;
pub struct LocalOrigin {
id: OriginId,
root: PathBuf,
display_name: String,
}
impl LocalOrigin {
pub fn new(id: impl Into<OriginId>, root: impl Into<PathBuf>) -> Self {
let root = root.into();
let display_name = format!("Local: {}", root.display());
Self {
id: id.into(),
root,
display_name,
}
}
fn full_path(&self, path: &Path) -> PathBuf {
if path.as_os_str().is_empty() || path == Path::new("/") {
self.root.clone()
} else {
self.root.join(path.strip_prefix("/").unwrap_or(path))
}
}
}
#[async_trait]
impl Origin for LocalOrigin {
fn id(&self) -> &OriginId {
&self.id
}
fn origin_type(&self) -> OriginType {
OriginType::Local
}
fn display_name(&self) -> &str {
&self.display_name
}
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>> {
let full_path = self.full_path(path);
debug!("LocalOrigin::readdir({:?})", full_path);
let mut entries = Vec::new();
let mut dir = fs::read_dir(&full_path).await?;
while let Some(entry) = dir.next_entry().await? {
let metadata = entry.metadata().await?;
let name = entry.file_name().to_string_lossy().into_owned();
entries.push(DirEntry {
name,
is_dir: metadata.is_dir(),
size: metadata.len(),
mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
});
}
Ok(entries)
}
async fn stat(&self, path: &Path) -> Result<FileStat> {
let full_path = self.full_path(path);
debug!("LocalOrigin::stat({:?})", full_path);
let metadata = fs::metadata(&full_path).await?;
Ok(FileStat {
size: metadata.len(),
mtime: metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
is_dir: metadata.is_dir(),
})
}
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let full_path = self.full_path(path);
debug!(
"LocalOrigin::read({:?}, offset={}, size={})",
full_path, offset, size
);
let mut file = fs::File::open(&full_path).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buffer = vec![0u8; size as usize];
let bytes_read = file.read(&mut buffer).await?;
buffer.truncate(bytes_read);
Ok(buffer)
}
async fn exists(&self, path: &Path) -> Result<bool> {
let full_path = self.full_path(path);
Ok(fs::try_exists(&full_path).await?)
}
async fn health(&self) -> HealthStatus {
match fs::try_exists(&self.root).await {
Ok(true) => HealthStatus::Healthy,
Ok(false) => HealthStatus::Unhealthy,
Err(_) => HealthStatus::Unhealthy,
}
}
async fn open_read(&self, path: &Path) -> Result<Box<dyn AsyncRead + Send + Unpin>> {
let full_path = self.full_path(path);
let file = fs::File::open(&full_path).await?;
Ok(Box::new(file))
}
async fn watch(&self, path: &Path, _callback: WatchCallback) -> Result<WatchHandle> {
debug!("LocalOrigin::watch({:?}) - stub implementation", path);
let (tx, _rx) = tokio::sync::oneshot::channel();
Ok(WatchHandle::new(tx))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_local_origin_readdir() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
std::fs::create_dir(dir.path().join("subdir")).unwrap();
let origin = LocalOrigin::new("test", dir.path());
let entries = origin.readdir(Path::new("/")).await.unwrap();
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|e| e.name == "test.txt" && !e.is_dir));
assert!(entries.iter().any(|e| e.name == "subdir" && e.is_dir));
}
#[tokio::test]
async fn test_local_origin_stat() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let stat = origin.stat(Path::new("/test.txt")).await.unwrap();
assert_eq!(stat.size, 11);
assert!(!stat.is_dir);
}
#[tokio::test]
async fn test_local_origin_read() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let data = origin.read(Path::new("/test.txt"), 0, 5).await.unwrap();
assert_eq!(data, b"hello");
}
#[tokio::test]
async fn test_local_origin_read_offset() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello world").unwrap();
let origin = LocalOrigin::new("test", dir.path());
let data = origin.read(Path::new("/test.txt"), 6, 5).await.unwrap();
assert_eq!(data, b"world");
}
#[tokio::test]
async fn test_local_origin_exists() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let origin = LocalOrigin::new("test", dir.path());
assert!(origin.exists(Path::new("/test.txt")).await.unwrap());
assert!(!origin.exists(Path::new("/nonexistent.txt")).await.unwrap());
}
#[tokio::test]
async fn test_local_origin_health() {
let dir = TempDir::new().unwrap();
let origin = LocalOrigin::new("test", dir.path());
assert_eq!(origin.health().await, HealthStatus::Healthy);
}
}
@@ -0,0 +1,55 @@
use async_trait::async_trait;
use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, Result};
use std::path::{Path, PathBuf};
use tokio::io::AsyncRead;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginType {
Local,
Nfs,
Smb,
S3,
Sftp,
}
#[async_trait]
pub trait Origin: Send + Sync {
fn id(&self) -> &OriginId;
fn origin_type(&self) -> OriginType;
fn display_name(&self) -> &str;
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>>;
async fn stat(&self, path: &Path) -> Result<FileStat>;
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Vec<u8>>;
async fn exists(&self, path: &Path) -> Result<bool>;
async fn health(&self) -> HealthStatus;
async fn open_read(&self, path: &Path) -> Result<Box<dyn AsyncRead + Send + Unpin>>;
async fn watch(&self, path: &Path, callback: WatchCallback) -> Result<WatchHandle>;
}
pub type WatchCallback = Box<dyn Fn(WatchEvent) + Send + Sync>;
pub struct WatchHandle {
_cancel: tokio::sync::oneshot::Sender<()>,
}
impl WatchHandle {
pub fn new(cancel: tokio::sync::oneshot::Sender<()>) -> Self {
Self { _cancel: cancel }
}
}
#[derive(Debug, Clone)]
pub enum WatchEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
}