From e08988f7f3e950ecf51ae0c087551482ca85e41f Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 12 May 2026 17:52:33 +0200 Subject: [PATCH] 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 --- docs/v2/development-plan.md | 1338 +++++++++++++++++++++++++ docs/v2/plans/week-01-foundation.md | 1126 +++++++++++++++++++++ docs/v2/plans/week-02-metadata.md | 750 ++++++++++++++ docs/v2/plans/week-03-virtual-tree.md | 1069 ++++++++++++++++++++ 4 files changed, 4283 insertions(+) create mode 100644 docs/v2/development-plan.md create mode 100644 docs/v2/plans/week-01-foundation.md create mode 100644 docs/v2/plans/week-02-metadata.md create mode 100644 docs/v2/plans/week-03-virtual-tree.md diff --git a/docs/v2/development-plan.md b/docs/v2/development-plan.md new file mode 100644 index 0000000..2df81ca --- /dev/null +++ b/docs/v2/development-plan.md @@ -0,0 +1,1338 @@ +# MusicFS Development Plan + +**Version**: 1.0 +**Date**: 2026-05-12 +**Status**: Draft +**Prerequisites**: [requirements.md](requirements.md), [architecture.md](architecture.md) + +--- + +## 1. Overview + +This plan breaks down the 11-week implementation into specific deliverables with Rust modules, test requirements, and acceptance criteria mapped to requirements. + +### 1.1 Project Structure + +``` +musicfs/ +├── Cargo.toml # Workspace root +├── proto/ +│ └── musicfs.proto # gRPC definitions +├── crates/ +│ ├── musicfs-core/ # Core types, traits, errors +│ ├── musicfs-fuse/ # FUSE filesystem implementation +│ ├── musicfs-cache/ # Three-tier caching (L1/L2/L3) +│ ├── musicfs-cas/ # Content-addressable storage +│ ├── musicfs-sync/ # Delta sync, CDC chunking +│ ├── musicfs-origins/ # Origin plugins (local, s3, sftp) +│ ├── musicfs-metadata/ # Audio metadata extraction +│ ├── musicfs-search/ # Full-text search (tantivy) +│ ├── musicfs-plugins/ # Plugin host (native + WASM) +│ ├── musicfs-grpc/ # gRPC control API +│ └── musicfs-cli/ # CLI binary +├── tests/ +│ ├── integration/ # Cross-crate integration tests +│ └── e2e/ # End-to-end FUSE tests +└── benches/ # Criterion benchmarks +``` + +### 1.2 Dependency Graph + +``` + musicfs-cli + │ + ┌──────────┼──────────┐ + │ │ │ + ▼ ▼ ▼ + musicfs-grpc musicfs-fuse musicfs-search + │ │ │ + └────┬─────┴───────────────┘ + │ + ▼ + musicfs-core + / | \ + / | \ + ▼ ▼ ▼ + musicfs-cache musicfs-origins musicfs-metadata + │ │ + ▼ │ + musicfs-cas ◄───────┘ + │ + ▼ + musicfs-sync +``` + +--- + +## 2. Phase 1: MVP (Weeks 1-4) + +**Goal**: Basic functional filesystem with single local origin. + +**Requirements Covered**: FR-1, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7, FR-8, FR-9, FR-18, NFR-1.1-1.7 + +--- + +### Week 1: Foundation + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Workspace setup | root | `Cargo.toml`, `.cargo/config.toml` | - | +| Core types | musicfs-core | `lib.rs`, `error.rs`, `types.rs` | - | +| Event Bus | musicfs-core | `events.rs` | FR-18.1-18.4 | +| FUSE skeleton | musicfs-fuse | `lib.rs`, `filesystem.rs` | FR-1.1, FR-1.2 | +| Local origin | musicfs-origins | `lib.rs`, `local.rs`, `traits.rs` | FR-12.1 | +| Nix flake | root | `flake.nix` | - | + +#### Core Types (`musicfs-core/src/types.rs`) + +```rust +// Virtual path in metadata-organized tree +pub struct VirtualPath(PathBuf); + +// Real path on origin +pub struct RealPath { + pub origin_id: OriginId, + pub path: PathBuf, +} + +// File metadata +pub struct FileMeta { + pub id: FileId, + pub virtual_path: VirtualPath, + pub real_path: RealPath, + pub size: u64, + pub mtime: SystemTime, + pub content_hash: ContentHash, + pub audio: Option, +} + +pub struct AudioMeta { + pub artist: Option, + pub album: Option, + pub title: Option, + pub track_number: Option, + pub duration_ms: Option, + pub format: AudioFormat, +} + +// Content-addressable hash (per architecture 8.3) +pub struct ContentHash([u8; 8]); // xxHash64 for file-level dedup +pub struct ChunkHash([u8; 8]); // xxHash64 for chunk-level dedup +``` + +#### Event Bus (`musicfs-core/src/events.rs`) + +```rust +use tokio::sync::broadcast; + +/// Central event bus for system-wide notifications (per architecture 4.2) +pub struct EventBus { + sender: broadcast::Sender, +} + +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); // Ignore if no receivers + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } +} + +#[derive(Clone, Debug)] +pub enum Event { + FileAdded { path: VirtualPath, origin_id: OriginId }, + FileRemoved { path: VirtualPath }, + FileModified { path: VirtualPath }, + OriginConnected { origin_id: OriginId }, + OriginDisconnected { origin_id: OriginId }, + SyncStarted { origin_id: OriginId }, + SyncCompleted { origin_id: OriginId, files_changed: u64 }, + CacheEviction { bytes_freed: u64 }, +} +``` + +#### Origin Trait (`musicfs-origins/src/traits.rs`) + +```rust +#[async_trait] +pub trait Origin: Send + Sync { + fn id(&self) -> &OriginId; + fn origin_type(&self) -> OriginType; + + /// List entries in directory + async fn readdir(&self, path: &Path) -> Result>; + + /// Get file metadata + async fn stat(&self, path: &Path) -> Result; + + /// Read file content + async fn read(&self, path: &Path, offset: u64, size: u32) -> Result; + + /// Check if path exists + async fn exists(&self, path: &Path) -> Result; + + /// Health check + async fn health(&self) -> HealthStatus; +} +``` + +#### FUSE Skeleton (`musicfs-fuse/src/filesystem.rs`) + +```rust +pub struct MusicFs { + origins: Arc, + cache: Arc, + tree: Arc>, +} + +impl fuser::Filesystem for MusicFs { + fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry); + fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr); + fn readdir(&mut self, _req: &Request, ino: u64, fh: u64, offset: i64, reply: ReplyDirectory); + fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen); + fn read(&mut self, _req: &Request, ino: u64, fh: u64, offset: i64, size: u32, ...); + fn release(&mut self, _req: &Request, ino: u64, fh: u64, ...); +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_workspace_builds` | Unit | All crates compile | +| `test_local_origin_readdir` | Unit | Local origin lists files | +| `test_fuse_mount_unmount` | Integration | Mount/unmount works (FR-1.1, FR-1.2) | + +#### Exit Criteria + +- [ ] `cargo build` succeeds for all crates +- [ ] `cargo test` passes +- [ ] FUSE mount creates mount point +- [ ] FUSE unmount removes mount point + +--- + +### Week 2: Metadata Extraction + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Audio parsing | musicfs-metadata | `lib.rs`, `parser.rs`, `formats/` | FR-6.1-6.5 | +| Format detection | musicfs-metadata | `formats/flac.rs`, `formats/mp3.rs`, etc. | FR-24.1 | +| SQLite schema | musicfs-cache | `schema.sql`, `db.rs` | FR-7.1-7.4 | +| Metadata cache | musicfs-cache | `metadata.rs` | FR-7.1 | + +#### Metadata Parser (`musicfs-metadata/src/parser.rs`) + +```rust +pub struct MetadataParser { + symphonia: SymphoniaParser, +} + +impl MetadataParser { + /// Extract metadata from audio file + pub fn parse(&self, reader: impl Read + Seek) -> Result { + // Uses symphonia for format-agnostic parsing + } + + /// Extract just the header (first N KB for metadata overlay) + pub fn parse_header(&self, reader: impl Read + Seek) -> Result<(AudioMeta, Bytes)> { + // Returns metadata + raw header bytes for caching + } +} +``` + +#### SQLite Schema (`musicfs-cache/src/schema.sql`) + +```sql +-- As defined in architecture.md section 4.3.6 +-- NOTE: Chunk index stored in sled (chunks.sled/), NOT SQLite + +CREATE TABLE files ( + id INTEGER PRIMARY KEY, + origin_id TEXT NOT NULL, -- Origin identifier + real_path TEXT NOT NULL, -- Path on origin + virtual_path TEXT NOT NULL, -- Metadata-derived path + + -- Audio metadata + title TEXT, + artist TEXT, + album TEXT, + track_number INTEGER, + duration_ms INTEGER, + bitrate INTEGER, + sample_rate INTEGER, + format TEXT, + + -- Sync state + origin_mtime INTEGER, + origin_size INTEGER, + content_hash TEXT, + chunk_manifest BLOB, -- msgpack: [(chunk_hash, offset, size)] + last_sync INTEGER, + + UNIQUE(origin_id, real_path) +); + +CREATE TABLE artwork ( + id INTEGER PRIMARY KEY, + file_id INTEGER REFERENCES files(id), + art_type TEXT, -- 'front', 'back' + chunk_hash TEXT, -- reference to CAS + width INTEGER, + height INTEGER, + UNIQUE(file_id, art_type) +); + +CREATE TABLE collections ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE, + query_json TEXT, -- smart collection query + created_at INTEGER +); + +-- Indexes for performance (NFR-1.1, NFR-1.2) +CREATE INDEX idx_virtual ON files(virtual_path); +CREATE INDEX idx_artist_album ON files(artist, album); +CREATE INDEX idx_content_hash ON files(content_hash); +``` + +#### Sled Chunk Index (`musicfs-cas/chunks.sled/`) + +```rust +// Chunk hash → storage location (NOT in SQLite per architecture 4.3.2) +// Key: ChunkHash (8 bytes, xxHash64) +// Value: ChunkLocation { path: PathBuf, offset: u64, size: u32 } +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_parse_flac` | Unit | FLAC metadata extraction (FR-6.1-6.5) | +| `test_parse_mp3_id3v2` | Unit | MP3 ID3v2 tags | +| `test_parse_mp3_id3v1` | Unit | MP3 ID3v1 fallback | +| `test_parse_opus` | Unit | Opus/Vorbis comments | +| `test_metadata_cache_insert` | Unit | SQLite insert/query | +| `test_metadata_cache_persistence` | Integration | Data survives restart (FR-7.4) | + +#### Exit Criteria + +- [ ] Parse FLAC, MP3, Opus, M4A metadata +- [ ] Extract: artist, album, title, track, duration, format +- [ ] SQLite schema created and migrated +- [ ] Metadata persists across restarts + +--- + +### Week 3: Virtual Tree & Basic Ops + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Virtual path resolver | musicfs-core | `resolver.rs` | FR-5.1, FR-5.2 | +| Tree cache | musicfs-cache | `tree.rs` | FR-9.1-9.4 | +| readdir impl | musicfs-fuse | `ops/readdir.rs` | FR-2.1-2.3 | +| stat impl | musicfs-fuse | `ops/stat.rs` | FR-2.1 | +| open/read impl | musicfs-fuse | `ops/read.rs` | FR-3.1-3.5 | + +#### Virtual Path Resolver (`musicfs-core/src/resolver.rs`) + +```rust +pub struct PathResolver { + templates: Vec, +} + +impl PathResolver { + /// Map real path + metadata → virtual path + pub fn resolve(&self, real: &RealPath, meta: &AudioMeta) -> VirtualPath { + // Template: "/{artist}/{album}/{track:02} - {title}.{ext}" + } + + /// Reverse lookup: virtual path → file ID + pub fn lookup(&self, virtual_path: &VirtualPath, tree: &VirtualTree) -> Option { + // O(1) hash lookup + } +} + +pub struct PathTemplate { + pub pattern: String, + pub fallback_artist: String, // "Unknown Artist" + pub fallback_album: String, // "Unknown Album" +} +``` + +#### Tree Cache (`musicfs-cache/src/tree.rs`) + +```rust +pub struct VirtualTree { + root: DirNode, + inode_map: HashMap, + path_map: HashMap, + next_inode: AtomicU64, +} + +pub enum VirtualNode { + Dir(DirNode), + File(FileNode), +} + +pub struct DirNode { + pub inode: u64, + pub name: OsString, + pub children: BTreeMap, + pub mtime: SystemTime, +} + +pub struct FileNode { + pub inode: u64, + pub name: OsString, + pub file_id: FileId, + pub size: u64, + pub mtime: SystemTime, +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_resolve_complete_metadata` | Unit | Full path resolution (FR-5.1) | +| `test_resolve_missing_album` | Unit | Fallback handling (FR-5.2) | +| `test_tree_readdir` | Unit | Directory listing (FR-2.1) | +| `test_tree_stat` | Unit | File attributes (FR-2.1) | +| `test_fuse_readdir` | Integration | FUSE readdir (FR-2.2, NFR-1.2) | +| `test_fuse_stat` | Integration | FUSE stat (NFR-1.1) | +| `test_fuse_read` | Integration | FUSE read (FR-3.1) | +| `test_read_only_enforcement` | Integration | Write ops fail (FR-4.1-4.4) | + +#### Benchmark + +```rust +// benches/tree_ops.rs +fn bench_stat_cached(c: &mut Criterion) { + // Target: <1ms p99 (NFR-1.1) +} + +fn bench_readdir_1000_entries(c: &mut Criterion) { + // Target: <10ms p99 (NFR-1.2) +} + +fn bench_mount_time(c: &mut Criterion) { + // Target: <100ms, Max: <500ms (NFR-1.7) + // Mount must be O(1), not O(files) +} +``` + +#### Exit Criteria + +- [ ] Virtual tree built from metadata +- [ ] `ls /mnt/music` shows Artist directories +- [ ] `ls /mnt/music/Artist/Album` shows tracks +- [ ] `stat` returns correct size, mtime +- [ ] `cat` reads file content +- [ ] Write operations return EROFS (FR-4.1) +- [ ] Mount completes in <500ms (NFR-1.7) + +--- + +### Week 4: CAS & Chunk Caching + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| CAS implementation | musicfs-cas | `lib.rs`, `store.rs` | FR-20.1-20.4 | +| Chunk storage | musicfs-cas | `chunks.rs` | FR-8.1-8.4 | +| Cache eviction | musicfs-cache | `eviction.rs` | FR-8.2 | +| Integration tests | tests/integration | `basic_mount.rs` | FR-1, FR-2, FR-3 | + +#### CAS Store (`musicfs-cas/src/store.rs`) + +```rust +pub struct CasStore { + chunks_dir: PathBuf, + index: sled::Db, // hash → chunk location +} + +impl CasStore { + /// Store chunk, returns hash + pub async fn put(&self, data: &[u8]) -> Result { + let hash = xxhash64(data); + let path = self.chunk_path(&hash); + if !path.exists() { + tokio::fs::write(&path, data).await?; + } + Ok(hash) + } + + /// Retrieve chunk by hash + pub async fn get(&self, hash: &ChunkHash) -> Result { + let path = self.chunk_path(hash); + Ok(tokio::fs::read(&path).await?.into()) + } + + /// Check existence (for dedup) + pub fn exists(&self, hash: &ChunkHash) -> bool { + self.chunk_path(hash).exists() + } +} +``` + +#### Cache Eviction (`musicfs-cache/src/eviction.rs`) + +```rust +pub struct LruEviction { + max_size: u64, + current_size: AtomicU64, + access_log: RwLock>, +} + +impl LruEviction { + /// Evict chunks until under limit + pub async fn evict_to_target(&self, store: &CasStore, target: u64) -> Result { + // LRU eviction based on access time + // Returns bytes freed + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_cas_put_get` | Unit | Basic store/retrieve (FR-20.1) | +| `test_cas_dedup` | Unit | Same content → same hash (FR-20.2) | +| `test_cas_integrity` | Unit | Verify chunk hash (FR-20.4) | +| `test_cache_eviction` | Unit | LRU eviction works (FR-8.2) | +| `test_cache_persistence` | Integration | Survives restart (FR-8.4) | +| `test_mount_play_audio` | E2E | mpv can play file through FUSE | + +#### Exit Criteria + +- [ ] Chunks stored in CAS with deduplication +- [ ] Cache size limit enforced via eviction +- [ ] Audio playback works through mounted filesystem +- [ ] Cache persists across daemon restarts +- [ ] All Phase 1 requirements pass acceptance tests + +--- + +## 3. Phase 2: Delta Sync & Multi-Origin (Weeks 5-7) + +**Goal**: Efficient synchronization and origin federation. + +**Requirements Covered**: FR-10, FR-11, FR-12, FR-13, NFR-4, NFR-5 + +--- + +### Week 5: CDC & Delta Detection + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| FastCDC integration | musicfs-sync | `cdc.rs` | FR-11.2 | +| Manifest storage | musicfs-sync | `manifest.rs` | FR-11.3 | +| Delta detection | musicfs-sync | `delta.rs` | FR-10.1-10.4, FR-11.1 | +| Change watcher | musicfs-sync | `watcher.rs` | FR-10.3 | + +#### CDC Chunking (`musicfs-sync/src/cdc.rs`) + +```rust +pub struct CdcChunker { + min_size: usize, // 16 KB + avg_size: usize, // 64 KB + max_size: usize, // 256 KB +} + +impl CdcChunker { + /// Chunk file content using FastCDC + pub fn chunk(&self, data: &[u8]) -> Vec { + let chunker = fastcdc::v2020::FastCDC::new( + data, self.min_size, self.avg_size, self.max_size + ); + chunker.map(|c| Chunk { + offset: c.offset, + length: c.length, + hash: xxhash64(&data[c.offset..c.offset + c.length]), + }).collect() + } +} + +pub struct ChunkManifest { + pub content_hash: ContentHash, + pub chunks: Vec<(ChunkHash, u64, u32)>, // (hash, offset, size) + pub total_size: u64, +} +``` + +#### Delta Detection (`musicfs-sync/src/delta.rs`) + +```rust +pub struct DeltaDetector { + db: Arc, +} + +impl DeltaDetector { + /// Compare origin state to cached state + pub async fn detect_changes(&self, origin: &dyn Origin) -> Result { + let origin_files = origin.list_recursive().await?; + let cached_files = self.db.list_files(origin.id()).await?; + + ChangeSet { + added: self.find_added(&origin_files, &cached_files), + removed: self.find_removed(&origin_files, &cached_files), + modified: self.find_modified(&origin_files, &cached_files).await?, + } + } + + /// Compute chunk delta for modified file + pub async fn compute_delta( + &self, + old_manifest: &ChunkManifest, + new_chunks: &[Chunk], + ) -> ChunkDelta { + let old_hashes: HashSet<_> = old_manifest.chunks.iter().map(|c| c.0).collect(); + ChunkDelta { + reuse: new_chunks.iter().filter(|c| old_hashes.contains(&c.hash)).cloned().collect(), + fetch: new_chunks.iter().filter(|c| !old_hashes.contains(&c.hash)).cloned().collect(), + } + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_cdc_stable_boundaries` | Unit | Insertions don't shift all chunks | +| `test_delta_detect_added` | Unit | New files detected (FR-10.1) | +| `test_delta_detect_removed` | Unit | Deleted files detected (FR-10.2) | +| `test_delta_detect_modified` | Unit | Changed files detected (FR-10.4) | +| `test_delta_chunk_reuse` | Unit | Unchanged chunks reused (FR-11.1) | +| `test_bandwidth_reduction` | Integration | >90% reduction on metadata edit (NFR-5.1) | + +#### Exit Criteria + +- [ ] CDC produces stable chunk boundaries +- [ ] Delta sync detects add/remove/modify +- [ ] Unchanged chunks reused (not re-fetched) +- [ ] Metadata-only edits achieve >90% bandwidth reduction + +--- + +### Week 6: Origin Federation + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Origin registry | musicfs-origins | `registry.rs` | FR-13.1-13.3 | +| Priority routing | musicfs-origins | `router.rs` | FR-13.4 | +| Health checks | musicfs-origins | `health.rs` | FR-13.6 | +| Failover logic | musicfs-origins | `failover.rs` | FR-13.5 | + +#### Origin Registry (`musicfs-origins/src/registry.rs`) + +```rust +pub struct OriginRegistry { + origins: RwLock>>, + router: Router, + health: HealthMonitor, +} + +impl OriginRegistry { + /// Register new origin + pub async fn register(&self, config: OriginConfig) -> Result; + + /// Get best origin for path + pub async fn route(&self, path: &RealPath) -> Result>; + + /// Get all origins for path (for redundancy) + pub async fn route_all(&self, path: &RealPath) -> Vec>; +} +``` + +#### Router (`musicfs-origins/src/router.rs`) + +```rust +pub struct Router { + priority_map: RwLock>, + latency_stats: DashMap, +} + +impl Router { + /// Select best origin based on priority + health + latency + pub fn select(&self, candidates: &[OriginId], health: &HealthSnapshot) -> Option { + candidates + .iter() + .filter(|id| health.is_healthy(id)) + .min_by_key(|id| { + let priority = self.priority_map.read().get(id).copied().unwrap_or(100); + let latency = self.latency_stats.get(id).map(|s| s.p50_ms).unwrap_or(1000); + (priority, latency) + }) + .copied() + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_register_multiple_origins` | Unit | Multi-origin support (FR-13.1) | +| `test_route_by_priority` | Unit | Priority routing (FR-13.4) | +| `test_failover_on_unhealthy` | Unit | Automatic failover (FR-13.5) | +| `test_health_check_interval` | Integration | Periodic health checks (FR-13.6) | +| `test_origin_offline_graceful` | Integration | Serve cached when offline (NFR-7.1) | + +#### Exit Criteria + +- [ ] Multiple origins configurable +- [ ] Requests route to healthiest origin +- [ ] Failover when primary fails +- [ ] Offline origin doesn't crash daemon + +--- + +### Week 7: Remote Origins + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| NFS origin | musicfs-origins | `nfs.rs` | FR-12.2 | +| SMB origin | musicfs-origins | `smb.rs` | FR-12.3 | +| S3 origin | musicfs-origins | `s3.rs` | FR-12.4 | +| SFTP origin | musicfs-origins | `sftp.rs` | FR-12.5 | +| Credential handling | musicfs-core | `credentials.rs` | Security | + +#### NFS Origin (`musicfs-origins/src/nfs.rs`) + +```rust +/// NFS origin treats mounted NFS as local filesystem +/// User mounts NFS externally; we just read from mount point +pub struct NfsOrigin { + mount_point: PathBuf, + id: OriginId, +} + +#[async_trait] +impl Origin for NfsOrigin { + // Delegates to LocalOrigin implementation + // NFS-specific: handle stale file handles, network timeouts + async fn read(&self, path: &Path, offset: u64, size: u32) -> Result { + // Retry on ESTALE (stale NFS file handle) + for attempt in 0..3 { + match self.do_read(path, offset, size).await { + Ok(data) => return Ok(data), + Err(e) if e.raw_os_error() == Some(libc::ESTALE) => { + tokio::time::sleep(Duration::from_millis(100 * (1 << attempt))).await; + } + Err(e) => return Err(e.into()), + } + } + Err(Error::NfsStaleHandle) + } +} +``` + +#### S3 Origin (`musicfs-origins/src/s3.rs`) + +```rust +pub struct S3Origin { + client: aws_sdk_s3::Client, + bucket: String, + prefix: String, + id: OriginId, +} + +#[async_trait] +impl Origin for S3Origin { + async fn readdir(&self, path: &Path) -> Result> { + let prefix = self.prefix.join(path); + let resp = self.client.list_objects_v2() + .bucket(&self.bucket) + .prefix(&prefix) + .delimiter("/") + .send().await?; + // Convert S3 response to DirEntry + } + + async fn read(&self, path: &Path, offset: u64, size: u32) -> Result { + let range = format!("bytes={}-{}", offset, offset + size as u64 - 1); + let resp = self.client.get_object() + .bucket(&self.bucket) + .key(&self.prefix.join(path)) + .range(range) + .send().await?; + Ok(resp.body.collect().await?.into_bytes()) + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_nfs_origin_stale_handle` | Unit | NFS ESTALE retry (FR-12.2) | +| `test_smb_origin_mock` | Unit | SMB protocol (FR-12.3) | +| `test_s3_origin_mock` | Unit | S3 protocol (FR-12.4) | +| `test_sftp_origin_mock` | Unit | SFTP protocol (FR-12.5) | +| `test_s3_origin_real` | Integration | Real S3 (requires creds) | +| `test_mixed_origins` | Integration | Local + remote together | + +#### Exit Criteria + +- [ ] NFS origin functional (treats NFS mount as local) +- [ ] SMB origin functional +- [ ] S3 origin functional +- [ ] SFTP origin functional +- [ ] Mixed local + remote works +- [ ] All Phase 2 requirements pass acceptance tests + +--- + +## 4. Phase 3: Search & Smart Features (Weeks 8-9) + +**Goal**: Full-text search and intelligent caching. + +**Requirements Covered**: FR-14, FR-15, FR-16, FR-19 + +--- + +### Week 8: Search Index + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| tantivy integration | musicfs-search | `index.rs` | FR-14.1-14.4 | +| Search virtual dir | musicfs-fuse | `ops/search.rs` | FR-14.3 | +| Query parser | musicfs-search | `query.rs` | FR-14.2 | +| Incremental indexing | musicfs-search | `indexer.rs` | FR-14.4 | + +#### Search Index (`musicfs-search/src/index.rs`) + +```rust +pub struct SearchIndex { + index: tantivy::Index, + reader: IndexReader, + writer: Mutex, +} + +impl SearchIndex { + pub fn search(&self, query: &str, limit: usize) -> Result> { + let searcher = self.reader.searcher(); + let query_parser = QueryParser::for_index( + &self.index, + vec![ + self.schema.get_field("artist")?, + self.schema.get_field("album")?, + self.schema.get_field("title")?, + ], + ); + let query = query_parser.parse_query(query)?; + let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?; + // Convert to SearchHit + } + + pub fn index_file(&self, file: &FileMeta) -> Result<()> { + let mut writer = self.writer.lock(); + writer.add_document(doc!( + self.schema.get_field("path")? => file.virtual_path.as_str(), + self.schema.get_field("artist")? => file.audio.as_ref().and_then(|a| a.artist.as_deref()).unwrap_or(""), + self.schema.get_field("album")? => file.audio.as_ref().and_then(|a| a.album.as_deref()).unwrap_or(""), + self.schema.get_field("title")? => file.audio.as_ref().and_then(|a| a.title.as_deref()).unwrap_or(""), + ))?; + Ok(()) + } +} +``` + +#### Search Virtual Directory (`musicfs-fuse/src/ops/search.rs`) + +```rust +// /.search/metallica/ → symlinks to matching files +impl MusicFs { + fn handle_search_readdir(&self, query: &str, reply: ReplyDirectory) { + let results = self.search.search(query, 1000)?; + for (i, hit) in results.iter().enumerate() { + reply.add( + hit.inode, + (i + 1) as i64, + FileType::Symlink, + &hit.display_name(), + ); + } + } + + fn handle_search_readlink(&self, inode: u64, reply: ReplyData) { + // Return path to actual file + let target = self.search.resolve_inode(inode)?; + reply.data(target.as_bytes()); + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_search_artist` | Unit | Artist search (FR-14.1) | +| `test_search_album` | Unit | Album search (FR-14.1) | +| `test_search_multi_field` | Unit | Cross-field search (FR-14.2) | +| `test_search_performance_1m` | Benchmark | <1s for 1M tracks (NFR-2.5) | +| `test_search_virtual_dir` | Integration | /.search/query works (FR-14.3) | +| `test_search_incremental` | Integration | New files indexed (FR-14.4) | + +#### Exit Criteria + +- [ ] Full-text search works +- [ ] `/.search/{query}/` returns symlinks +- [ ] Search <1s for 1M tracks +- [ ] New files automatically indexed + +--- + +### Week 9: Smart Features + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Smart collections | musicfs-search | `collections.rs` | FR-15.1-15.5 | +| Cover art cache | musicfs-cache | `artwork.rs` | FR-16.1-16.4 | +| Prefetch engine | musicfs-cache | `prefetch.rs` | FR-19.1-19.4 | +| Access patterns | musicfs-cache | `patterns.rs` | FR-19.3 | + +#### Smart Collections (`musicfs-search/src/collections.rs`) + +```rust +pub struct SmartCollection { + pub name: String, + pub query: CollectionQuery, +} + +pub enum CollectionQuery { + DateRange { field: String, start: NaiveDate, end: NaiveDate }, + Match { field: String, pattern: String }, + Rating { min: u8 }, + RecentlyAdded { days: u32 }, + Compound { op: BoolOp, children: Vec }, +} + +impl SmartCollection { + /// Materialize as virtual directory + pub fn resolve(&self, index: &SearchIndex) -> Vec { + index.query(&self.query.to_tantivy_query()) + } +} +``` + +#### Prefetch Engine (`musicfs-cache/src/prefetch.rs`) + +```rust +pub struct PrefetchEngine { + pattern_db: PatternStore, + queue: PriorityQueue, +} + +impl PrefetchEngine { + /// Record file access + pub fn record_access(&self, file_id: FileId, context: AccessContext) { + self.pattern_db.record(file_id, context); + } + + /// Predict next likely accesses + pub fn predict(&self, current: FileId) -> Vec { + // Album-aware: if track N accessed, prefetch N+1, N+2 + // Artist-aware: other albums by same artist + // Time-based: tracks often played together + } + + /// Background prefetch worker + pub async fn run(&self, cache: Arc) { + loop { + if let Some(task) = self.queue.pop() { + cache.prefetch(&task.file_id).await; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_collection_date_range` | Unit | Date-based collections (FR-15.2) | +| `test_collection_recently_added` | Unit | Recent additions (FR-15.2) | +| `test_artwork_extraction` | Unit | Cover art from audio (FR-16.1) | +| `test_artwork_thumbnail` | Unit | Thumbnail generation (FR-16.3) | +| `test_prefetch_album_sequence` | Unit | Sequential track prefetch (FR-19.1) | +| `test_prefetch_reduces_misses` | Integration | >50% miss reduction (FR-19.4) | + +#### Exit Criteria + +- [ ] Smart collections as virtual directories +- [ ] Album art extracted and thumbnailed +- [ ] Prefetch reduces cache misses >50% +- [ ] All Phase 3 requirements pass acceptance tests + +--- + +## 5. Phase 4: Plugin System & Polish (Weeks 10-11) + +**Goal**: Extensibility and production readiness. + +**Requirements Covered**: FR-17, FR-18, FR-23, FR-24, NFR-6 + +--- + +### Week 10: Plugin System + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Plugin traits | musicfs-plugins | `traits.rs` | FR-23.1-23.4 | +| Native host | musicfs-plugins | `native.rs` | FR-23.2 | +| WASM host | musicfs-plugins | `wasm.rs` | FR-23.3 | +| Example plugins | plugins/ | `local-origin/`, `flac-format/` | FR-23.5 | + +#### Plugin Traits (`musicfs-plugins/src/traits.rs`) + +```rust +/// Origin plugin interface +pub trait OriginPlugin: Send + Sync { + fn id(&self) -> &str; + fn origin_type(&self) -> &str; + fn create(&self, config: Value) -> Result>; +} + +/// Metadata source plugin +pub trait MetadataPlugin: Send + Sync { + fn id(&self) -> &str; + fn lookup(&self, query: &MetadataQuery) -> Result>; +} + +/// Format plugin (for custom audio formats) +pub trait FormatPlugin: Send + Sync { + fn id(&self) -> &str; + fn extensions(&self) -> &[&str]; + fn parse(&self, reader: &mut dyn Read) -> Result; +} +``` + +#### WASM Host (`musicfs-plugins/src/wasm.rs`) + +```rust +pub struct WasmPluginHost { + engine: wasmtime::Engine, + linker: wasmtime::Linker, +} + +impl WasmPluginHost { + pub fn load(&self, wasm_bytes: &[u8]) -> Result { + let module = wasmtime::Module::new(&self.engine, wasm_bytes)?; + let instance = self.linker.instantiate(&mut store, &module)?; + // Extract plugin interface + } +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_native_plugin_load` | Unit | Native plugin loading (FR-23.2) | +| `test_wasm_plugin_sandbox` | Unit | WASM isolation (FR-23.3) | +| `test_plugin_hot_reload` | Integration | Reload without restart (FR-23.4) | +| `test_example_origin_plugin` | Integration | Custom origin works | + +#### Exit Criteria + +- [ ] Native plugins loadable at runtime +- [ ] WASM plugins sandboxed +- [ ] Example plugins functional +- [ ] Plugins hot-reloadable + +--- + +### Week 11: Control API & Production + +#### Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| gRPC server | musicfs-grpc | `server.rs` | FR-17.1-17.5 | +| Proto codegen | proto/ | `musicfs.proto`, `build.rs` | FR-17.2 | +| Event streaming | musicfs-grpc | `events.rs` | FR-18.1-18.4 | +| Metrics export | musicfs-core | `metrics.rs` | NFR-6.1-6.4 | +| CLI completion | musicfs-cli | `main.rs` | FR-17 | +| systemd unit | dist/ | `musicfs.service` | Production | +| Packaging | dist/ | `PKGBUILD`, `musicfs.spec` | Production | + +#### Proto Definitions (`proto/musicfs.proto`) + +**Source**: Copy verbatim from [architecture.md section 4.3.7](architecture.md#437-control-api) + +```protobuf +syntax = "proto3"; +package musicfs.v1; + +service MusicFS { + // Daemon lifecycle + rpc GetStatus(Empty) returns (StatusResponse); + rpc Shutdown(ShutdownRequest) returns (Empty); + + // Cache management + rpc GetCacheStats(Empty) returns (CacheStats); + rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse); + rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress); + + // Origin management + rpc ListOrigins(Empty) returns (OriginsResponse); + rpc GetOriginHealth(OriginRequest) returns (OriginHealth); + rpc RescanOrigin(OriginRequest) returns (stream SyncProgress); + + // Search + rpc Search(SearchRequest) returns (SearchResponse); + rpc SearchStream(SearchRequest) returns (stream SearchResult); + + // Events (server-streaming) + rpc SubscribeEvents(EventFilter) returns (stream Event); +} + +// Full message definitions in architecture.md section 4.3.7 +// Including: StatusResponse, CacheStats, TierStats, OriginInfo, +// OriginHealth, SyncProgress, SearchRequest, SearchResponse, +// EventFilter, Event, and all enums +``` + +#### gRPC Server (`musicfs-grpc/src/server.rs`) + +```rust +pub struct MusicFsService { + core: Arc, + events: broadcast::Sender, +} + +#[tonic::async_trait] +impl musicfs::v1::music_fs_server::MusicFs for MusicFsService { + async fn get_status(&self, _: Request) -> Result, Status> { + let status = self.core.status().await; + Ok(Response::new(status.into())) + } + + type SubscribeEventsStream = ReceiverStream>; + + async fn subscribe_events( + &self, + request: Request, + ) -> Result, Status> { + let filter = request.into_inner(); + let rx = self.events.subscribe(); + let stream = BroadcastStream::new(rx) + .filter(|e| filter.matches(e)) + .map(|e| Ok(e.into())); + Ok(Response::new(ReceiverStream::new(stream))) + } +} +``` + +#### Metrics (`musicfs-core/src/metrics.rs`) + +```rust +lazy_static! { + pub static ref FUSE_OPS: IntCounterVec = register_int_counter_vec!( + "musicfs_fuse_ops_total", + "Total FUSE operations", + &["op"] + ).unwrap(); + + pub static ref FUSE_LATENCY: HistogramVec = register_histogram_vec!( + "musicfs_fuse_latency_seconds", + "FUSE operation latency", + &["op"], + vec![0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0] + ).unwrap(); + + pub static ref CACHE_HITS: IntCounter = register_int_counter!( + "musicfs_cache_hits_total", + "Cache hits" + ).unwrap(); +} +``` + +#### Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_grpc_status` | Unit | GetStatus RPC (FR-17.1) | +| `test_grpc_cache_clear` | Unit | ClearCache RPC (FR-17.3) | +| `test_grpc_events_stream` | Integration | Event streaming (FR-18.1) | +| `test_metrics_prometheus` | Unit | Prometheus format (NFR-6.1) | +| `test_cli_commands` | Integration | CLI works | +| `test_systemd_service` | E2E | Service lifecycle | + +#### Exit Criteria + +- [ ] gRPC API fully functional +- [ ] Event streaming works +- [ ] Prometheus metrics exported +- [ ] CLI feature-complete +- [ ] systemd service works +- [ ] All acceptance tests pass + +--- + +## 6. Acceptance Test Matrix + +| Test Suite | Requirements | Phase | +|------------|--------------|-------| +| `test_basic_mount` | FR-1.1-1.5, NFR-1.4-1.5, NFR-1.7 | 1 | +| `test_directory_ops` | FR-2.1-2.3, NFR-1.2 | 1 | +| `test_file_read` | FR-3.1-3.5, NFR-1.3 | 1 | +| `test_read_only` | FR-4.1-4.4 | 1 | +| `test_metadata_extraction` | FR-6.1-6.5 | 1 | +| `test_cache_persistence` | FR-7.1-7.4, FR-8.1-8.4 | 1 | +| `test_delta_sync` | FR-10.1-10.4, FR-11.1-11.4, NFR-5.1 | 2 | +| `test_multi_origin` | FR-13.1-13.6 | 2 | +| `test_remote_origins` | FR-12.1-12.5 | 2 | +| `test_search` | FR-14.1-14.4, NFR-2.5 | 3 | +| `test_smart_collections` | FR-15.1-15.5 | 3 | +| `test_album_art` | FR-16.1-16.4 | 3 | +| `test_prefetch` | FR-19.1-19.4 | 3 | +| `test_plugins` | FR-23.1-23.5 | 4 | +| `test_control_api` | FR-17.1-17.5 | 4 | +| `test_events` | FR-18.1-18.4 | 4 | +| `test_performance` | NFR-1.1-1.5, NFR-2.1-2.4 | All | +| `test_scalability` | NFR-3.1-3.4 | All | + +--- + +## 7. Risk Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| FUSE performance ceiling | Medium | High | Early benchmarking in Week 3; consider io_uring bypass | +| symphonia format gaps | Low | Medium | Document unsupported formats; allow format plugins | +| WASM plugin overhead | Medium | Low | Benchmark; native fallback for perf-critical plugins | +| S3 latency variability | High | Medium | Aggressive prefetch; health-based routing | +| SQLite contention | Low | Medium | WAL mode; read-only connections; consider sled fallback | + +--- + +## 8. Deferred Requirements (Post-MVP) + +The following P1 requirements are explicitly deferred from the 11-week plan: + +### FR-21: External Metadata Sources [P1] → Phase 5 + +| ID | Requirement | Rationale for Deferral | +|----|-------------|------------------------| +| FR-21.1 | MusicBrainz integration | Requires rate limiting, caching, API key management | +| FR-21.2 | Discogs integration | OAuth flow complexity; lower priority than core function | +| FR-21.3 | Last.fm integration | Scrobbling is write operation; conflicts with read-only model | +| FR-21.4 | AcoustID fingerprinting | CPU-intensive; requires chromaprint dependency | +| FR-21.5 | Custom metadata plugins | Covered by FR-23 plugin system; metadata plugin type needed | + +**Recommended Phase 5 scope (Weeks 12-13):** +- MusicBrainz lookup plugin (native) +- Metadata plugin trait in musicfs-plugins +- Example WASM metadata plugin + +### FR-22: Import & Migration [P1] → Phase 5 + +| ID | Requirement | Rationale for Deferral | +|----|-------------|------------------------| +| FR-22.1 | Import from beets database | Requires beets schema knowledge; optional for fresh installs | +| FR-22.2 | Import from iTunes library | XML parsing; Apple ecosystem complexity | +| FR-22.3 | Export library metadata | Nice-to-have; users can query SQLite directly | + +**Recommended Phase 5 scope (Week 14):** +- `musicfs import beets /path/to/beets.db` CLI command +- Export to JSON/CSV via CLI + +### Why Defer? + +1. **Core functionality first**: MVP must prove FUSE + caching + multi-origin works +2. **Plugin system prerequisite**: FR-21.5 depends on FR-23 being stable +3. **Limited impact**: Users can populate library from origins without import +4. **Scope control**: 11 weeks is aggressive; buffer for unknowns + +--- + +## 9. Definition of Done + +A feature is complete when: + +1. [ ] Implementation matches architecture.md specification +2. [ ] Unit tests pass with >80% coverage +3. [ ] Integration tests pass +4. [ ] Performance meets NFR targets (benchmarked) +5. [ ] Documentation updated +6. [ ] No new `unsafe` without justification +7. [ ] Clippy clean (deny warnings) +8. [ ] Code reviewed + +--- + +## 9. Getting Started (Week 1, Day 1) + +```bash +# Create workspace +mkdir -p musicfs && cd musicfs +cargo init --name musicfs + +# Create crate structure +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 + +# Initialize each crate +for crate in crates/*; do + cargo init --lib "$crate" +done +cargo init crates/musicfs-cli + +# Add to workspace Cargo.toml +cat > Cargo.toml << 'EOF' +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +rust-version = "1.75" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" +thiserror = "1" +tracing = "0.1" +serde = { version = "1", features = ["derive"] } +EOF + +# Create flake.nix for reproducible builds +# ... (nix flake with rust-overlay) +``` + +Ready to begin implementation? diff --git a/docs/v2/plans/week-01-foundation.md b/docs/v2/plans/week-01-foundation.md new file mode 100644 index 0000000..8d5394d --- /dev/null +++ b/docs/v2/plans/week-01-foundation.md @@ -0,0 +1,1126 @@ +# 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 + +```bash +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` + +```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` + +```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 + +```bash +cd crates/musicfs-core +cargo init --lib +``` + +### 2.2 Create `Cargo.toml` + +```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` + +```rust +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` + +```rust +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 = std::result::Result; +``` + +### 2.5 Create `src/types.rs` + +```rust +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) -> 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, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub genre: Option, + pub year: Option, + pub track: Option, // "track" per architecture 4.3.6 + pub disc: Option, // "disc" per architecture 4.3.6 + pub duration_ms: Option, + pub bitrate: Option, + pub sample_rate: Option, + 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, + pub audio: Option, +} + +/// 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` + +```rust +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, +} + +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 { + 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` + +```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` + +```rust +mod filesystem; + +pub use filesystem::MusicFs; +``` + +### 3.3 Create `src/filesystem.rs` + +```rust +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, + // cache: Arc, + // tree: Arc>, +} + +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, + 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, + _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, + 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, + _uid: Option, + _gid: Option, + _size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + 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` + +```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` + +```rust +mod traits; +mod local; + +pub use traits::Origin; +pub use local::LocalOrigin; +``` + +### 4.3 Create `src/traits.rs` + +```rust +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>; + + /// Get file/directory metadata + async fn stat(&self, path: &Path) -> Result; + + /// Read file content at offset + async fn read(&self, path: &Path, offset: u64, size: u32) -> Result>; + + /// Check if path exists + async fn exists(&self, path: &Path) -> Result; + + /// Health check + async fn health(&self) -> HealthStatus; + + /// Get a reader for streaming large files + async fn open_read(&self, path: &Path) -> Result>; + + /// 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; +} + +/// Callback for file change notifications +pub type WatchCallback = Box; + +/// 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` + +```rust +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, root: impl Into) -> 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> { + 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 { + 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> { + 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 { + 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> { + 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 { + // 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` + +```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: + +```rust +#[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`: + +```rust +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 + +```bash +# 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. diff --git a/docs/v2/plans/week-02-metadata.md b/docs/v2/plans/week-02-metadata.md new file mode 100644 index 0000000..252f2b4 --- /dev/null +++ b/docs/v2/plans/week-02-metadata.md @@ -0,0 +1,750 @@ +# Week 2: Metadata Extraction + +**Phase**: 1 (MVP) +**Prerequisites**: Week 1 (Foundation) +**Estimated effort**: 5 days + +--- + +## Objective + +Implement audio metadata extraction using symphonia and create SQLite schema for metadata cache. + +--- + +## Deliverables + +| Task | Crate | Files | Done | +|------|-------|-------|------| +| Audio parsing | musicfs-metadata | `lib.rs`, `parser.rs` | [ ] | +| Format handlers | musicfs-metadata | `formats/*.rs` | [ ] | +| SQLite schema | musicfs-cache | `schema.sql`, `db.rs` | [ ] | +| Metadata cache | musicfs-cache | `metadata.rs` | [ ] | + +--- + +## Task 1: Metadata Parser (`musicfs-metadata`) + +### 1.1 Create `Cargo.toml` + +```toml +[package] +name = "musicfs-metadata" +version.workspace = true +edition.workspace = true + +[dependencies] +musicfs-core = { path = "../musicfs-core" } +symphonia = { version = "0.5", features = ["all"] } +thiserror.workspace = true +tracing.workspace = true +``` + +### 1.2 Create `src/lib.rs` + +```rust +mod parser; + +pub use parser::MetadataParser; +``` + +### 1.3 Create `src/parser.rs` + +```rust +use musicfs_core::{AudioFormat, AudioMeta, Result, Error}; +use std::io::{Read, Seek}; +use std::path::Path; +use symphonia::core::codecs::CODEC_TYPE_NULL; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use tracing::debug; + +/// Metadata extraction using symphonia (FR-6.1-6.5) +pub struct MetadataParser; + +impl MetadataParser { + pub fn new() -> Self { + Self + } + + /// Extract metadata from audio file + pub fn parse_file(&self, path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let ext = path.extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + self.parse_reader(file, ext) + } + + /// Extract metadata from reader + pub fn parse_reader( + &self, + reader: R, + extension: &str, + ) -> Result { + let mss = MediaSourceStream::new(Box::new(reader), Default::default()); + + let mut hint = Hint::new(); + if !extension.is_empty() { + hint.with_extension(extension); + } + + let format_opts = FormatOptions { + enable_gapless: false, + ..Default::default() + }; + + let metadata_opts = MetadataOptions::default(); + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &format_opts, &metadata_opts) + .map_err(|e| Error::Cache(format!("Failed to probe format: {}", e)))?; + + let mut format = probed.format; + let mut audio_meta = AudioMeta { + format: AudioFormat::from_extension(extension), + ..Default::default() + }; + + // Extract metadata from container + if let Some(metadata) = format.metadata().current() { + self.extract_tags(&mut audio_meta, metadata); + } + + // Also check probed metadata + if let Some(metadata) = probed.metadata.current() { + self.extract_tags(&mut audio_meta, metadata); + } + + // Get duration and codec info from track + if let Some(track) = format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL) { + let params = &track.codec_params; + + if let Some(n_frames) = params.n_frames { + if let Some(sample_rate) = params.sample_rate { + audio_meta.duration_ms = Some((n_frames as u64 * 1000) / sample_rate as u64); + audio_meta.sample_rate = Some(sample_rate); + } + } + + if let Some(bits_per_sample) = params.bits_per_sample { + if let Some(sample_rate) = params.sample_rate { + if let Some(channels) = params.channels { + audio_meta.bitrate = Some( + bits_per_sample * sample_rate * channels.count() as u32 / 1000 + ); + } + } + } + } + + debug!("Parsed metadata: {:?}", audio_meta); + Ok(audio_meta) + } + + fn extract_tags(&self, meta: &mut AudioMeta, metadata: &symphonia::core::meta::MetadataRevision) { + use symphonia::core::meta::StandardTagKey; + + for tag in metadata.tags() { + if let Some(std_key) = tag.std_key { + let value = tag.value.to_string(); + match std_key { + StandardTagKey::TrackTitle => meta.title = Some(value), + StandardTagKey::Artist => meta.artist = Some(value), + StandardTagKey::Album => meta.album = Some(value), + StandardTagKey::AlbumArtist => meta.album_artist = Some(value), + StandardTagKey::Genre => meta.genre = Some(value), + StandardTagKey::TrackNumber => { + meta.track = value.split('/').next() + .and_then(|s| s.parse().ok()); + } + StandardTagKey::DiscNumber => { + meta.disc = value.split('/').next() + .and_then(|s| s.parse().ok()); + } + StandardTagKey::Date | StandardTagKey::ReleaseDate => { + meta.year = value.chars().take(4).collect::() + .parse().ok(); + } + _ => {} + } + } + } + } +} + +impl Default for MetadataParser { + fn default() -> Self { + Self::new() + } +} +``` + +--- + +## Task 2: Cache Database (`musicfs-cache`) + +### 2.1 Create `Cargo.toml` + +```toml +[package] +name = "musicfs-cache" +version.workspace = true +edition.workspace = true + +[dependencies] +musicfs-core = { path = "../musicfs-core" } +rusqlite = { workspace = true, features = ["bundled"] } +sled.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde.workspace = true +rmp-serde.workspace = true +``` + +### 2.2 Create `src/lib.rs` + +```rust +mod db; +mod metadata; + +pub use db::Database; +pub use metadata::MetadataCache; +``` + +### 2.3 Create `src/schema.sql` + +```sql +-- MusicFS Metadata Cache Schema +-- Per architecture.md section 4.3.6 +-- NOTE: Chunk index stored in sled (chunks.sled/), NOT SQLite + +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +PRAGMA synchronous = NORMAL; + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + origin_id TEXT NOT NULL, + real_path TEXT NOT NULL, + virtual_path TEXT NOT NULL, + + -- Audio metadata (FR-6.1-6.5) + title TEXT, + artist TEXT, + album TEXT, + album_artist TEXT, + genre TEXT, + year INTEGER, + track INTEGER, + disc INTEGER, + duration_ms INTEGER, + bitrate INTEGER, + sample_rate INTEGER, + format TEXT, + + -- Sync state + origin_mtime INTEGER NOT NULL, + origin_size INTEGER NOT NULL, + content_hash TEXT, -- hex-encoded xxHash64 + chunk_manifest BLOB, -- msgpack: [(chunk_hash, offset, size)] + last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + + UNIQUE(origin_id, real_path) +); + +CREATE TABLE IF NOT EXISTS artwork ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + art_type TEXT NOT NULL, -- 'front', 'back', 'disc' + chunk_hash TEXT NOT NULL, -- hex-encoded reference to CAS + width INTEGER, + height INTEGER, + mime_type TEXT, + UNIQUE(file_id, art_type) +); + +CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + query_json TEXT NOT NULL, -- smart collection query + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +-- Indexes for performance (NFR-1.1, NFR-1.2) +CREATE INDEX IF NOT EXISTS idx_files_virtual ON files(virtual_path); +CREATE INDEX IF NOT EXISTS idx_files_artist_album ON files(artist, album); +CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash); +CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path); -- FR-7.3 +CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id); +CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync); +CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id); +``` + +### 2.4 Create `src/db.rs` + +```rust +use musicfs_core::{AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result, VirtualPath}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::{debug, info}; + +const SCHEMA: &str = include_str!("schema.sql"); + +/// SQLite database connection manager +pub struct Database { + conn: Arc>, +} + +impl Database { + /// Open or create database at path + pub fn open(path: &Path) -> Result { + info!("Opening database at {:?}", path); + + let conn = Connection::open(path) + .map_err(|e| Error::Database(e.to_string()))?; + + // Execute schema + conn.execute_batch(SCHEMA) + .map_err(|e| Error::Database(e.to_string()))?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Open in-memory database (for testing) + pub fn open_memory() -> Result { + let conn = Connection::open_in_memory() + .map_err(|e| Error::Database(e.to_string()))?; + + conn.execute_batch(SCHEMA) + .map_err(|e| Error::Database(e.to_string()))?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + /// Insert or update file metadata + pub fn upsert_file( + &self, + origin_id: &OriginId, + real_path: &Path, + virtual_path: &VirtualPath, + audio_meta: &AudioMeta, + origin_mtime: SystemTime, + origin_size: u64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + + let mtime_secs = origin_mtime + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + conn.execute( + r#" + INSERT INTO files ( + origin_id, real_path, virtual_path, + title, artist, album, album_artist, genre, + year, track, disc, + duration_ms, bitrate, sample_rate, format, + origin_mtime, origin_size + ) VALUES ( + ?1, ?2, ?3, + ?4, ?5, ?6, ?7, ?8, + ?9, ?10, ?11, + ?12, ?13, ?14, ?15, + ?16, ?17 + ) + ON CONFLICT(origin_id, real_path) DO UPDATE SET + virtual_path = excluded.virtual_path, + title = excluded.title, + artist = excluded.artist, + album = excluded.album, + album_artist = excluded.album_artist, + genre = excluded.genre, + year = excluded.year, + track = excluded.track, + disc = excluded.disc, + duration_ms = excluded.duration_ms, + bitrate = excluded.bitrate, + sample_rate = excluded.sample_rate, + format = excluded.format, + origin_mtime = excluded.origin_mtime, + origin_size = excluded.origin_size, + last_sync = strftime('%s', 'now') + "#, + params![ + &origin_id.0, + real_path.to_string_lossy(), + virtual_path.as_str(), + &audio_meta.title, + &audio_meta.artist, + &audio_meta.album, + &audio_meta.album_artist, + &audio_meta.genre, + &audio_meta.year, + &audio_meta.track, + &audio_meta.disc, + &audio_meta.duration_ms.map(|d| d as i64), + &audio_meta.bitrate, + &audio_meta.sample_rate, + format!("{:?}", audio_meta.format), + mtime_secs, + origin_size as i64, + ], + ).map_err(|e| Error::Database(e.to_string()))?; + + let id = conn.last_insert_rowid(); + debug!("Upserted file {} with id {}", virtual_path.as_str(), id); + + Ok(FileId(id)) + } + + /// Get file by virtual path + pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result> { + let conn = self.conn.lock().unwrap(); + + conn.query_row( + r#" + SELECT id, origin_id, real_path, virtual_path, + title, artist, album, album_artist, genre, + year, track, disc, + duration_ms, bitrate, sample_rate, format, + origin_mtime, origin_size, content_hash + FROM files + WHERE virtual_path = ?1 + "#, + params![path.as_str()], + |row| { + Ok(FileMeta { + id: FileId(row.get(0)?), + real_path: RealPath { + origin_id: OriginId(row.get(1)?), + path: PathBuf::from(row.get::<_, String>(2)?), + }, + virtual_path: VirtualPath::new(row.get::<_, String>(3)?), + audio: Some(AudioMeta { + title: row.get(4)?, + artist: row.get(5)?, + album: row.get(6)?, + album_artist: row.get(7)?, + genre: row.get(8)?, + year: row.get(9)?, + track: row.get(10)?, + disc: row.get(11)?, + duration_ms: row.get::<_, Option>(12)?.map(|d| d as u64), + bitrate: row.get(13)?, + sample_rate: row.get(14)?, + format: musicfs_core::AudioFormat::Unknown, // TODO: parse + }), + size: row.get::<_, i64>(17)? as u64, + mtime: UNIX_EPOCH + std::time::Duration::from_secs(row.get::<_, i64>(16)? as u64), + content_hash: row.get::<_, Option>>(18)? + .map(|b| ContentHash(b.try_into().unwrap_or([0; 8]))), + }) + }, + ) + .optional() + .map_err(|e| Error::Database(e.to_string())) + } + + /// Get file by ID + pub fn get_file_by_id(&self, id: FileId) -> Result> { + let conn = self.conn.lock().unwrap(); + + conn.query_row( + "SELECT virtual_path FROM files WHERE id = ?1", + params![id.0], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| Error::Database(e.to_string()))? + .map(|vp| self.get_file_by_virtual_path(&VirtualPath::new(vp))) + .transpose() + .map(|o| o.flatten()) + } + + /// List all files for an origin + pub fn list_files(&self, origin_id: &OriginId) -> Result> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT virtual_path FROM files WHERE origin_id = ?1" + ).map_err(|e| Error::Database(e.to_string()))?; + + let paths: Vec = stmt + .query_map(params![&origin_id.0], |row| row.get(0)) + .map_err(|e| Error::Database(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + drop(stmt); + drop(conn); + + paths + .into_iter() + .filter_map(|p| self.get_file_by_virtual_path(&VirtualPath::new(p)).ok().flatten()) + .collect::>() + .pipe(Ok) + } + + /// Delete file by ID + pub fn delete_file(&self, id: FileId) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM files WHERE id = ?1", params![id.0]) + .map_err(|e| Error::Database(e.to_string()))?; + Ok(()) + } + + /// Get file count + pub fn file_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0)) + .map(|c| c as u64) + .map_err(|e| Error::Database(e.to_string())) + } +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} + +impl Pipe for T {} +``` + +### 2.5 Create `src/metadata.rs` + +```rust +use crate::db::Database; +use musicfs_core::{AudioMeta, FileMeta, OriginId, Result, VirtualPath}; +use std::path::Path; +use std::sync::Arc; +use std::time::SystemTime; + +/// High-level metadata cache interface +pub struct MetadataCache { + db: Arc, +} + +impl MetadataCache { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Store file metadata + pub fn store( + &self, + origin_id: &OriginId, + real_path: &Path, + virtual_path: &VirtualPath, + audio_meta: &AudioMeta, + origin_mtime: SystemTime, + origin_size: u64, + ) -> Result<()> { + self.db.upsert_file( + origin_id, + real_path, + virtual_path, + audio_meta, + origin_mtime, + origin_size, + )?; + Ok(()) + } + + /// Lookup by virtual path + pub fn lookup(&self, path: &VirtualPath) -> Result> { + self.db.get_file_by_virtual_path(path) + } + + /// Check if file exists and is fresh + pub fn is_fresh( + &self, + origin_id: &OriginId, + real_path: &Path, + current_mtime: SystemTime, + ) -> Result { + // TODO: Compare mtime with cached value + Ok(false) + } +} +``` + +--- + +## Tests + +### Unit Tests (`musicfs-metadata`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_parse_flac_metadata() { + // Use a real FLAC file for testing + // For CI, embed a small test file or use a fixture + let parser = MetadataParser::new(); + + // This would need a real file path + // let meta = parser.parse_file(Path::new("test.flac")).unwrap(); + // assert!(meta.title.is_some()); + } + + #[test] + fn test_audio_format_detection() { + assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac); + assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3); + assert_eq!(AudioFormat::from_extension("opus"), AudioFormat::Opus); + } +} +``` + +### Unit Tests (`musicfs-cache`) + +```rust +#[cfg(test)] +mod tests { + use super::*; + use musicfs_core::{AudioFormat, AudioMeta, OriginId, VirtualPath}; + use std::time::UNIX_EPOCH; + + #[test] + fn test_database_creation() { + let db = Database::open_memory().unwrap(); + assert_eq!(db.file_count().unwrap(), 0); + } + + #[test] + fn test_upsert_and_retrieve() { + let db = Database::open_memory().unwrap(); + + let origin_id = OriginId::from("local"); + let real_path = Path::new("/music/test.flac"); + let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac"); + let audio_meta = AudioMeta { + title: Some("Track".to_string()), + artist: Some("Artist".to_string()), + album: Some("Album".to_string()), + track: Some(1), + format: AudioFormat::Flac, + ..Default::default() + }; + + let id = db.upsert_file( + &origin_id, + real_path, + &virtual_path, + &audio_meta, + UNIX_EPOCH, + 1000, + ).unwrap(); + + let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap(); + assert_eq!(retrieved.id, id); + assert_eq!(retrieved.audio.as_ref().unwrap().title, Some("Track".to_string())); + } + + #[test] + fn test_upsert_updates_existing() { + let db = Database::open_memory().unwrap(); + + let origin_id = OriginId::from("local"); + let real_path = Path::new("/music/test.flac"); + let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac"); + + // First insert + let meta1 = AudioMeta { + title: Some("Original".to_string()), + ..Default::default() + }; + db.upsert_file(&origin_id, real_path, &virtual_path, &meta1, UNIX_EPOCH, 1000).unwrap(); + + // Update + let meta2 = AudioMeta { + title: Some("Updated".to_string()), + ..Default::default() + }; + db.upsert_file(&origin_id, real_path, &virtual_path, &meta2, UNIX_EPOCH, 1000).unwrap(); + + // Should still be 1 file + assert_eq!(db.file_count().unwrap(), 1); + + // Title should be updated + let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap(); + assert_eq!(retrieved.audio.as_ref().unwrap().title, Some("Updated".to_string())); + } + + #[test] + fn test_metadata_persistence() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + + // Create and populate + { + let db = Database::open(&db_path).unwrap(); + db.upsert_file( + &OriginId::from("local"), + Path::new("/test.flac"), + &VirtualPath::new("/Test.flac"), + &AudioMeta::default(), + UNIX_EPOCH, + 100, + ).unwrap(); + } + + // Reopen and verify + { + let db = Database::open(&db_path).unwrap(); + assert_eq!(db.file_count().unwrap(), 1); + } + } +} +``` + +--- + +## Exit Criteria + +- [ ] Parse FLAC metadata (title, artist, album, track, duration) +- [ ] Parse MP3 metadata (ID3v2 and ID3v1 fallback) +- [ ] Parse Opus/Vorbis comments +- [ ] Parse M4A/AAC metadata +- [ ] Handle missing metadata gracefully (FR-6.5) +- [ ] SQLite schema creates all tables +- [ ] Metadata persists across daemon restarts (FR-7.4) +- [ ] Upsert correctly updates existing records + +--- + +## Verification Commands + +```bash +# Run metadata tests +cargo test -p musicfs-metadata + +# Run cache tests +cargo test -p musicfs-cache + +# Test with real audio file +cargo run --example parse_metadata -- /path/to/test.flac +``` + +--- + +## Next Week + +Week 3 will implement the virtual path resolver and tree cache, connecting metadata to the FUSE operations. diff --git a/docs/v2/plans/week-03-virtual-tree.md b/docs/v2/plans/week-03-virtual-tree.md new file mode 100644 index 0000000..9739633 --- /dev/null +++ b/docs/v2/plans/week-03-virtual-tree.md @@ -0,0 +1,1069 @@ +# Week 3: Virtual Tree & Basic Ops + +**Phase**: 1 (MVP) +**Prerequisites**: Week 2 (Metadata Extraction) +**Estimated effort**: 5 days + +--- + +## Objective + +Implement virtual path resolver, tree cache, and connect to FUSE operations (readdir, stat, read). + +--- + +## Deliverables + +| Task | Crate | Files | Done | +|------|-------|-------|------| +| Virtual path resolver | musicfs-core | `resolver.rs` | [ ] | +| Tree cache | musicfs-cache | `tree.rs` | [ ] | +| readdir implementation | musicfs-fuse | `ops/readdir.rs` | [ ] | +| stat implementation | musicfs-fuse | `ops/stat.rs` | [ ] | +| open/read implementation | musicfs-fuse | `ops/read.rs` | [ ] | + +--- + +## Task 1: Virtual Path Resolver + +### 1.1 Add to `musicfs-core/src/lib.rs` + +```rust +pub mod resolver; +pub use resolver::{PathResolver, PathTemplate}; +``` + +### 1.2 Create `musicfs-core/src/resolver.rs` + +```rust +use crate::{AudioMeta, VirtualPath}; +use std::path::PathBuf; + +/// Path template configuration (per architecture 4.3.1) +/// Uses $variable syntax: $artist, $album, $title, $track, $year, $genre, +/// $format, $format_upper, $disc +#[derive(Debug, Clone)] +pub struct PathTemplate { + /// Template pattern using $var syntax + /// Default: "$artist/$album ($year) [$format_upper]/$track - $title.$format" + pub pattern: String, + /// Fallback for missing artist + pub fallback_artist: String, + /// Fallback for missing album + pub fallback_album: String, + /// Fallback for missing title + pub fallback_title: String, + /// Fallback for missing year + pub fallback_year: String, +} + +impl Default for PathTemplate { + fn default() -> Self { + Self { + // Architecture 4.3.1 default template + pattern: "$artist/$album ($year) [$format_upper]/$track - $title.$format".to_string(), + fallback_artist: "Unknown Artist".to_string(), + fallback_album: "Unknown Album".to_string(), + fallback_title: "Unknown Track".to_string(), + fallback_year: "Unknown".to_string(), + } + } +} + +/// Virtual path resolver (FR-5.1, FR-5.2) +pub struct PathResolver { + template: PathTemplate, +} + +impl PathResolver { + pub fn new(template: PathTemplate) -> Self { + Self { template } + } + + /// Resolve real path + metadata to virtual path + /// Uses $var syntax per architecture 4.3.1 + pub fn resolve(&self, meta: &AudioMeta, extension: &str) -> VirtualPath { + let artist = meta.artist.as_deref() + .unwrap_or(&self.template.fallback_artist); + let album = meta.album.as_deref() + .unwrap_or(&self.template.fallback_album); + let title = meta.title.as_deref() + .unwrap_or(&self.template.fallback_title); + let year = meta.year + .map(|y| y.to_string()) + .unwrap_or_else(|| self.template.fallback_year.clone()); + let track = meta.track.unwrap_or(0); + let disc = meta.disc.unwrap_or(1); + let genre = meta.genre.as_deref().unwrap_or("Unknown"); + let format = extension.to_lowercase(); + let format_upper = extension.to_uppercase(); + + // Sanitize path components + let artist = sanitize_path_component(artist); + let album = sanitize_path_component(album); + let title = sanitize_path_component(title); + let genre = sanitize_path_component(genre); + + // Replace $var patterns (architecture 4.3.1 grammar) + let path = self.template.pattern + .replace("$artist", &artist) + .replace("$album", &album) + .replace("$title", &title) + .replace("$track", &format!("{:02}", track)) + .replace("$disc", &disc.to_string()) + .replace("$year", &year) + .replace("$genre", &genre) + .replace("$format_upper", &format_upper) + .replace("$format", &format); + + VirtualPath::new(path) + } + + /// Parse template pattern to extract directory structure + pub fn get_hierarchy(&self) -> Vec<&str> { + // Returns ["artist", "album"] for default template + self.template.pattern + .split('/') + .filter(|s| s.starts_with('{') && s.ends_with('}')) + .map(|s| s.trim_matches(|c| c == '{' || c == '}')) + .filter(|s| *s == "artist" || *s == "album") + .collect() + } +} + +impl Default for PathResolver { + fn default() -> Self { + Self::new(PathTemplate::default()) + } +} + +/// Sanitize string for use in file path +fn sanitize_path_component(s: &str) -> String { + s.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + '\0' => '_', + c => c, + }) + .collect::() + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AudioFormat; + + #[test] + fn test_resolve_complete_metadata() { + let resolver = PathResolver::default(); + let meta = AudioMeta { + artist: Some("Metallica".to_string()), + album: Some("Master of Puppets".to_string()), + title: Some("Battery".to_string()), + track: Some(1), + year: Some(1986), + format: AudioFormat::Flac, + ..Default::default() + }; + + let path = resolver.resolve(&meta, "flac"); + // Default template: $artist/$album ($year) [$format_upper]/$track - $title.$format + assert_eq!(path.as_str(), "Metallica/Master of Puppets (1986) [FLAC]/01 - Battery.flac"); + } + + #[test] + fn test_resolve_missing_album() { + let resolver = PathResolver::default(); + let meta = AudioMeta { + artist: Some("Artist".to_string()), + title: Some("Track".to_string()), + track: Some(5), + ..Default::default() + }; + + let path = resolver.resolve(&meta, "mp3"); + // Missing year uses fallback + assert_eq!(path.as_str(), "Artist/Unknown Album (Unknown) [MP3]/05 - Track.mp3"); + } + + #[test] + fn test_sanitize_special_chars() { + let resolver = PathResolver::default(); + let meta = AudioMeta { + artist: Some("AC/DC".to_string()), + album: Some("Who Made Who?".to_string()), + title: Some("Test:Track".to_string()), + track: Some(1), + year: Some(1986), + ..Default::default() + }; + + let path = resolver.resolve(&meta, "flac"); + // Should not contain problematic characters + assert!(!path.as_str().contains(':')); + assert!(!path.as_str().contains('?')); + // AC/DC becomes AC_DC + assert!(path.as_str().contains("AC_DC")); + } + + #[test] + fn test_custom_template() { + let template = PathTemplate { + pattern: "$genre/$artist - $album/$track $title.$format".to_string(), + ..Default::default() + }; + let resolver = PathResolver::new(template); + let meta = AudioMeta { + artist: Some("Artist".to_string()), + album: Some("Album".to_string()), + title: Some("Song".to_string()), + genre: Some("Rock".to_string()), + track: Some(3), + ..Default::default() + }; + + let path = resolver.resolve(&meta, "flac"); + assert_eq!(path.as_str(), "Rock/Artist - Album/03 Song.flac"); + } +} +``` + +--- + +## Task 2: Tree Cache + +### 2.1 Add to `musicfs-cache/src/lib.rs` + +```rust +mod tree; +pub use tree::{VirtualTree, VirtualNode, TreeBuilder}; +``` + +### 2.2 Create `musicfs-cache/src/tree.rs` + +```rust +use musicfs_core::{FileId, FileMeta, VirtualPath}; +use std::collections::{BTreeMap, HashMap}; +use std::ffi::{OsStr, OsString}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; +use std::time::{Duration, SystemTime}; + +/// Inode number type +pub type Inode = u64; + +/// Root inode (FUSE convention) +pub const ROOT_INODE: Inode = 1; + +/// Node in the virtual tree +#[derive(Debug)] +pub enum VirtualNode { + Directory(DirNode), + File(FileNode), +} + +impl VirtualNode { + pub fn inode(&self) -> Inode { + match self { + VirtualNode::Directory(d) => d.inode, + VirtualNode::File(f) => f.inode, + } + } + + pub fn name(&self) -> &OsStr { + match self { + VirtualNode::Directory(d) => &d.name, + VirtualNode::File(f) => &f.name, + } + } + + pub fn is_dir(&self) -> bool { + matches!(self, VirtualNode::Directory(_)) + } +} + +/// Directory node +#[derive(Debug)] +pub struct DirNode { + pub inode: Inode, + pub name: OsString, + pub children: BTreeMap, + pub mtime: SystemTime, +} + +/// File node +#[derive(Debug)] +pub struct FileNode { + pub inode: Inode, + pub name: OsString, + pub file_id: FileId, + pub size: u64, + pub mtime: SystemTime, +} + +/// Refresh policy configuration (FR-9.3) +#[derive(Debug, Clone)] +pub struct RefreshPolicy { + /// Time-to-live for cached entries + pub ttl: Duration, + /// Whether to refresh on access + pub refresh_on_access: bool, + /// Background refresh interval (None = disabled) + pub background_interval: Option, +} + +impl Default for RefreshPolicy { + fn default() -> Self { + Self { + ttl: Duration::from_secs(300), // 5 minutes + refresh_on_access: false, + background_interval: None, + } + } +} + +/// Virtual filesystem tree (FR-9.1-9.4) +pub struct VirtualTree { + nodes: HashMap, + path_to_inode: HashMap, + next_inode: AtomicU64, + /// Last refresh timestamp + last_refresh: RwLock, + /// Refresh policy (FR-9.3) + refresh_policy: RefreshPolicy, +} + +impl VirtualTree { + pub fn new() -> Self { + Self::with_policy(RefreshPolicy::default()) + } + + pub fn with_policy(policy: RefreshPolicy) -> Self { + let mut tree = Self { + nodes: HashMap::new(), + path_to_inode: HashMap::new(), + next_inode: AtomicU64::new(ROOT_INODE + 1), + last_refresh: RwLock::new(SystemTime::now()), + refresh_policy: policy, + }; + + // Create root directory + tree.nodes.insert(ROOT_INODE, VirtualNode::Directory(DirNode { + inode: ROOT_INODE, + name: OsString::from(""), + children: BTreeMap::new(), + mtime: SystemTime::now(), + })); + tree.path_to_inode.insert(VirtualPath::new("/"), ROOT_INODE); + + tree + } + + /// Allocate a new inode number + fn alloc_inode(&self) -> Inode { + self.next_inode.fetch_add(1, Ordering::SeqCst) + } + + /// Get node by inode + pub fn get(&self, inode: Inode) -> Option<&VirtualNode> { + self.nodes.get(&inode) + } + + /// Get node by path + pub fn get_by_path(&self, path: &VirtualPath) -> Option<&VirtualNode> { + self.path_to_inode.get(path).and_then(|ino| self.nodes.get(ino)) + } + + /// Lookup child in directory + pub fn lookup(&self, parent_inode: Inode, name: &OsStr) -> Option { + if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&parent_inode) { + dir.children.get(name).copied() + } else { + None + } + } + + /// List directory children + pub fn readdir(&self, inode: Inode) -> Option> { + if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&inode) { + Some(dir.children.iter().map(|(name, &ino)| { + let is_dir = self.nodes.get(&ino).map(|n| n.is_dir()).unwrap_or(false); + (name.clone(), ino, is_dir) + }).collect()) + } else { + None + } + } + + /// Insert a file into the tree + pub fn insert_file(&mut self, meta: &FileMeta) -> Inode { + let path = &meta.virtual_path; + + // Ensure parent directories exist + let parent_inode = self.ensure_parents(path); + + // Create file node + let inode = self.alloc_inode(); + let name = std::path::Path::new(path.as_str()) + .file_name() + .unwrap_or_default() + .to_os_string(); + + let file_node = FileNode { + inode, + name: name.clone(), + file_id: meta.id, + size: meta.size, + mtime: meta.mtime, + }; + + self.nodes.insert(inode, VirtualNode::File(file_node)); + self.path_to_inode.insert(path.clone(), inode); + + // Add to parent + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) { + dir.children.insert(name, inode); + } + + inode + } + + /// Ensure all parent directories exist for a path + fn ensure_parents(&mut self, path: &VirtualPath) -> Inode { + let path_str = path.as_str(); + let components: Vec<&str> = path_str + .trim_start_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + + if components.len() <= 1 { + return ROOT_INODE; + } + + let mut current_inode = ROOT_INODE; + let mut current_path = String::from("/"); + + // Process all but the last component (which is the file) + for component in &components[..components.len() - 1] { + current_path.push_str(component); + + let vpath = VirtualPath::new(¤t_path); + + if let Some(&existing) = self.path_to_inode.get(&vpath) { + current_inode = existing; + } else { + // Create directory + let new_inode = self.alloc_inode(); + let name = OsString::from(*component); + + let dir_node = DirNode { + inode: new_inode, + name: name.clone(), + children: BTreeMap::new(), + mtime: SystemTime::now(), + }; + + self.nodes.insert(new_inode, VirtualNode::Directory(dir_node)); + self.path_to_inode.insert(vpath, new_inode); + + // Add to parent + if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) { + parent.children.insert(name, new_inode); + } + + current_inode = new_inode; + } + + current_path.push('/'); + } + + current_inode + } + + /// Remove a file from the tree + pub fn remove_file(&mut self, path: &VirtualPath) -> Option { + let inode = self.path_to_inode.remove(path)?; + + if let Some(VirtualNode::File(file)) = self.nodes.remove(&inode) { + // Remove from parent + let parent_path = std::path::Path::new(path.as_str()) + .parent() + .map(|p| VirtualPath::new(p.to_string_lossy())) + .unwrap_or_else(|| VirtualPath::new("/")); + + if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) { + if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) { + dir.children.remove(&file.name); + } + } + + Some(file.file_id) + } else { + None + } + } + + /// Get total file count + pub fn file_count(&self) -> usize { + self.nodes.values().filter(|n| matches!(n, VirtualNode::File(_))).count() + } + + /// Get total directory count + pub fn dir_count(&self) -> usize { + self.nodes.values().filter(|n| matches!(n, VirtualNode::Directory(_))).count() + } + + // ========================================================================= + // Refresh Policy (FR-9.3, FR-9.4) + // ========================================================================= + + /// Check if tree needs refresh based on policy (FR-9.3) + pub fn needs_refresh(&self) -> bool { + let last = *self.last_refresh.read().unwrap(); + last.elapsed().unwrap_or(Duration::MAX) > self.refresh_policy.ttl + } + + /// Force refresh - clears tree for rebuild (FR-9.4) + /// Call this from signal handler or API endpoint + pub fn force_refresh(&mut self) { + // Keep root, clear everything else + self.nodes.retain(|&ino, _| ino == ROOT_INODE); + self.path_to_inode.retain(|p, _| p.as_str() == "/"); + + // Reset root children + if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) { + root.children.clear(); + } + + *self.last_refresh.write().unwrap() = SystemTime::now(); + } + + /// Mark tree as refreshed + pub fn mark_refreshed(&self) { + *self.last_refresh.write().unwrap() = SystemTime::now(); + } + + /// Get current refresh policy + pub fn refresh_policy(&self) -> &RefreshPolicy { + &self.refresh_policy + } +} + +impl Default for VirtualTree { + fn default() -> Self { + Self::new() + } +} + +/// Builder for constructing tree from database +pub struct TreeBuilder { + tree: VirtualTree, +} + +impl TreeBuilder { + pub fn new() -> Self { + Self { + tree: VirtualTree::new(), + } + } + + pub fn add_file(&mut self, meta: &FileMeta) { + self.tree.insert_file(meta); + } + + pub fn build(self) -> VirtualTree { + self.tree + } +} + +impl Default for TreeBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use musicfs_core::{AudioMeta, OriginId, RealPath}; + use std::path::PathBuf; + + fn make_file_meta(id: i64, vpath: &str) -> FileMeta { + FileMeta { + id: FileId(id), + virtual_path: VirtualPath::new(vpath), + real_path: RealPath { + origin_id: OriginId::from("test"), + path: PathBuf::from("/test"), + }, + size: 1000, + mtime: SystemTime::now(), + content_hash: None, + audio: None, + } + } + + #[test] + fn test_tree_creation() { + let tree = VirtualTree::new(); + assert!(tree.get(ROOT_INODE).is_some()); + } + + #[test] + fn test_insert_file() { + let mut tree = VirtualTree::new(); + let meta = make_file_meta(1, "/Artist/Album/Track.flac"); + tree.insert_file(&meta); + + assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/Artist/Album")).is_some()); + assert!(tree.get_by_path(&VirtualPath::new("/Artist/Album/Track.flac")).is_some()); + } + + #[test] + fn test_readdir() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac")); + tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac")); + + let root_children = tree.readdir(ROOT_INODE).unwrap(); + assert_eq!(root_children.len(), 1); + assert_eq!(root_children[0].0, "Artist"); + } + + #[test] + fn test_lookup() { + let mut tree = VirtualTree::new(); + tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac")); + + let artist_inode = tree.lookup(ROOT_INODE, OsStr::new("Artist")).unwrap(); + assert!(tree.lookup(artist_inode, OsStr::new("Album")).is_some()); + } +} +``` + +--- + +## Task 3: FUSE Operations + +### 3.1 Refactor `musicfs-fuse/src/lib.rs` + +```rust +mod filesystem; +mod ops; + +pub use filesystem::MusicFs; +``` + +### 3.2 Create `musicfs-fuse/src/ops/mod.rs` + +```rust +pub mod readdir; +pub mod stat; +pub mod read; +``` + +### 3.3 Update `musicfs-fuse/src/filesystem.rs` + +```rust +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, + ReplyEntry, ReplyOpen, Request, FUSE_ROOT_ID, +}; +use musicfs_cache::{Database, MetadataCache, VirtualTree, VirtualNode, ROOT_INODE}; +use musicfs_core::{Error, Result, VirtualPath}; +use musicfs_origins::Origin; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tracing::{debug, error, info, warn}; + +const TTL: Duration = Duration::from_secs(1); +const BLOCK_SIZE: u32 = 512; + +/// Main FUSE filesystem implementation +pub struct MusicFs { + tree: Arc>, + cache: Arc, + origins: Arc>>, + uid: u32, + gid: u32, +} + +impl MusicFs { + pub fn new( + tree: Arc>, + cache: Arc, + origins: Arc>>, + ) -> Self { + Self { + tree, + cache, + origins, + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + } + } + + fn node_to_attr(&self, node: &VirtualNode) -> FileAttr { + match node { + VirtualNode::Directory(dir) => FileAttr { + ino: dir.inode, + size: 0, + blocks: 0, + atime: dir.mtime, + mtime: dir.mtime, + ctime: dir.mtime, + crtime: dir.mtime, + kind: FileType::Directory, + perm: 0o755, + nlink: 2, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: BLOCK_SIZE, + flags: 0, + }, + VirtualNode::File(file) => FileAttr { + ino: file.inode, + size: file.size, + blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64, + atime: file.mtime, + mtime: file.mtime, + ctime: file.mtime, + crtime: file.mtime, + kind: FileType::RegularFile, + perm: 0o644, + nlink: 1, + uid: self.uid, + gid: self.gid, + rdev: 0, + blksize: BLOCK_SIZE, + flags: 0, + }, + } + } +} + +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); + + let tree = self.tree.read().unwrap(); + + if let Some(inode) = tree.lookup(parent, name) { + if let Some(node) = tree.get(inode) { + let attr = self.node_to_attr(node); + reply.entry(&TTL, &attr, 0); + return; + } + } + + reply.error(libc::ENOENT); + } + + fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) { + debug!("getattr(ino={})", ino); + + let tree = self.tree.read().unwrap(); + + if let Some(node) = tree.get(ino) { + let attr = self.node_to_attr(node); + reply.attr(&TTL, &attr); + } else { + 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); + + let tree = self.tree.read().unwrap(); + + if let Some(children) = tree.readdir(ino) { + let mut entries = vec![ + (ino, FileType::Directory, "."), + (if ino == ROOT_INODE { ROOT_INODE } else { ROOT_INODE }, FileType::Directory, ".."), + ]; + + for (name, child_ino, is_dir) in children { + let kind = if is_dir { FileType::Directory } else { FileType::RegularFile }; + entries.push((child_ino, kind, name.to_str().unwrap_or("?"))); + } + + for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) { + if reply.add(*inode, (i + 1) as i64, *kind, name) { + break; + } + } + + reply.ok(); + } else { + 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 (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; + } + + let tree = self.tree.read().unwrap(); + + if tree.get(ino).is_some() { + reply.opened(0, 0); + } else { + reply.error(libc::ENOENT); + } + } + + fn read( + &mut self, + _req: &Request, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + debug!("read(ino={}, offset={}, size={})", ino, offset, size); + + // Get file info from tree + let file_id = { + let tree = self.tree.read().unwrap(); + if let Some(VirtualNode::File(file)) = tree.get(ino) { + file.file_id + } else { + reply.error(libc::ENOENT); + return; + } + }; + + // TODO: Read from cache/origin + // For now, return empty data + reply.data(&[]); + } + + fn release( + &mut self, + _req: &Request, + ino: u64, + _fh: u64, + _flags: i32, + _lock_owner: Option, + _flush: bool, + reply: fuser::ReplyEmpty, + ) { + debug!("release(ino={})", ino); + reply.ok(); + } + + // Write operations - always 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, 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); + } +} +``` + +--- + +## Task 4: Benchmarks + +### 4.1 Create `benches/tree_ops.rs` + +```rust +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use musicfs_cache::{VirtualTree, TreeBuilder}; +use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath}; +use std::path::PathBuf; +use std::time::SystemTime; + +fn make_file_meta(id: i64, artist: &str, album: &str, track: u32) -> FileMeta { + let vpath = format!("/{}/{}/{:02} - Track.flac", artist, album, track); + FileMeta { + id: FileId(id), + virtual_path: VirtualPath::new(&vpath), + real_path: RealPath { + origin_id: OriginId::from("test"), + path: PathBuf::from("/test"), + }, + size: 30_000_000, + mtime: SystemTime::now(), + content_hash: None, + audio: None, + } +} + +fn build_tree(n_artists: usize, albums_per_artist: usize, tracks_per_album: usize) -> VirtualTree { + let mut builder = TreeBuilder::new(); + let mut id = 1i64; + + for a in 0..n_artists { + for b in 0..albums_per_artist { + for t in 0..tracks_per_album { + let meta = make_file_meta( + id, + &format!("Artist {}", a), + &format!("Album {}", b), + t as u32 + 1, + ); + builder.add_file(&meta); + id += 1; + } + } + } + + builder.build() +} + +fn bench_stat_cached(c: &mut Criterion) { + // 100 artists * 10 albums * 12 tracks = 12,000 files + let tree = build_tree(100, 10, 12); + let path = VirtualPath::new("/Artist 50/Album 5/06 - Track.flac"); + + c.bench_function("stat_cached", |b| { + b.iter(|| { + black_box(tree.get_by_path(&path)) + }) + }); + + // Target: <1ms p99 (NFR-1.1) +} + +fn bench_readdir_1000_entries(c: &mut Criterion) { + // Create tree with 1000 artists (1000 entries in root) + let tree = build_tree(1000, 1, 1); + + c.bench_function("readdir_1000", |b| { + b.iter(|| { + black_box(tree.readdir(1)) // ROOT_INODE + }) + }); + + // Target: <10ms p99 (NFR-1.2) +} + +fn bench_lookup(c: &mut Criterion) { + let tree = build_tree(100, 10, 12); + + c.bench_function("lookup", |b| { + b.iter(|| { + let artist = tree.lookup(1, std::ffi::OsStr::new("Artist 50")); + if let Some(a) = artist { + black_box(tree.lookup(a, std::ffi::OsStr::new("Album 5"))); + } + }) + }); +} + +fn bench_mount_time(c: &mut Criterion) { + c.bench_function("tree_build_12k", |b| { + b.iter(|| { + black_box(build_tree(100, 10, 12)) + }) + }); + + // Target: <100ms, Max: <500ms (NFR-1.7) +} + +criterion_group!(benches, bench_stat_cached, bench_readdir_1000_entries, bench_lookup, bench_mount_time); +criterion_main!(benches); +``` + +--- + +## Tests + +### Integration Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fuse_readdir() { + // Build tree and verify readdir returns correct entries + } + + #[test] + fn test_fuse_stat() { + // Verify stat returns correct size/mtime + } + + #[test] + fn test_read_only_enforcement() { + // Verify write ops return EROFS + } +} +``` + +--- + +## Exit Criteria + +- [ ] Virtual tree built from metadata +- [ ] `ls /mnt/music` shows Artist directories +- [ ] `ls /mnt/music/Artist/Album` shows tracks +- [ ] `stat` returns correct size, mtime +- [ ] Write operations return EROFS (FR-4.1) +- [ ] stat benchmark <1ms p99 (NFR-1.1) +- [ ] readdir benchmark <10ms p99 (NFR-1.2) +- [ ] Mount completes in <500ms (NFR-1.7) + +--- + +## Next Week + +Week 4 will implement CAS storage and chunk caching, enabling actual file reads.