Files
MusicFS/docs/v2/plans/week-01-foundation.md
T
Alexander e08988f7f3 Add development plan and Oracle-validated weekly plans (Weeks 1-3)
development-plan.md (master plan):
- 11-week implementation broken into 4 phases
- 11 Rust crates with dependency graph
- Per-week deliverables, tests, exit criteria
- Deferred requirements (FR-21, FR-22) with rationale

plans/week-01-foundation.md:
- Workspace setup, core types, FUSE skeleton, local origin
- Origin trait with watch() method (arch 4.3.4)
- EventBus with FileAccessed event (FR-18.1)
- All EROFS handlers for read-only enforcement (FR-4.1-4.5)

plans/week-02-metadata.md:
- symphonia metadata extraction (FR-6.1-6.5)
- SQLite schema matching architecture 4.3.6 exactly
- Column names: track/disc (not track_number/disc_number)
- Hash columns as TEXT (hex-encoded, not BLOB)
- Added idx_files_real index (FR-7.3)

plans/week-03-virtual-tree.md:
- Path resolver with $var syntax (arch 4.3.1)
- Template vars: $artist, $album, $title, $track, $year, $disc, $genre, $format, $format_upper
- RefreshPolicy struct for FR-9.3 (TTL-based refresh)
- force_refresh() method for FR-9.4 (signal/API refresh)

All plans Oracle-validated against architecture.md and requirements.md
2026-05-12 17:52:33 +02:00

26 KiB

Week 1: Foundation

Phase: 1 (MVP)
Prerequisites: None
Estimated effort: 5 days


Objective

Set up Rust workspace, define core types, create FUSE skeleton, implement local origin plugin.


Deliverables

Task Crate Files Done
Workspace setup root Cargo.toml, .cargo/config.toml [ ]
Core types musicfs-core lib.rs, error.rs, types.rs [ ]
Event Bus musicfs-core events.rs [ ]
FUSE skeleton musicfs-fuse lib.rs, filesystem.rs [ ]
Local origin musicfs-origins lib.rs, local.rs, traits.rs [ ]
Nix flake root flake.nix [ ]

Task 1: Workspace Setup

1.1 Create directory structure

mkdir -p musicfs
cd musicfs
mkdir -p crates/{musicfs-core,musicfs-fuse,musicfs-cache,musicfs-cas,musicfs-sync,musicfs-origins,musicfs-metadata,musicfs-search,musicfs-plugins,musicfs-grpc,musicfs-cli}
mkdir -p proto tests/{integration,e2e} benches

1.2 Create root Cargo.toml

[workspace]
resolver = "2"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.75"
authors = ["MusicFS Contributors"]
repository = "https://github.com/user/musicfs"

[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"

# Error handling
thiserror = "1"
anyhow = "1"

# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rmp-serde = "1"  # msgpack

# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# FUSE
fuser = "0.14"

# Database
rusqlite = { version = "0.31", features = ["bundled"] }
sled = "0.34"

# Hashing (per architecture 8.3)
xxhash-rust = { version = "0.8", features = ["xxh64"] }

# Testing
tempfile = "3"

1.3 Create .cargo/config.toml

[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

[target.x86_64-unknown-linux-gnu]
linker = "clang"

[alias]
t = "test"
c = "check"
b = "build"

Task 2: Core Types (musicfs-core)

2.1 Initialize crate

cd crates/musicfs-core
cargo init --lib

2.2 Create Cargo.toml

[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

2.3 Create src/lib.rs

pub mod error;
pub mod types;
pub mod events;

pub use error::{Error, Result};
pub use types::*;
pub use events::{Event, EventBus};

2.4 Create src/error.rs

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>;

2.5 Create src/types.rs

use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;

/// Unique identifier for an origin
#[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())
    }
}

/// Unique identifier for a file in the database
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileId(pub i64);

/// Virtual path in metadata-organized tree
#[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("")
    }
}

/// Real path on origin storage
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RealPath {
    pub origin_id: OriginId,
    pub path: PathBuf,
}

/// Content-addressable hash (xxHash64 per architecture 8.3)
#[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())
    }
}

/// Chunk-level hash (xxHash64)
#[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())
    }
}

/// Audio format enumeration
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AudioFormat {
    Flac,
    Mp3,
    Opus,
    Vorbis,
    Aac,
    Alac,
    Wav,
    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,
        }
    }
}

/// Audio metadata extracted from files
#[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>,        // "track" per architecture 4.3.6
    pub disc: Option<u32>,         // "disc" per architecture 4.3.6
    pub duration_ms: Option<u64>,
    pub bitrate: Option<u32>,
    pub sample_rate: Option<u32>,
    pub format: AudioFormat,
}

/// Complete file metadata
#[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>,
}

/// Directory entry for readdir
#[derive(Debug, Clone)]
pub struct DirEntry {
    pub name: String,
    pub is_dir: bool,
    pub size: u64,
    pub mtime: SystemTime,
}

/// File stat information
#[derive(Debug, Clone)]
pub struct FileStat {
    pub size: u64,
    pub mtime: SystemTime,
    pub is_dir: bool,
}

/// Origin health status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthStatus {
    Healthy,
    Degraded,
    Unhealthy,
    Unknown,
}

2.6 Create src/events.rs

use crate::types::{OriginId, VirtualPath};
use tokio::sync::broadcast;

/// Central event bus for system-wide notifications (per architecture 4.2)
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) {
        // Ignore error if no receivers
        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)
    }
}

/// System events
#[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,
    },
}

Task 3: FUSE Skeleton (musicfs-fuse)

3.1 Create Cargo.toml

[package]
name = "musicfs-fuse"
version.workspace = true
edition.workspace = true

[dependencies]
musicfs-core = { path = "../musicfs-core" }
fuser.workspace = true
tokio.workspace = true
tracing.workspace = true

3.2 Create src/lib.rs

mod filesystem;

pub use filesystem::MusicFs;

3.3 Create src/filesystem.rs

use fuser::{
    FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory,
    ReplyEntry, ReplyOpen, Request, FUSE_ROOT_ID,
};
use musicfs_core::{Error, Result};
use std::ffi::OsStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{debug, error, info};

const TTL: Duration = Duration::from_secs(1);

/// Main FUSE filesystem implementation
pub struct MusicFs {
    // Will be populated in later weeks:
    // origins: Arc<OriginRegistry>,
    // cache: Arc<CacheManager>,
    // tree: Arc<RwLock<VirtualTree>>,
}

impl MusicFs {
    pub fn new() -> Self {
        Self {}
    }
    
    /// Mount the filesystem
    pub fn mount(self, mountpoint: &std::path::Path) -> Result<()> {
        info!("Mounting MusicFS at {:?}", mountpoint);
        
        let options = vec![
            fuser::MountOption::RO,           // Read-only
            fuser::MountOption::FSName("musicfs".to_string()),
            fuser::MountOption::AutoUnmount,
            fuser::MountOption::AllowOther,
        ];
        
        fuser::mount2(self, mountpoint, &options)
            .map_err(|e| Error::Io(e))?;
        
        Ok(())
    }
    
    fn root_attr(&self) -> FileAttr {
        FileAttr {
            ino: FUSE_ROOT_ID,
            size: 0,
            blocks: 0,
            atime: UNIX_EPOCH,
            mtime: UNIX_EPOCH,
            ctime: UNIX_EPOCH,
            crtime: UNIX_EPOCH,
            kind: FileType::Directory,
            perm: 0o755,
            nlink: 2,
            uid: unsafe { libc::getuid() },
            gid: unsafe { libc::getgid() },
            rdev: 0,
            blksize: 512,
            flags: 0,
        }
    }
}

impl Default for MusicFs {
    fn default() -> Self {
        Self::new()
    }
}

impl Filesystem for MusicFs {
    fn init(
        &mut self,
        _req: &Request<'_>,
        _config: &mut fuser::KernelConfig,
    ) -> std::result::Result<(), libc::c_int> {
        info!("MusicFS initialized");
        Ok(())
    }
    
    fn destroy(&mut self) {
        info!("MusicFS destroyed");
    }
    
    fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
        debug!("lookup(parent={}, name={:?})", parent, name);
        
        // TODO: Implement in Week 3
        reply.error(libc::ENOENT);
    }
    
    fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
        debug!("getattr(ino={})", ino);
        
        if ino == FUSE_ROOT_ID {
            reply.attr(&TTL, &self.root_attr());
        } else {
            // TODO: Implement in Week 3
            reply.error(libc::ENOENT);
        }
    }
    
    fn readdir(
        &mut self,
        _req: &Request,
        ino: u64,
        _fh: u64,
        offset: i64,
        mut reply: ReplyDirectory,
    ) {
        debug!("readdir(ino={}, offset={})", ino, offset);
        
        if ino == FUSE_ROOT_ID {
            // Root directory with . and ..
            if offset == 0 {
                let _ = reply.add(FUSE_ROOT_ID, 1, FileType::Directory, ".");
            }
            if offset <= 1 {
                let _ = reply.add(FUSE_ROOT_ID, 2, FileType::Directory, "..");
            }
            // TODO: Add actual entries in Week 3
            reply.ok();
        } else {
            // TODO: Implement in Week 3
            reply.error(libc::ENOENT);
        }
    }
    
    fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
        debug!("open(ino={}, flags={})", ino, flags);
        
        // Check for write flags - we're read-only (FR-4.1)
        let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
        if flags & write_flags != 0 {
            reply.error(libc::EROFS);
            return;
        }
        
        // TODO: Implement in Week 3
        reply.error(libc::ENOENT);
    }
    
    fn read(
        &mut self,
        _req: &Request,
        ino: u64,
        _fh: u64,
        offset: i64,
        size: u32,
        _flags: i32,
        _lock_owner: Option<u64>,
        reply: ReplyData,
    ) {
        debug!("read(ino={}, offset={}, size={})", ino, offset, size);
        
        // TODO: Implement in Week 3
        reply.error(libc::ENOENT);
    }
    
    fn release(
        &mut self,
        _req: &Request,
        ino: u64,
        _fh: u64,
        _flags: i32,
        _lock_owner: Option<u64>,
        _flush: bool,
        reply: fuser::ReplyEmpty,
    ) {
        debug!("release(ino={})", ino);
        reply.ok();
    }
    
    // Write operations - always return EROFS (FR-4.1-4.4)
    
    fn write(
        &mut self,
        _req: &Request,
        _ino: u64,
        _fh: u64,
        _offset: i64,
        _data: &[u8],
        _write_flags: u32,
        _flags: i32,
        _lock_owner: Option<u64>,
        reply: fuser::ReplyWrite,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn mkdir(
        &mut self,
        _req: &Request,
        _parent: u64,
        _name: &OsStr,
        _mode: u32,
        _umask: u32,
        reply: ReplyEntry,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
        reply.error(libc::EROFS);
    }
    
    fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
        reply.error(libc::EROFS);
    }
    
    fn rename(
        &mut self,
        _req: &Request,
        _parent: u64,
        _name: &OsStr,
        _newparent: u64,
        _newname: &OsStr,
        _flags: u32,
        reply: fuser::ReplyEmpty,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn create(
        &mut self,
        _req: &Request,
        _parent: u64,
        _name: &OsStr,
        _mode: u32,
        _umask: u32,
        _flags: i32,
        reply: fuser::ReplyCreate,
    ) {
        reply.error(libc::EROFS);
    }
    
    // Additional EROFS handlers (FR-4.5)
    
    fn setattr(
        &mut self,
        _req: &Request,
        _ino: u64,
        _mode: Option<u32>,
        _uid: Option<u32>,
        _gid: Option<u32>,
        _size: Option<u64>,
        _atime: Option<fuser::TimeOrNow>,
        _mtime: Option<fuser::TimeOrNow>,
        _ctime: Option<SystemTime>,
        _fh: Option<u64>,
        _crtime: Option<SystemTime>,
        _chgtime: Option<SystemTime>,
        _bkuptime: Option<SystemTime>,
        _flags: Option<u32>,
        reply: ReplyAttr,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn symlink(
        &mut self,
        _req: &Request,
        _parent: u64,
        _name: &OsStr,
        _link: &std::path::Path,
        reply: ReplyEntry,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn link(
        &mut self,
        _req: &Request,
        _ino: u64,
        _newparent: u64,
        _newname: &OsStr,
        reply: ReplyEntry,
    ) {
        reply.error(libc::EROFS);
    }
    
    fn mknod(
        &mut self,
        _req: &Request,
        _parent: u64,
        _name: &OsStr,
        _mode: u32,
        _umask: u32,
        _rdev: u32,
        reply: ReplyEntry,
    ) {
        reply.error(libc::EROFS);
    }
}

Task 4: Local Origin (musicfs-origins)

4.1 Create Cargo.toml

[package]
name = "musicfs-origins"
version.workspace = true
edition.workspace = true

[dependencies]
musicfs-core = { path = "../musicfs-core" }
async-trait.workspace = true
tokio = { workspace = true, features = ["fs"] }
tracing.workspace = true

4.2 Create src/lib.rs

mod traits;
mod local;

pub use traits::Origin;
pub use local::LocalOrigin;

4.3 Create src/traits.rs

use async_trait::async_trait;
use musicfs_core::{DirEntry, FileStat, HealthStatus, OriginId, Result};
use std::path::Path;
use tokio::io::AsyncRead;

/// Origin type enumeration
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginType {
    Local,
    Nfs,
    Smb,
    S3,
    Sftp,
}

/// Origin plugin interface (per architecture 4.3.4)
#[async_trait]
pub trait Origin: Send + Sync {
    /// Unique identifier for this origin
    fn id(&self) -> &OriginId;
    
    /// Origin type
    fn origin_type(&self) -> OriginType;
    
    /// Human-readable display name
    fn display_name(&self) -> &str;
    
    /// List entries in directory
    async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>>;
    
    /// Get file/directory metadata
    async fn stat(&self, path: &Path) -> Result<FileStat>;
    
    /// Read file content at offset
    async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Vec<u8>>;
    
    /// Check if path exists
    async fn exists(&self, path: &Path) -> Result<bool>;
    
    /// Health check
    async fn health(&self) -> HealthStatus;
    
    /// Get a reader for streaming large files
    async fn open_read(&self, path: &Path) -> Result<Box<dyn AsyncRead + Send + Unpin>>;
    
    /// Watch path for changes (per architecture 4.3.4)
    /// Returns handle that cancels watch on drop
    async fn watch(&self, path: &Path, callback: WatchCallback) -> Result<WatchHandle>;
}

/// Callback for file change notifications
pub type WatchCallback = Box<dyn Fn(WatchEvent) + Send + Sync>;

/// Handle to cancel a watch - cancels on drop
pub struct WatchHandle {
    _cancel: tokio::sync::oneshot::Sender<()>,
}

/// Watch event types
#[derive(Debug, Clone)]
pub enum WatchEvent {
    Created(PathBuf),
    Modified(PathBuf),
    Deleted(PathBuf),
}

4.4 Create src/local.rs

use crate::traits::{Origin, OriginType};
use async_trait::async_trait;
use musicfs_core::{DirEntry, Error, FileStat, HealthStatus, OriginId, Result};
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncRead;
use tracing::debug;

/// Local filesystem origin (FR-12.1)
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 {
        self.root.join(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> {
        // Stub implementation for Week 1
        // Full inotify/notify implementation deferred to Week 5 (FR-10.2)
        let (tx, mut rx) = tokio::sync::oneshot::channel();
        
        // In Week 5: Use notify crate for real filesystem watching
        // For now, just return a handle that does nothing
        debug!("LocalOrigin::watch({:?}) - stub implementation", path);
        
        Ok(WatchHandle { _cancel: tx })
    }
}

Task 5: Nix Flake

5.1 Create flake.nix

{
  description = "MusicFS - FUSE filesystem for music libraries";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, rust-overlay, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs { inherit system overlays; };
        
        rustToolchain = pkgs.rust-bin.stable.latest.default.override {
          extensions = [ "rust-src" "rust-analyzer" ];
        };
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = with pkgs; [
            rustToolchain
            pkg-config
            fuse3
            sqlite
            openssl
            
            # Development tools
            cargo-watch
            cargo-nextest
            cargo-criterion
            
            # gRPC
            protobuf
            grpcurl
          ];
          
          RUST_BACKTRACE = 1;
          RUST_LOG = "debug";
        };
        
        packages.default = pkgs.rustPlatform.buildRustPackage {
          pname = "musicfs";
          version = "0.1.0";
          src = ./.;
          cargoLock.lockFile = ./Cargo.lock;
          
          nativeBuildInputs = [ pkgs.pkg-config ];
          buildInputs = [ pkgs.fuse3 pkgs.sqlite pkgs.openssl ];
        };
      }
    );
}

Tests

Unit Tests

Create crates/musicfs-core/src/lib.rs test module:

#[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);
    }
    
    #[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 { .. }));
    }
}

Integration Tests

Create tests/integration/basic_mount.rs:

use musicfs_fuse::MusicFs;
use std::process::Command;
use tempfile::TempDir;

#[test]
fn test_fuse_mount_unmount() {
    let mount_dir = TempDir::new().unwrap();
    let mount_path = mount_dir.path();
    
    // Fork and mount in child process
    let fs = MusicFs::new();
    
    // For now, just verify construction works
    // Full mount test requires FUSE permissions
    drop(fs);
}

#[test]
fn test_read_only_enforcement() {
    // Verify write operations return EROFS
    // This will be expanded in Week 3
}

Exit Criteria

  • cargo build succeeds for all crates
  • cargo test passes
  • nix develop enters shell with all dependencies
  • FUSE skeleton compiles with all required trait methods
  • LocalOrigin can list files in a test directory
  • Write operations return EROFS
  • EventBus publishes and receives events

Verification Commands

# Build all crates
cargo build --workspace

# Run all tests
cargo test --workspace

# Check for warnings
cargo clippy --workspace -- -D warnings

# Enter nix shell
nix develop

# Test local origin (manual)
cargo run --example local_origin_test

Next Week

Week 2 will implement metadata extraction using symphonia and create the SQLite schema.