sochdb_core/
sochfs_metadata.rs

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