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:
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "musicfs-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
xxhash-rust.workspace = true
|
||||
hex = "0.4"
|
||||
@@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Origin not found: {0}")]
|
||||
OriginNotFound(String),
|
||||
|
||||
#[error("File not found: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("Path resolution failed: {0}")]
|
||||
PathResolution(String),
|
||||
|
||||
#[error("Cache error: {0}")]
|
||||
Cache(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("NFS stale file handle")]
|
||||
NfsStaleHandle,
|
||||
|
||||
#[error("Operation not permitted (read-only filesystem)")]
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,96 @@
|
||||
use crate::types::{OriginId, VirtualPath};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<Event>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (sender, _) = broadcast::channel(capacity);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub fn publish(&self, event: Event) {
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new(1024)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Event {
|
||||
FileAdded {
|
||||
path: VirtualPath,
|
||||
origin_id: OriginId,
|
||||
},
|
||||
FileRemoved {
|
||||
path: VirtualPath,
|
||||
},
|
||||
FileModified {
|
||||
path: VirtualPath,
|
||||
},
|
||||
FileAccessed {
|
||||
path: VirtualPath,
|
||||
origin_id: OriginId,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
},
|
||||
OriginConnected {
|
||||
origin_id: OriginId,
|
||||
},
|
||||
OriginDisconnected {
|
||||
origin_id: OriginId,
|
||||
},
|
||||
SyncStarted {
|
||||
origin_id: OriginId,
|
||||
},
|
||||
SyncCompleted {
|
||||
origin_id: OriginId,
|
||||
files_changed: u64,
|
||||
},
|
||||
CacheEviction {
|
||||
bytes_freed: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_event_bus() {
|
||||
let bus = EventBus::new(16);
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
bus.publish(Event::SyncStarted {
|
||||
origin_id: OriginId::from("test"),
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert!(matches!(event, Event::SyncStarted { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_event_bus_multiple_subscribers() {
|
||||
let bus = EventBus::new(16);
|
||||
let mut rx1 = bus.subscribe();
|
||||
let mut rx2 = bus.subscribe();
|
||||
|
||||
bus.publish(Event::CacheEviction { bytes_freed: 1024 });
|
||||
|
||||
let e1 = rx1.recv().await.unwrap();
|
||||
let e2 = rx2.recv().await.unwrap();
|
||||
|
||||
assert!(matches!(e1, Event::CacheEviction { bytes_freed: 1024 }));
|
||||
assert!(matches!(e2, Event::CacheEviction { bytes_freed: 1024 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use events::{Event, EventBus};
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,179 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct OriginId(pub String);
|
||||
|
||||
impl From<&str> for OriginId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OriginId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FileId(pub i64);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct VirtualPath(pub PathBuf);
|
||||
|
||||
impl VirtualPath {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self(path.into())
|
||||
}
|
||||
|
||||
pub fn as_path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.to_str().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RealPath {
|
||||
pub origin_id: OriginId,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ContentHash(pub [u8; 8]);
|
||||
|
||||
impl ContentHash {
|
||||
pub fn from_bytes(data: &[u8]) -> Self {
|
||||
use xxhash_rust::xxh64::xxh64;
|
||||
Self(xxh64(data, 0).to_le_bytes())
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ChunkHash(pub [u8; 8]);
|
||||
|
||||
impl ChunkHash {
|
||||
pub fn from_bytes(data: &[u8]) -> Self {
|
||||
use xxhash_rust::xxh64::xxh64;
|
||||
Self(xxh64(data, 0).to_le_bytes())
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AudioFormat {
|
||||
Flac,
|
||||
Mp3,
|
||||
Opus,
|
||||
Vorbis,
|
||||
Aac,
|
||||
Alac,
|
||||
Wav,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn from_extension(ext: &str) -> Self {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"flac" => Self::Flac,
|
||||
"mp3" => Self::Mp3,
|
||||
"opus" => Self::Opus,
|
||||
"ogg" => Self::Vorbis,
|
||||
"m4a" | "aac" => Self::Aac,
|
||||
"wav" => Self::Wav,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AudioMeta {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub track: Option<u32>,
|
||||
pub disc: Option<u32>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub bitrate: Option<u32>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub format: AudioFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileMeta {
|
||||
pub id: FileId,
|
||||
pub virtual_path: VirtualPath,
|
||||
pub real_path: RealPath,
|
||||
pub size: u64,
|
||||
pub mtime: SystemTime,
|
||||
pub content_hash: Option<ContentHash>,
|
||||
pub audio: Option<AudioMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub mtime: SystemTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileStat {
|
||||
pub size: u64,
|
||||
pub mtime: SystemTime,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HealthStatus {
|
||||
Healthy,
|
||||
Degraded,
|
||||
Unhealthy,
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_content_hash() {
|
||||
let data = b"hello world";
|
||||
let hash1 = ContentHash::from_bytes(data);
|
||||
let hash2 = ContentHash::from_bytes(data);
|
||||
assert_eq!(hash1, hash2);
|
||||
|
||||
let hash3 = ContentHash::from_bytes(b"different");
|
||||
assert_ne!(hash1, hash3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_format_from_extension() {
|
||||
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
|
||||
assert_eq!(AudioFormat::from_extension("MP3"), AudioFormat::Mp3);
|
||||
assert_eq!(AudioFormat::from_extension("unknown"), AudioFormat::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_virtual_path() {
|
||||
let path = VirtualPath::new("/Artist/Album/Track.flac");
|
||||
assert_eq!(path.as_str(), "/Artist/Album/Track.flac");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user