Skip to main content

sochdb_core/
sochfs_metadata.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! SochFS Metadata Layer (Task 11)
19//!
20//! Persists filesystem metadata via LSCS system tables for ACID guarantees:
21//! - _sys_fs_inodes: Inode storage (id, type, size, blocks, permissions, timestamps)
22//! - _sys_fs_dirs: Directory entries (parent_id, name, child_inode)
23//! - _sys_fs_superblock: Filesystem metadata (root, next_inode, next_block)
24//!
25//! ## System Table Schema
26//!
27//! ```text
28//! _sys_fs_inodes:
29//! ┌──────────┬───────────┬────────┬────────────┬─────────────┐
30//! │ inode_id │ file_type │ size   │ blocks     │ permissions │
31//! │ u64 PK   │ u8        │ u64    │ blob       │ u16         │
32//! └──────────┴───────────┴────────┴────────────┴─────────────┘
33//!
34//! _sys_fs_dirs:
35//! ┌────────────┬────────────┬──────────────┐
36//! │ parent_id  │ name       │ child_inode  │
37//! │ u64        │ text       │ u64          │
38//! └────────────┴────────────┴──────────────┘
39//! ```
40//!
41//! ## Path Resolution: O(d) where d = depth
42//!
43//! ```text
44//! resolve("/docs/report.toon"):
45//!   1. lookup(_sys_fs_dirs, parent=1, name="docs") → inode=2
46//!   2. lookup(_sys_fs_dirs, parent=2, name="report.toon") → inode=7
47//!   3. lookup(_sys_fs_inodes, inode=7) → Inode{...}
48//! ```
49
50use parking_lot::RwLock;
51use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53
54use crate::vfs::{DirEntry, FileType, Inode, InodeId, Permissions, Superblock};
55use crate::{Result, SochDBError};
56
57/// System table names
58pub const SYSTEM_TABLE_INODES: &str = "_sys_fs_inodes";
59pub const SYSTEM_TABLE_DIRS: &str = "_sys_fs_dirs";
60pub const SYSTEM_TABLE_SUPERBLOCK: &str = "_sys_fs_superblock";
61
62/// Root inode ID
63pub const ROOT_INODE: InodeId = 1;
64
65/// Serialized inode for storage
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct InodeRow {
68    pub inode_id: u64,
69    pub file_type: u8,
70    pub size: u64,
71    pub blocks: Vec<u64>,
72    pub permissions: u16,
73    pub created_us: u64,
74    pub modified_us: u64,
75    pub accessed_us: u64,
76    pub nlink: u32,
77    pub symlink_target: Option<String>,
78    pub soch_schema: Option<String>,
79}
80
81impl From<&Inode> for InodeRow {
82    fn from(inode: &Inode) -> Self {
83        Self {
84            inode_id: inode.id,
85            file_type: inode.file_type as u8,
86            size: inode.size,
87            blocks: inode.blocks.clone(),
88            permissions: inode.permissions.to_mode() as u16,
89            created_us: inode.created_us,
90            modified_us: inode.modified_us,
91            accessed_us: inode.accessed_us,
92            nlink: inode.nlink,
93            symlink_target: inode.symlink_target.clone(),
94            soch_schema: inode.soch_schema.clone(),
95        }
96    }
97}
98
99impl InodeRow {
100    pub fn to_inode(&self) -> Inode {
101        Inode {
102            id: self.inode_id,
103            file_type: match self.file_type {
104                1 => FileType::Regular,
105                2 => FileType::Directory,
106                3 => FileType::Symlink,
107                4 => FileType::SochDocument,
108                _ => FileType::Regular,
109            },
110            size: self.size,
111            blocks: self.blocks.clone(),
112            permissions: Permissions::from_mode(self.permissions as u8),
113            created_us: self.created_us,
114            modified_us: self.modified_us,
115            accessed_us: self.accessed_us,
116            nlink: self.nlink,
117            symlink_target: self.symlink_target.clone(),
118            soch_schema: self.soch_schema.clone(),
119        }
120    }
121
122    /// Serialize to bytes
123    pub fn to_bytes(&self) -> Vec<u8> {
124        bincode::serialize(self).unwrap_or_default()
125    }
126
127    /// Deserialize from bytes
128    pub fn from_bytes(data: &[u8]) -> Result<Self> {
129        bincode::deserialize(data).map_err(|e| SochDBError::Serialization(e.to_string()))
130    }
131}
132
133/// Serialized directory entry for storage
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct DirEntryRow {
136    pub parent_id: u64,
137    pub name: String,
138    pub child_inode: u64,
139    pub file_type: u8,
140}
141
142impl DirEntryRow {
143    pub fn new(
144        parent_id: InodeId,
145        name: String,
146        child_inode: InodeId,
147        file_type: FileType,
148    ) -> Self {
149        Self {
150            parent_id,
151            name,
152            child_inode,
153            file_type: file_type as u8,
154        }
155    }
156
157    /// Serialize to bytes
158    pub fn to_bytes(&self) -> Vec<u8> {
159        bincode::serialize(self).unwrap_or_default()
160    }
161
162    /// Deserialize from bytes
163    pub fn from_bytes(data: &[u8]) -> Result<Self> {
164        bincode::deserialize(data).map_err(|e| SochDBError::Serialization(e.to_string()))
165    }
166
167    /// Convert to key for lookup (parent_id + name)
168    pub fn to_key(&self) -> Vec<u8> {
169        let mut key = Vec::with_capacity(8 + self.name.len());
170        key.extend_from_slice(&self.parent_id.to_le_bytes());
171        key.extend_from_slice(self.name.as_bytes());
172        key
173    }
174
175    /// Create key from parent and name
176    pub fn make_key(parent_id: InodeId, name: &str) -> Vec<u8> {
177        let mut key = Vec::with_capacity(8 + name.len());
178        key.extend_from_slice(&parent_id.to_le_bytes());
179        key.extend_from_slice(name.as_bytes());
180        key
181    }
182}
183
184/// WAL operation for filesystem changes
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub enum FsWalOp {
187    /// Create inode
188    CreateInode(InodeRow),
189    /// Update inode
190    UpdateInode(InodeRow),
191    /// Delete inode
192    DeleteInode(u64),
193    /// Add directory entry
194    AddDirEntry(DirEntryRow),
195    /// Remove directory entry
196    RemoveDirEntry { parent_id: u64, name: String },
197    /// Update superblock
198    UpdateSuperblock(Superblock),
199}
200
201impl FsWalOp {
202    /// Serialize to bytes
203    pub fn to_bytes(&self) -> Vec<u8> {
204        bincode::serialize(self).unwrap_or_default()
205    }
206
207    /// Deserialize from bytes
208    pub fn from_bytes(data: &[u8]) -> Result<Self> {
209        bincode::deserialize(data).map_err(|e| SochDBError::Serialization(e.to_string()))
210    }
211}
212
213/// SochFS Metadata Store
214///
215/// Manages filesystem metadata with ACID guarantees via LSCS system tables.
216#[allow(clippy::type_complexity)]
217pub struct FsMetadataStore {
218    /// Inode cache
219    inodes: RwLock<HashMap<InodeId, Inode>>,
220    /// Directory cache (parent_id -> entries)
221    directories: RwLock<HashMap<InodeId, Vec<DirEntryRow>>>,
222    /// Superblock
223    superblock: RwLock<Superblock>,
224    /// Write callback for persistence
225    write_fn: Box<dyn Fn(&[u8], &[u8]) -> Result<()> + Send + Sync>,
226    /// WAL callback
227    wal_fn: Box<dyn Fn(&FsWalOp) -> Result<()> + Send + Sync>,
228    /// Dirty inodes (need flush)
229    #[allow(dead_code)]
230    dirty_inodes: RwLock<Vec<InodeId>>,
231}
232
233impl FsMetadataStore {
234    /// Create a new metadata store
235    pub fn new<W, L>(write_fn: W, wal_fn: L) -> Self
236    where
237        W: Fn(&[u8], &[u8]) -> Result<()> + Send + Sync + 'static,
238        L: Fn(&FsWalOp) -> Result<()> + Send + Sync + 'static,
239    {
240        let superblock = Superblock::new("toonfs");
241        let root_inode = Inode::new_directory(ROOT_INODE);
242
243        let mut inodes = HashMap::new();
244        inodes.insert(ROOT_INODE, root_inode);
245
246        Self {
247            inodes: RwLock::new(inodes),
248            directories: RwLock::new(HashMap::new()),
249            superblock: RwLock::new(superblock),
250            write_fn: Box::new(write_fn),
251            wal_fn: Box::new(wal_fn),
252            dirty_inodes: RwLock::new(Vec::new()),
253        }
254    }
255
256    /// Initialize filesystem (create root if needed)
257    pub fn init(&self) -> Result<()> {
258        let sb = self.superblock.read();
259        let root = Inode::new_directory(sb.root_inode);
260        drop(sb);
261
262        // Create root inode
263        let row = InodeRow::from(&root);
264        (self.wal_fn)(&FsWalOp::CreateInode(row.clone()))?;
265
266        let key = root.id.to_le_bytes();
267        (self.write_fn)(&key, &row.to_bytes())?;
268
269        self.inodes.write().insert(root.id, root);
270        Ok(())
271    }
272
273    /// Get inode by ID
274    pub fn get_inode(&self, id: InodeId) -> Option<Inode> {
275        self.inodes.read().get(&id).cloned()
276    }
277
278    /// Create a new inode
279    pub fn create_inode(&self, file_type: FileType) -> Result<Inode> {
280        let id = {
281            let mut sb = self.superblock.write();
282            sb.alloc_inode()
283        };
284
285        let inode = match file_type {
286            FileType::Regular => Inode::new_file(id),
287            FileType::Directory => Inode::new_directory(id),
288            FileType::Symlink => Inode::new_symlink(id, String::new()),
289            FileType::SochDocument => Inode::new_toon(id, String::new()),
290        };
291
292        // WAL first
293        let row = InodeRow::from(&inode);
294        (self.wal_fn)(&FsWalOp::CreateInode(row.clone()))?;
295
296        // Then persist
297        let key = inode.id.to_le_bytes();
298        (self.write_fn)(&key, &row.to_bytes())?;
299
300        // Cache
301        self.inodes.write().insert(id, inode.clone());
302
303        Ok(inode)
304    }
305
306    /// Update an inode
307    pub fn update_inode(&self, inode: &Inode) -> Result<()> {
308        let row = InodeRow::from(inode);
309
310        // WAL first
311        (self.wal_fn)(&FsWalOp::UpdateInode(row.clone()))?;
312
313        // Then persist
314        let key = inode.id.to_le_bytes();
315        (self.write_fn)(&key, &row.to_bytes())?;
316
317        // Update cache
318        self.inodes.write().insert(inode.id, inode.clone());
319
320        Ok(())
321    }
322
323    /// Delete an inode
324    pub fn delete_inode(&self, id: InodeId) -> Result<()> {
325        // WAL first
326        (self.wal_fn)(&FsWalOp::DeleteInode(id))?;
327
328        // Remove from cache
329        self.inodes.write().remove(&id);
330
331        Ok(())
332    }
333
334    /// Add directory entry
335    pub fn add_dir_entry(
336        &self,
337        parent_id: InodeId,
338        name: &str,
339        child_id: InodeId,
340        file_type: FileType,
341    ) -> Result<()> {
342        let entry = DirEntryRow::new(parent_id, name.to_string(), child_id, file_type);
343
344        // WAL first
345        (self.wal_fn)(&FsWalOp::AddDirEntry(entry.clone()))?;
346
347        // Then persist
348        let key = entry.to_key();
349        (self.write_fn)(&key, &entry.to_bytes())?;
350
351        // Update cache
352        self.directories
353            .write()
354            .entry(parent_id)
355            .or_default()
356            .push(entry);
357
358        Ok(())
359    }
360
361    /// Remove directory entry
362    pub fn remove_dir_entry(&self, parent_id: InodeId, name: &str) -> Result<()> {
363        // WAL first
364        (self.wal_fn)(&FsWalOp::RemoveDirEntry {
365            parent_id,
366            name: name.to_string(),
367        })?;
368
369        // Update cache
370        let mut dirs = self.directories.write();
371        if let Some(entries) = dirs.get_mut(&parent_id) {
372            entries.retain(|e| e.name != name);
373        }
374
375        Ok(())
376    }
377
378    /// List directory entries
379    pub fn list_dir(&self, parent_id: InodeId) -> Vec<DirEntryRow> {
380        self.directories
381            .read()
382            .get(&parent_id)
383            .cloned()
384            .unwrap_or_default()
385    }
386
387    /// Lookup entry in directory
388    pub fn lookup(&self, parent_id: InodeId, name: &str) -> Option<InodeId> {
389        self.directories
390            .read()
391            .get(&parent_id)
392            .and_then(|entries| entries.iter().find(|e| e.name == name))
393            .map(|e| e.child_inode)
394    }
395
396    /// Resolve path to inode
397    ///
398    /// O(d) where d = path depth
399    pub fn resolve_path(&self, path: &str) -> Result<InodeId> {
400        let path = path.trim_start_matches('/');
401        if path.is_empty() {
402            return Ok(ROOT_INODE);
403        }
404
405        let mut current = ROOT_INODE;
406        for component in path.split('/') {
407            if component.is_empty() || component == "." {
408                continue;
409            }
410            if component == ".." {
411                // Get parent from directory
412                if let Some(inode) = self.get_inode(current)
413                    && inode.is_dir()
414                {
415                    // Look up parent in _sys_fs_dirs would go here
416                    // For now, just stay at root if we can't go up
417                }
418                continue;
419            }
420
421            current = self.lookup(current, component).ok_or_else(|| {
422                SochDBError::NotFound(format!("Path component not found: {}", component))
423            })?;
424        }
425
426        Ok(current)
427    }
428
429    /// Create file in directory
430    pub fn create_file(&self, parent_id: InodeId, name: &str) -> Result<Inode> {
431        // Check parent is directory
432        let parent = self
433            .get_inode(parent_id)
434            .ok_or_else(|| SochDBError::NotFound("Parent not found".into()))?;
435
436        if !parent.is_dir() {
437            return Err(SochDBError::InvalidArgument(
438                "Parent is not a directory".into(),
439            ));
440        }
441
442        // Check name doesn't exist
443        if self.lookup(parent_id, name).is_some() {
444            return Err(SochDBError::InvalidArgument("File already exists".into()));
445        }
446
447        // Create inode
448        let inode = self.create_inode(FileType::Regular)?;
449
450        // Add directory entry
451        self.add_dir_entry(parent_id, name, inode.id, FileType::Regular)?;
452
453        Ok(inode)
454    }
455
456    /// Create directory in parent
457    pub fn create_dir(&self, parent_id: InodeId, name: &str) -> Result<Inode> {
458        // Check parent is directory
459        let parent = self
460            .get_inode(parent_id)
461            .ok_or_else(|| SochDBError::NotFound("Parent not found".into()))?;
462
463        if !parent.is_dir() {
464            return Err(SochDBError::InvalidArgument(
465                "Parent is not a directory".into(),
466            ));
467        }
468
469        // Check name doesn't exist
470        if self.lookup(parent_id, name).is_some() {
471            return Err(SochDBError::InvalidArgument(
472                "Directory already exists".into(),
473            ));
474        }
475
476        // Create inode
477        let inode = self.create_inode(FileType::Directory)?;
478
479        // Add directory entry
480        self.add_dir_entry(parent_id, name, inode.id, FileType::Directory)?;
481
482        Ok(inode)
483    }
484
485    /// Delete file or empty directory
486    pub fn delete(&self, parent_id: InodeId, name: &str) -> Result<()> {
487        let child_id = self
488            .lookup(parent_id, name)
489            .ok_or_else(|| SochDBError::NotFound("Entry not found".into()))?;
490
491        let child = self
492            .get_inode(child_id)
493            .ok_or_else(|| SochDBError::NotFound("Inode not found".into()))?;
494
495        // If directory, must be empty
496        if child.is_dir() {
497            let entries = self.list_dir(child_id);
498            if !entries.is_empty() {
499                return Err(SochDBError::InvalidArgument("Directory not empty".into()));
500            }
501        }
502
503        // Remove directory entry
504        self.remove_dir_entry(parent_id, name)?;
505
506        // Delete inode
507        self.delete_inode(child_id)?;
508
509        Ok(())
510    }
511
512    /// Get superblock
513    pub fn superblock(&self) -> Superblock {
514        self.superblock.read().clone()
515    }
516
517    /// Update superblock
518    pub fn update_superblock(&self, sb: &Superblock) -> Result<()> {
519        (self.wal_fn)(&FsWalOp::UpdateSuperblock(sb.clone()))?;
520        *self.superblock.write() = sb.clone();
521        Ok(())
522    }
523
524    /// Recover from WAL operations
525    pub fn replay_wal_op(&self, op: &FsWalOp) -> Result<()> {
526        match op {
527            FsWalOp::CreateInode(row) => {
528                self.inodes.write().insert(row.inode_id, row.to_inode());
529            }
530            FsWalOp::UpdateInode(row) => {
531                self.inodes.write().insert(row.inode_id, row.to_inode());
532            }
533            FsWalOp::DeleteInode(id) => {
534                self.inodes.write().remove(id);
535            }
536            FsWalOp::AddDirEntry(entry) => {
537                self.directories
538                    .write()
539                    .entry(entry.parent_id)
540                    .or_default()
541                    .push(entry.clone());
542            }
543            FsWalOp::RemoveDirEntry { parent_id, name } => {
544                let mut dirs = self.directories.write();
545                if let Some(entries) = dirs.get_mut(parent_id) {
546                    entries.retain(|e| &e.name != name);
547                }
548            }
549            FsWalOp::UpdateSuperblock(sb) => {
550                *self.superblock.write() = sb.clone();
551            }
552        }
553        Ok(())
554    }
555}
556
557/// SochFS - Complete filesystem layer
558#[allow(clippy::type_complexity)]
559pub struct SochFS {
560    /// Metadata store
561    metadata: FsMetadataStore,
562    /// Block storage callback
563    block_write_fn: Box<dyn Fn(u64, &[u8]) -> Result<u64> + Send + Sync>,
564    /// Block read callback
565    block_read_fn: Box<dyn Fn(u64, usize) -> Result<Vec<u8>> + Send + Sync>,
566}
567
568impl SochFS {
569    /// Create new SochFS instance
570    pub fn new<W, L, BW, BR>(write_fn: W, wal_fn: L, block_write_fn: BW, block_read_fn: BR) -> Self
571    where
572        W: Fn(&[u8], &[u8]) -> Result<()> + Send + Sync + 'static,
573        L: Fn(&FsWalOp) -> Result<()> + Send + Sync + 'static,
574        BW: Fn(u64, &[u8]) -> Result<u64> + Send + Sync + 'static,
575        BR: Fn(u64, usize) -> Result<Vec<u8>> + Send + Sync + 'static,
576    {
577        Self {
578            metadata: FsMetadataStore::new(write_fn, wal_fn),
579            block_write_fn: Box::new(block_write_fn),
580            block_read_fn: Box::new(block_read_fn),
581        }
582    }
583
584    /// Initialize filesystem
585    pub fn init(&self) -> Result<()> {
586        self.metadata.init()
587    }
588
589    /// Resolve path to inode
590    pub fn resolve(&self, path: &str) -> Result<InodeId> {
591        self.metadata.resolve_path(path)
592    }
593
594    /// Get inode
595    pub fn get_inode(&self, id: InodeId) -> Option<Inode> {
596        self.metadata.get_inode(id)
597    }
598
599    /// Create file
600    pub fn create_file(&self, path: &str) -> Result<Inode> {
601        let (parent_path, name) = split_path(path);
602        let parent_id = self.metadata.resolve_path(&parent_path)?;
603        self.metadata.create_file(parent_id, &name)
604    }
605
606    /// Create directory
607    pub fn mkdir(&self, path: &str) -> Result<Inode> {
608        let (parent_path, name) = split_path(path);
609        let parent_id = self.metadata.resolve_path(&parent_path)?;
610        self.metadata.create_dir(parent_id, &name)
611    }
612
613    /// Delete file or empty directory
614    pub fn delete(&self, path: &str) -> Result<()> {
615        let (parent_path, name) = split_path(path);
616        let parent_id = self.metadata.resolve_path(&parent_path)?;
617        self.metadata.delete(parent_id, &name)
618    }
619
620    /// List directory
621    pub fn readdir(&self, path: &str) -> Result<Vec<DirEntry>> {
622        let inode_id = self.metadata.resolve_path(path)?;
623        let inode = self
624            .metadata
625            .get_inode(inode_id)
626            .ok_or_else(|| SochDBError::NotFound("Directory not found".into()))?;
627
628        if !inode.is_dir() {
629            return Err(SochDBError::InvalidArgument("Not a directory".into()));
630        }
631
632        let entries = self.metadata.list_dir(inode_id);
633        Ok(entries
634            .into_iter()
635            .map(|e| DirEntry {
636                name: e.name,
637                inode: e.child_inode,
638                file_type: match e.file_type {
639                    1 => FileType::Regular,
640                    2 => FileType::Directory,
641                    3 => FileType::Symlink,
642                    4 => FileType::SochDocument,
643                    _ => FileType::Regular,
644                },
645            })
646            .collect())
647    }
648
649    /// Write file data
650    pub fn write_file(&self, path: &str, data: &[u8]) -> Result<usize> {
651        let inode_id = self.metadata.resolve_path(path)?;
652        let mut inode = self
653            .metadata
654            .get_inode(inode_id)
655            .ok_or_else(|| SochDBError::NotFound("File not found".into()))?;
656
657        if !inode.is_file() && !inode.is_toon() {
658            return Err(SochDBError::InvalidArgument("Not a regular file".into()));
659        }
660
661        // Write data block
662        let block_id = (self.block_write_fn)(inode_id, data)?;
663
664        // Update inode
665        inode.blocks = vec![block_id];
666        inode.size = data.len() as u64;
667        inode.touch();
668
669        self.metadata.update_inode(&inode)?;
670
671        Ok(data.len())
672    }
673
674    /// Read file data
675    pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
676        let inode_id = self.metadata.resolve_path(path)?;
677        let inode = self
678            .metadata
679            .get_inode(inode_id)
680            .ok_or_else(|| SochDBError::NotFound("File not found".into()))?;
681
682        if !inode.is_file() && !inode.is_toon() {
683            return Err(SochDBError::InvalidArgument("Not a regular file".into()));
684        }
685
686        if inode.blocks.is_empty() {
687            return Ok(Vec::new());
688        }
689
690        // Read data from blocks
691        let mut data = Vec::new();
692        for &block_id in &inode.blocks {
693            let block_data = (self.block_read_fn)(block_id, inode.size as usize)?;
694            data.extend(block_data);
695        }
696
697        Ok(data)
698    }
699
700    /// Get file stat
701    pub fn stat(&self, path: &str) -> Result<Inode> {
702        let inode_id = self.metadata.resolve_path(path)?;
703        self.metadata
704            .get_inode(inode_id)
705            .ok_or_else(|| SochDBError::NotFound("File not found".into()))
706    }
707}
708
709/// Split path into parent and name
710fn split_path(path: &str) -> (String, String) {
711    let path = path.trim_end_matches('/');
712    if let Some(pos) = path.rfind('/') {
713        let parent = if pos == 0 { "/" } else { &path[..pos] };
714        let name = &path[pos + 1..];
715        (parent.to_string(), name.to_string())
716    } else {
717        ("/".to_string(), path.to_string())
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use std::sync::atomic::{AtomicU64, Ordering};
725
726    #[test]
727    fn test_inode_row_serialization() {
728        let inode = Inode::new_file(42);
729        let row = InodeRow::from(&inode);
730        let bytes = row.to_bytes();
731        let recovered = InodeRow::from_bytes(&bytes).unwrap();
732
733        assert_eq!(recovered.inode_id, 42);
734        assert_eq!(recovered.file_type, FileType::Regular as u8);
735    }
736
737    #[test]
738    fn test_dir_entry_key() {
739        let entry = DirEntryRow::new(1, "test.txt".to_string(), 42, FileType::Regular);
740        let key = entry.to_key();
741        let expected_key = DirEntryRow::make_key(1, "test.txt");
742        assert_eq!(key, expected_key);
743    }
744
745    #[test]
746    fn test_path_split() {
747        assert_eq!(
748            split_path("/foo/bar"),
749            ("/foo".to_string(), "bar".to_string())
750        );
751        assert_eq!(split_path("/foo"), ("/".to_string(), "foo".to_string()));
752        assert_eq!(split_path("foo"), ("/".to_string(), "foo".to_string()));
753    }
754
755    #[test]
756    fn test_metadata_store() {
757        let store = FsMetadataStore::new(|_, _| Ok(()), |_| Ok(()));
758        store.init().unwrap();
759
760        // Create file
761        let file = store.create_file(ROOT_INODE, "test.txt").unwrap();
762        assert!(file.is_file());
763
764        // Lookup
765        let found = store.lookup(ROOT_INODE, "test.txt");
766        assert_eq!(found, Some(file.id));
767
768        // List dir
769        let entries = store.list_dir(ROOT_INODE);
770        assert_eq!(entries.len(), 1);
771        assert_eq!(entries[0].name, "test.txt");
772    }
773
774    #[test]
775    fn test_path_resolution() {
776        let store = FsMetadataStore::new(|_, _| Ok(()), |_| Ok(()));
777        store.init().unwrap();
778
779        // Create nested structure: /docs/reports/summary.txt
780        let docs = store.create_dir(ROOT_INODE, "docs").unwrap();
781        let reports = store.create_dir(docs.id, "reports").unwrap();
782        let _summary = store.create_file(reports.id, "summary.txt").unwrap();
783
784        // Resolve paths
785        assert_eq!(store.resolve_path("/").unwrap(), ROOT_INODE);
786        assert_eq!(store.resolve_path("/docs").unwrap(), docs.id);
787        assert_eq!(store.resolve_path("/docs/reports").unwrap(), reports.id);
788    }
789
790    #[test]
791    fn test_toonfs() {
792        let block_counter = AtomicU64::new(0);
793        let blocks: std::sync::Arc<RwLock<HashMap<u64, Vec<u8>>>> =
794            std::sync::Arc::new(RwLock::new(HashMap::new()));
795        let blocks_write = blocks.clone();
796        let blocks_read = blocks.clone();
797
798        let fs = SochFS::new(
799            |_, _| Ok(()),
800            |_| Ok(()),
801            move |_inode, data: &[u8]| {
802                let id = block_counter.fetch_add(1, Ordering::SeqCst);
803                blocks_write.write().insert(id, data.to_vec());
804                Ok(id)
805            },
806            move |id, _size| {
807                blocks_read
808                    .read()
809                    .get(&id)
810                    .cloned()
811                    .ok_or_else(|| SochDBError::NotFound("Block not found".into()))
812            },
813        );
814
815        fs.init().unwrap();
816
817        // Create and write file
818        fs.create_file("/test.txt").unwrap();
819        fs.write_file("/test.txt", b"Hello, SochFS!").unwrap();
820
821        // Read back
822        let data = fs.read_file("/test.txt").unwrap();
823        assert_eq!(data, b"Hello, SochFS!");
824
825        // Stat
826        let stat = fs.stat("/test.txt").unwrap();
827        assert_eq!(stat.size, 14);
828    }
829}