ragfs_fuse/
inode.rs

1//! Inode management for virtual and real files.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6/// Reserved inode numbers.
7pub const ROOT_INO: u64 = 1;
8pub const RAGFS_DIR_INO: u64 = 2;
9pub const QUERY_DIR_INO: u64 = 3;
10pub const SEARCH_DIR_INO: u64 = 4;
11pub const INDEX_FILE_INO: u64 = 5;
12pub const CONFIG_FILE_INO: u64 = 6;
13pub const REINDEX_FILE_INO: u64 = 7;
14pub const SIMILAR_DIR_INO: u64 = 8;
15pub const HELP_FILE_INO: u64 = 9;
16
17// Phase 2: .ops/ directory for agent operations
18pub const OPS_DIR_INO: u64 = 10;
19pub const OPS_CREATE_INO: u64 = 11;
20pub const OPS_DELETE_INO: u64 = 12;
21pub const OPS_MOVE_INO: u64 = 13;
22pub const OPS_BATCH_INO: u64 = 14;
23pub const OPS_RESULT_INO: u64 = 15;
24
25// Phase 3: .safety/ directory for protection
26pub const SAFETY_DIR_INO: u64 = 20;
27pub const TRASH_DIR_INO: u64 = 21;
28pub const HISTORY_FILE_INO: u64 = 22;
29pub const UNDO_FILE_INO: u64 = 23;
30
31// Phase 4: .semantic/ directory for intelligent operations
32pub const SEMANTIC_DIR_INO: u64 = 30;
33pub const ORGANIZE_FILE_INO: u64 = 31;
34pub const SIMILAR_OPS_FILE_INO: u64 = 32;
35pub const CLEANUP_FILE_INO: u64 = 33;
36pub const DEDUPE_FILE_INO: u64 = 34;
37pub const PENDING_DIR_INO: u64 = 35;
38pub const APPROVE_FILE_INO: u64 = 36;
39pub const REJECT_FILE_INO: u64 = 37;
40
41pub const FIRST_REAL_INO: u64 = 1000;
42
43/// Type of inode.
44#[derive(Debug, Clone)]
45pub enum InodeKind {
46    /// Root of mounted filesystem
47    Root,
48    /// Virtual .ragfs control directory
49    RagfsDir,
50    /// Virtual .query directory
51    QueryDir,
52    /// Dynamic query result file
53    QueryResult { query: String },
54    /// Virtual .search directory
55    SearchDir,
56    /// Search results as symlink directory
57    SearchResult { query: String },
58    /// .index status file
59    IndexStatus,
60    /// .config file
61    Config,
62    /// .reindex trigger file
63    Reindex,
64    /// .help documentation file
65    Help,
66    /// .similar directory
67    SimilarDir,
68    /// Similar file lookup
69    SimilarLookup { source_path: PathBuf },
70    /// Real file/directory passthrough
71    Real { path: PathBuf, underlying_ino: u64 },
72
73    // Phase 2: .ops/ virtual directory
74    /// .ops directory for agent operations
75    OpsDir,
76    /// .ops/.create - write "path\ncontent" to create file
77    OpsCreate,
78    /// .ops/.delete - write "path" to delete file
79    OpsDelete,
80    /// .ops/.move - write "src\ndst" to move file
81    OpsMove,
82    /// .ops/.batch - write JSON for batch operations
83    OpsBatch,
84    /// .ops/.result - read JSON result of last operation
85    OpsResult,
86
87    // Phase 3: .safety/ virtual directory
88    /// .safety directory for protection features
89    SafetyDir,
90    /// .safety/.trash directory for deleted files
91    TrashDir,
92    /// .safety/.trash/<uuid> - individual trash entry
93    TrashEntry { id: String },
94    /// .safety/.history - audit log file
95    History,
96    /// .safety/.undo - write `operation_id` to undo
97    Undo,
98
99    // Phase 4: .semantic/ virtual directory
100    /// .semantic directory for intelligent operations
101    SemanticDir,
102    /// .semantic/.organize - write `OrganizeRequest` JSON to create plan
103    Organize,
104    /// .semantic/.similar - write path to find similar files
105    SimilarOps,
106    /// .semantic/.cleanup - read cleanup analysis JSON
107    Cleanup,
108    /// .semantic/.dedupe - read duplicate groups JSON
109    Dedupe,
110    /// .semantic/.pending directory for proposed plans
111    PendingDir,
112    /// .semantic/.pending/`<plan_id>` - individual plan
113    PendingPlan { plan_id: String },
114    /// .semantic/.approve - write `plan_id` to execute plan
115    Approve,
116    /// .semantic/.reject - write `plan_id` to cancel plan
117    Reject,
118}
119
120/// Entry in the inode table.
121#[derive(Debug, Clone)]
122pub struct InodeEntry {
123    /// Inode number
124    pub ino: u64,
125    /// Type of inode
126    pub kind: InodeKind,
127    /// Parent inode
128    pub parent: u64,
129    /// Lookup count (for FUSE reference counting)
130    pub lookup_count: u64,
131}
132
133/// Inode table managing virtual and real file mappings.
134pub struct InodeTable {
135    /// Inode number -> entry
136    inodes: HashMap<u64, InodeEntry>,
137    /// Path -> inode for real files
138    path_to_ino: HashMap<PathBuf, u64>,
139    /// Underlying inode -> our inode (for real files)
140    real_ino_map: HashMap<u64, u64>,
141    /// Query string -> inode (for query results)
142    query_to_ino: HashMap<String, u64>,
143    /// Next available inode
144    next_ino: u64,
145}
146
147impl InodeTable {
148    /// Create a new inode table with virtual inodes initialized.
149    #[must_use]
150    pub fn new() -> Self {
151        let mut table = Self {
152            inodes: HashMap::new(),
153            path_to_ino: HashMap::new(),
154            real_ino_map: HashMap::new(),
155            query_to_ino: HashMap::new(),
156            next_ino: FIRST_REAL_INO,
157        };
158        table.init_virtual_inodes();
159        table
160    }
161
162    fn init_virtual_inodes(&mut self) {
163        // Root
164        self.inodes.insert(
165            ROOT_INO,
166            InodeEntry {
167                ino: ROOT_INO,
168                kind: InodeKind::Root,
169                parent: ROOT_INO,
170                lookup_count: 1,
171            },
172        );
173
174        // .ragfs directory
175        self.inodes.insert(
176            RAGFS_DIR_INO,
177            InodeEntry {
178                ino: RAGFS_DIR_INO,
179                kind: InodeKind::RagfsDir,
180                parent: ROOT_INO,
181                lookup_count: 0,
182            },
183        );
184
185        // .query directory
186        self.inodes.insert(
187            QUERY_DIR_INO,
188            InodeEntry {
189                ino: QUERY_DIR_INO,
190                kind: InodeKind::QueryDir,
191                parent: RAGFS_DIR_INO,
192                lookup_count: 0,
193            },
194        );
195
196        // .search directory
197        self.inodes.insert(
198            SEARCH_DIR_INO,
199            InodeEntry {
200                ino: SEARCH_DIR_INO,
201                kind: InodeKind::SearchDir,
202                parent: RAGFS_DIR_INO,
203                lookup_count: 0,
204            },
205        );
206
207        // .index file
208        self.inodes.insert(
209            INDEX_FILE_INO,
210            InodeEntry {
211                ino: INDEX_FILE_INO,
212                kind: InodeKind::IndexStatus,
213                parent: RAGFS_DIR_INO,
214                lookup_count: 0,
215            },
216        );
217
218        // .config file
219        self.inodes.insert(
220            CONFIG_FILE_INO,
221            InodeEntry {
222                ino: CONFIG_FILE_INO,
223                kind: InodeKind::Config,
224                parent: RAGFS_DIR_INO,
225                lookup_count: 0,
226            },
227        );
228
229        // .reindex file
230        self.inodes.insert(
231            REINDEX_FILE_INO,
232            InodeEntry {
233                ino: REINDEX_FILE_INO,
234                kind: InodeKind::Reindex,
235                parent: RAGFS_DIR_INO,
236                lookup_count: 0,
237            },
238        );
239
240        // .help file
241        self.inodes.insert(
242            HELP_FILE_INO,
243            InodeEntry {
244                ino: HELP_FILE_INO,
245                kind: InodeKind::Help,
246                parent: RAGFS_DIR_INO,
247                lookup_count: 0,
248            },
249        );
250
251        // .similar directory
252        self.inodes.insert(
253            SIMILAR_DIR_INO,
254            InodeEntry {
255                ino: SIMILAR_DIR_INO,
256                kind: InodeKind::SimilarDir,
257                parent: RAGFS_DIR_INO,
258                lookup_count: 0,
259            },
260        );
261
262        // Phase 2: .ops directory and files
263        self.inodes.insert(
264            OPS_DIR_INO,
265            InodeEntry {
266                ino: OPS_DIR_INO,
267                kind: InodeKind::OpsDir,
268                parent: RAGFS_DIR_INO,
269                lookup_count: 0,
270            },
271        );
272
273        self.inodes.insert(
274            OPS_CREATE_INO,
275            InodeEntry {
276                ino: OPS_CREATE_INO,
277                kind: InodeKind::OpsCreate,
278                parent: OPS_DIR_INO,
279                lookup_count: 0,
280            },
281        );
282
283        self.inodes.insert(
284            OPS_DELETE_INO,
285            InodeEntry {
286                ino: OPS_DELETE_INO,
287                kind: InodeKind::OpsDelete,
288                parent: OPS_DIR_INO,
289                lookup_count: 0,
290            },
291        );
292
293        self.inodes.insert(
294            OPS_MOVE_INO,
295            InodeEntry {
296                ino: OPS_MOVE_INO,
297                kind: InodeKind::OpsMove,
298                parent: OPS_DIR_INO,
299                lookup_count: 0,
300            },
301        );
302
303        self.inodes.insert(
304            OPS_BATCH_INO,
305            InodeEntry {
306                ino: OPS_BATCH_INO,
307                kind: InodeKind::OpsBatch,
308                parent: OPS_DIR_INO,
309                lookup_count: 0,
310            },
311        );
312
313        self.inodes.insert(
314            OPS_RESULT_INO,
315            InodeEntry {
316                ino: OPS_RESULT_INO,
317                kind: InodeKind::OpsResult,
318                parent: OPS_DIR_INO,
319                lookup_count: 0,
320            },
321        );
322
323        // Phase 3: .safety directory and files
324        self.inodes.insert(
325            SAFETY_DIR_INO,
326            InodeEntry {
327                ino: SAFETY_DIR_INO,
328                kind: InodeKind::SafetyDir,
329                parent: RAGFS_DIR_INO,
330                lookup_count: 0,
331            },
332        );
333
334        self.inodes.insert(
335            TRASH_DIR_INO,
336            InodeEntry {
337                ino: TRASH_DIR_INO,
338                kind: InodeKind::TrashDir,
339                parent: SAFETY_DIR_INO,
340                lookup_count: 0,
341            },
342        );
343
344        self.inodes.insert(
345            HISTORY_FILE_INO,
346            InodeEntry {
347                ino: HISTORY_FILE_INO,
348                kind: InodeKind::History,
349                parent: SAFETY_DIR_INO,
350                lookup_count: 0,
351            },
352        );
353
354        self.inodes.insert(
355            UNDO_FILE_INO,
356            InodeEntry {
357                ino: UNDO_FILE_INO,
358                kind: InodeKind::Undo,
359                parent: SAFETY_DIR_INO,
360                lookup_count: 0,
361            },
362        );
363
364        // Phase 4: .semantic directory and files
365        self.inodes.insert(
366            SEMANTIC_DIR_INO,
367            InodeEntry {
368                ino: SEMANTIC_DIR_INO,
369                kind: InodeKind::SemanticDir,
370                parent: RAGFS_DIR_INO,
371                lookup_count: 0,
372            },
373        );
374
375        self.inodes.insert(
376            ORGANIZE_FILE_INO,
377            InodeEntry {
378                ino: ORGANIZE_FILE_INO,
379                kind: InodeKind::Organize,
380                parent: SEMANTIC_DIR_INO,
381                lookup_count: 0,
382            },
383        );
384
385        self.inodes.insert(
386            SIMILAR_OPS_FILE_INO,
387            InodeEntry {
388                ino: SIMILAR_OPS_FILE_INO,
389                kind: InodeKind::SimilarOps,
390                parent: SEMANTIC_DIR_INO,
391                lookup_count: 0,
392            },
393        );
394
395        self.inodes.insert(
396            CLEANUP_FILE_INO,
397            InodeEntry {
398                ino: CLEANUP_FILE_INO,
399                kind: InodeKind::Cleanup,
400                parent: SEMANTIC_DIR_INO,
401                lookup_count: 0,
402            },
403        );
404
405        self.inodes.insert(
406            DEDUPE_FILE_INO,
407            InodeEntry {
408                ino: DEDUPE_FILE_INO,
409                kind: InodeKind::Dedupe,
410                parent: SEMANTIC_DIR_INO,
411                lookup_count: 0,
412            },
413        );
414
415        self.inodes.insert(
416            PENDING_DIR_INO,
417            InodeEntry {
418                ino: PENDING_DIR_INO,
419                kind: InodeKind::PendingDir,
420                parent: SEMANTIC_DIR_INO,
421                lookup_count: 0,
422            },
423        );
424
425        self.inodes.insert(
426            APPROVE_FILE_INO,
427            InodeEntry {
428                ino: APPROVE_FILE_INO,
429                kind: InodeKind::Approve,
430                parent: SEMANTIC_DIR_INO,
431                lookup_count: 0,
432            },
433        );
434
435        self.inodes.insert(
436            REJECT_FILE_INO,
437            InodeEntry {
438                ino: REJECT_FILE_INO,
439                kind: InodeKind::Reject,
440                parent: SEMANTIC_DIR_INO,
441                lookup_count: 0,
442            },
443        );
444    }
445
446    /// Get an inode entry.
447    #[must_use]
448    pub fn get(&self, ino: u64) -> Option<&InodeEntry> {
449        self.inodes.get(&ino)
450    }
451
452    /// Get or create inode for a real path.
453    pub fn get_or_create_real(&mut self, path: PathBuf, underlying_ino: u64) -> u64 {
454        if let Some(&ino) = self.path_to_ino.get(&path) {
455            return ino;
456        }
457
458        let ino = self.next_ino;
459        self.next_ino += 1;
460
461        self.inodes.insert(
462            ino,
463            InodeEntry {
464                ino,
465                kind: InodeKind::Real {
466                    path: path.clone(),
467                    underlying_ino,
468                },
469                parent: ROOT_INO,
470                lookup_count: 0,
471            },
472        );
473
474        self.path_to_ino.insert(path, ino);
475        self.real_ino_map.insert(underlying_ino, ino);
476
477        ino
478    }
479
480    /// Get or create inode for a query result.
481    pub fn get_or_create_query_result(&mut self, parent: u64, query: String) -> u64 {
482        if let Some(&ino) = self.query_to_ino.get(&query) {
483            return ino;
484        }
485
486        let ino = self.next_ino;
487        self.next_ino += 1;
488
489        self.inodes.insert(
490            ino,
491            InodeEntry {
492                ino,
493                kind: InodeKind::QueryResult {
494                    query: query.clone(),
495                },
496                parent,
497                lookup_count: 0,
498            },
499        );
500
501        self.query_to_ino.insert(query, ino);
502
503        ino
504    }
505
506    /// Increment lookup count.
507    pub fn lookup(&mut self, ino: u64) {
508        if let Some(entry) = self.inodes.get_mut(&ino) {
509            entry.lookup_count += 1;
510        }
511    }
512
513    /// Decrement lookup count.
514    pub fn forget(&mut self, ino: u64, nlookup: u64) {
515        if let Some(entry) = self.inodes.get_mut(&ino) {
516            entry.lookup_count = entry.lookup_count.saturating_sub(nlookup);
517        }
518    }
519
520    /// Check if inode is virtual (part of .ragfs).
521    #[must_use]
522    pub fn is_virtual(&self, ino: u64) -> bool {
523        ino < FIRST_REAL_INO
524    }
525
526    /// Get inode by path (for real files).
527    #[must_use]
528    pub fn get_by_path(&self, path: &PathBuf) -> Option<u64> {
529        self.path_to_ino.get(path).copied()
530    }
531
532    /// Remove an inode entry (for deleted files).
533    /// Only removes real files and query results, not virtual inodes.
534    pub fn remove(&mut self, ino: u64) {
535        // Don't remove virtual inodes
536        if self.is_virtual(ino) {
537            return;
538        }
539
540        if let Some(entry) = self.inodes.remove(&ino) {
541            if let InodeKind::Real {
542                path,
543                underlying_ino,
544            } = entry.kind
545            {
546                self.path_to_ino.remove(&path);
547                self.real_ino_map.remove(&underlying_ino);
548            } else if let InodeKind::QueryResult { query } = entry.kind {
549                self.query_to_ino.remove(&query);
550            }
551        }
552    }
553
554    /// Update the path for an existing inode (for renames).
555    pub fn update_path(&mut self, ino: u64, new_path: PathBuf) {
556        if let Some(entry) = self.inodes.get_mut(&ino)
557            && let InodeKind::Real {
558                ref path,
559                underlying_ino,
560            } = entry.kind
561        {
562            // Remove old path mapping
563            self.path_to_ino.remove(path);
564
565            // Update the kind with new path
566            entry.kind = InodeKind::Real {
567                path: new_path.clone(),
568                underlying_ino,
569            };
570
571            // Add new path mapping
572            self.path_to_ino.insert(new_path, ino);
573        }
574    }
575}
576
577impl Default for InodeTable {
578    fn default() -> Self {
579        Self::new()
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    // ========== Constants Tests ==========
588
589    #[test]
590    fn test_reserved_inode_constants() {
591        assert_eq!(ROOT_INO, 1);
592        assert_eq!(RAGFS_DIR_INO, 2);
593        assert_eq!(QUERY_DIR_INO, 3);
594        assert_eq!(SEARCH_DIR_INO, 4);
595        assert_eq!(INDEX_FILE_INO, 5);
596        assert_eq!(CONFIG_FILE_INO, 6);
597        assert_eq!(REINDEX_FILE_INO, 7);
598        assert_eq!(SIMILAR_DIR_INO, 8);
599        assert_eq!(HELP_FILE_INO, 9);
600        assert_eq!(FIRST_REAL_INO, 1000);
601    }
602
603    #[test]
604    fn test_reserved_inodes_are_below_first_real() {
605        assert!(ROOT_INO < FIRST_REAL_INO);
606        assert!(RAGFS_DIR_INO < FIRST_REAL_INO);
607        assert!(QUERY_DIR_INO < FIRST_REAL_INO);
608        assert!(SEARCH_DIR_INO < FIRST_REAL_INO);
609        assert!(INDEX_FILE_INO < FIRST_REAL_INO);
610        assert!(CONFIG_FILE_INO < FIRST_REAL_INO);
611        assert!(REINDEX_FILE_INO < FIRST_REAL_INO);
612        assert!(SIMILAR_DIR_INO < FIRST_REAL_INO);
613        assert!(HELP_FILE_INO < FIRST_REAL_INO);
614    }
615
616    // ========== InodeKind Tests ==========
617
618    #[test]
619    fn test_inode_kind_root() {
620        let kind = InodeKind::Root;
621        let debug_str = format!("{kind:?}");
622        assert!(debug_str.contains("Root"));
623    }
624
625    #[test]
626    fn test_inode_kind_ragfs_dir() {
627        let kind = InodeKind::RagfsDir;
628        let debug_str = format!("{kind:?}");
629        assert!(debug_str.contains("RagfsDir"));
630    }
631
632    #[test]
633    fn test_inode_kind_query_result() {
634        let kind = InodeKind::QueryResult {
635            query: "test query".to_string(),
636        };
637        let debug_str = format!("{kind:?}");
638        assert!(debug_str.contains("QueryResult"));
639        assert!(debug_str.contains("test query"));
640    }
641
642    #[test]
643    fn test_inode_kind_search_result() {
644        let kind = InodeKind::SearchResult {
645            query: "find files".to_string(),
646        };
647        let debug_str = format!("{kind:?}");
648        assert!(debug_str.contains("SearchResult"));
649    }
650
651    #[test]
652    fn test_inode_kind_real() {
653        let kind = InodeKind::Real {
654            path: PathBuf::from("/test/file.txt"),
655            underlying_ino: 12345,
656        };
657        let debug_str = format!("{kind:?}");
658        assert!(debug_str.contains("Real"));
659        assert!(debug_str.contains("file.txt"));
660    }
661
662    #[test]
663    fn test_inode_kind_similar_lookup() {
664        let kind = InodeKind::SimilarLookup {
665            source_path: PathBuf::from("/source/doc.pdf"),
666        };
667        let debug_str = format!("{kind:?}");
668        assert!(debug_str.contains("SimilarLookup"));
669    }
670
671    #[test]
672    fn test_inode_kind_clone() {
673        let kind = InodeKind::QueryResult {
674            query: "cloned query".to_string(),
675        };
676        let cloned = kind.clone();
677        if let InodeKind::QueryResult { query } = cloned {
678            assert_eq!(query, "cloned query");
679        } else {
680            panic!("Clone produced wrong variant");
681        }
682    }
683
684    // ========== InodeEntry Tests ==========
685
686    #[test]
687    fn test_inode_entry_creation() {
688        let entry = InodeEntry {
689            ino: 100,
690            kind: InodeKind::Root,
691            parent: 1,
692            lookup_count: 5,
693        };
694        assert_eq!(entry.ino, 100);
695        assert_eq!(entry.parent, 1);
696        assert_eq!(entry.lookup_count, 5);
697    }
698
699    #[test]
700    fn test_inode_entry_debug() {
701        let entry = InodeEntry {
702            ino: 42,
703            kind: InodeKind::IndexStatus,
704            parent: 2,
705            lookup_count: 3,
706        };
707        let debug_str = format!("{entry:?}");
708        assert!(debug_str.contains("42"));
709        assert!(debug_str.contains("IndexStatus"));
710    }
711
712    #[test]
713    fn test_inode_entry_clone() {
714        let entry = InodeEntry {
715            ino: 50,
716            kind: InodeKind::Config,
717            parent: 2,
718            lookup_count: 10,
719        };
720        let cloned = entry.clone();
721        assert_eq!(cloned.ino, 50);
722        assert_eq!(cloned.lookup_count, 10);
723    }
724
725    // ========== InodeTable Creation Tests ==========
726
727    #[test]
728    fn test_inode_table_new() {
729        let table = InodeTable::new();
730        assert!(table.get(ROOT_INO).is_some());
731        assert!(table.get(RAGFS_DIR_INO).is_some());
732        assert!(table.get(QUERY_DIR_INO).is_some());
733    }
734
735    #[test]
736    fn test_inode_table_default() {
737        let table = InodeTable::default();
738        assert!(table.get(ROOT_INO).is_some());
739    }
740
741    #[test]
742    fn test_virtual_inodes_initialized() {
743        let table = InodeTable::new();
744
745        // Root
746        let root = table.get(ROOT_INO).expect("Root should exist");
747        assert!(matches!(root.kind, InodeKind::Root));
748        assert_eq!(root.parent, ROOT_INO);
749
750        // RagfsDir
751        let ragfs = table.get(RAGFS_DIR_INO).expect("RagfsDir should exist");
752        assert!(matches!(ragfs.kind, InodeKind::RagfsDir));
753        assert_eq!(ragfs.parent, ROOT_INO);
754
755        // QueryDir
756        let query = table.get(QUERY_DIR_INO).expect("QueryDir should exist");
757        assert!(matches!(query.kind, InodeKind::QueryDir));
758        assert_eq!(query.parent, RAGFS_DIR_INO);
759
760        // SearchDir
761        let search = table.get(SEARCH_DIR_INO).expect("SearchDir should exist");
762        assert!(matches!(search.kind, InodeKind::SearchDir));
763        assert_eq!(search.parent, RAGFS_DIR_INO);
764
765        // IndexStatus
766        let index = table.get(INDEX_FILE_INO).expect("IndexFile should exist");
767        assert!(matches!(index.kind, InodeKind::IndexStatus));
768
769        // Config
770        let config = table.get(CONFIG_FILE_INO).expect("ConfigFile should exist");
771        assert!(matches!(config.kind, InodeKind::Config));
772
773        // Reindex
774        let reindex = table
775            .get(REINDEX_FILE_INO)
776            .expect("ReindexFile should exist");
777        assert!(matches!(reindex.kind, InodeKind::Reindex));
778
779        // SimilarDir
780        let similar = table.get(SIMILAR_DIR_INO).expect("SimilarDir should exist");
781        assert!(matches!(similar.kind, InodeKind::SimilarDir));
782
783        // Help
784        let help = table.get(HELP_FILE_INO).expect("HelpFile should exist");
785        assert!(matches!(help.kind, InodeKind::Help));
786    }
787
788    // ========== get() Tests ==========
789
790    #[test]
791    fn test_get_existing_inode() {
792        let table = InodeTable::new();
793        assert!(table.get(ROOT_INO).is_some());
794    }
795
796    #[test]
797    fn test_get_nonexistent_inode() {
798        let table = InodeTable::new();
799        assert!(table.get(99999).is_none());
800    }
801
802    // ========== get_or_create_real() Tests ==========
803
804    #[test]
805    fn test_get_or_create_real_new_path() {
806        let mut table = InodeTable::new();
807        let path = PathBuf::from("/test/file.txt");
808        let underlying_ino = 12345;
809
810        let ino = table.get_or_create_real(path.clone(), underlying_ino);
811
812        assert!(ino >= FIRST_REAL_INO);
813        let entry = table.get(ino).expect("Entry should exist");
814        if let InodeKind::Real {
815            path: p,
816            underlying_ino: u,
817        } = &entry.kind
818        {
819            assert_eq!(p, &path);
820            assert_eq!(*u, underlying_ino);
821        } else {
822            panic!("Expected Real inode kind");
823        }
824    }
825
826    #[test]
827    fn test_get_or_create_real_returns_existing() {
828        let mut table = InodeTable::new();
829        let path = PathBuf::from("/test/file.txt");
830        let underlying_ino = 12345;
831
832        let ino1 = table.get_or_create_real(path.clone(), underlying_ino);
833        let ino2 = table.get_or_create_real(path.clone(), underlying_ino);
834
835        assert_eq!(ino1, ino2, "Should return same inode for same path");
836    }
837
838    #[test]
839    fn test_get_or_create_real_increments_ino() {
840        let mut table = InodeTable::new();
841
842        let ino1 = table.get_or_create_real(PathBuf::from("/file1.txt"), 100);
843        let ino2 = table.get_or_create_real(PathBuf::from("/file2.txt"), 101);
844        let ino3 = table.get_or_create_real(PathBuf::from("/file3.txt"), 102);
845
846        assert!(ino2 > ino1);
847        assert!(ino3 > ino2);
848    }
849
850    #[test]
851    fn test_get_or_create_real_parent_is_root() {
852        let mut table = InodeTable::new();
853        let ino = table.get_or_create_real(PathBuf::from("/test.txt"), 100);
854
855        let entry = table.get(ino).unwrap();
856        assert_eq!(entry.parent, ROOT_INO);
857    }
858
859    // ========== get_or_create_query_result() Tests ==========
860
861    #[test]
862    fn test_get_or_create_query_result_new() {
863        let mut table = InodeTable::new();
864        let query = "how to implement auth".to_string();
865
866        let ino = table.get_or_create_query_result(QUERY_DIR_INO, query.clone());
867
868        assert!(ino >= FIRST_REAL_INO);
869        let entry = table.get(ino).expect("Entry should exist");
870        if let InodeKind::QueryResult { query: q } = &entry.kind {
871            assert_eq!(q, &query);
872        } else {
873            panic!("Expected QueryResult kind");
874        }
875    }
876
877    #[test]
878    fn test_get_or_create_query_result_returns_existing() {
879        let mut table = InodeTable::new();
880        let query = "test query".to_string();
881
882        let ino1 = table.get_or_create_query_result(QUERY_DIR_INO, query.clone());
883        let ino2 = table.get_or_create_query_result(QUERY_DIR_INO, query.clone());
884
885        assert_eq!(ino1, ino2, "Should return same inode for same query");
886    }
887
888    #[test]
889    fn test_get_or_create_query_result_different_queries() {
890        let mut table = InodeTable::new();
891
892        let ino1 = table.get_or_create_query_result(QUERY_DIR_INO, "query1".to_string());
893        let ino2 = table.get_or_create_query_result(QUERY_DIR_INO, "query2".to_string());
894
895        assert_ne!(ino1, ino2, "Different queries should have different inodes");
896    }
897
898    #[test]
899    fn test_get_or_create_query_result_preserves_parent() {
900        let mut table = InodeTable::new();
901        let ino = table.get_or_create_query_result(QUERY_DIR_INO, "test".to_string());
902
903        let entry = table.get(ino).unwrap();
904        assert_eq!(entry.parent, QUERY_DIR_INO);
905    }
906
907    // ========== lookup() Tests ==========
908
909    #[test]
910    fn test_lookup_increments_count() {
911        let mut table = InodeTable::new();
912
913        let initial_count = table.get(ROOT_INO).unwrap().lookup_count;
914        table.lookup(ROOT_INO);
915        let after_count = table.get(ROOT_INO).unwrap().lookup_count;
916
917        assert_eq!(after_count, initial_count + 1);
918    }
919
920    #[test]
921    fn test_lookup_multiple_times() {
922        let mut table = InodeTable::new();
923
924        let initial = table.get(RAGFS_DIR_INO).unwrap().lookup_count;
925        table.lookup(RAGFS_DIR_INO);
926        table.lookup(RAGFS_DIR_INO);
927        table.lookup(RAGFS_DIR_INO);
928        let final_count = table.get(RAGFS_DIR_INO).unwrap().lookup_count;
929
930        assert_eq!(final_count, initial + 3);
931    }
932
933    #[test]
934    fn test_lookup_nonexistent_does_nothing() {
935        let mut table = InodeTable::new();
936        table.lookup(99999); // Should not panic
937    }
938
939    // ========== forget() Tests ==========
940
941    #[test]
942    fn test_forget_decrements_count() {
943        let mut table = InodeTable::new();
944        table.lookup(ROOT_INO);
945        table.lookup(ROOT_INO);
946
947        let before = table.get(ROOT_INO).unwrap().lookup_count;
948        table.forget(ROOT_INO, 1);
949        let after = table.get(ROOT_INO).unwrap().lookup_count;
950
951        assert_eq!(after, before - 1);
952    }
953
954    #[test]
955    fn test_forget_saturating_sub() {
956        let mut table = InodeTable::new();
957        // Root starts with lookup_count = 1
958        table.forget(ROOT_INO, 100); // Should saturate to 0
959
960        let count = table.get(ROOT_INO).unwrap().lookup_count;
961        assert_eq!(count, 0);
962    }
963
964    #[test]
965    fn test_forget_nonexistent_does_nothing() {
966        let mut table = InodeTable::new();
967        table.forget(99999, 5); // Should not panic
968    }
969
970    // ========== is_virtual() Tests ==========
971
972    #[test]
973    fn test_is_virtual_true_for_reserved() {
974        let table = InodeTable::new();
975        assert!(table.is_virtual(ROOT_INO));
976        assert!(table.is_virtual(RAGFS_DIR_INO));
977        assert!(table.is_virtual(QUERY_DIR_INO));
978        assert!(table.is_virtual(SEARCH_DIR_INO));
979        assert!(table.is_virtual(INDEX_FILE_INO));
980        assert!(table.is_virtual(CONFIG_FILE_INO));
981        assert!(table.is_virtual(REINDEX_FILE_INO));
982        assert!(table.is_virtual(SIMILAR_DIR_INO));
983        assert!(table.is_virtual(HELP_FILE_INO));
984    }
985
986    #[test]
987    fn test_is_virtual_false_for_real() {
988        let table = InodeTable::new();
989        assert!(!table.is_virtual(FIRST_REAL_INO));
990        assert!(!table.is_virtual(FIRST_REAL_INO + 1));
991        assert!(!table.is_virtual(99999));
992    }
993
994    #[test]
995    fn test_is_virtual_boundary() {
996        let table = InodeTable::new();
997        assert!(table.is_virtual(FIRST_REAL_INO - 1));
998        assert!(!table.is_virtual(FIRST_REAL_INO));
999    }
1000
1001    // ========== get_by_path() Tests ==========
1002
1003    #[test]
1004    fn test_get_by_path_existing() {
1005        let mut table = InodeTable::new();
1006        let path = PathBuf::from("/documents/test.txt");
1007        let ino = table.get_or_create_real(path.clone(), 100);
1008
1009        let found = table.get_by_path(&path);
1010        assert_eq!(found, Some(ino));
1011    }
1012
1013    #[test]
1014    fn test_get_by_path_nonexistent() {
1015        let table = InodeTable::new();
1016        let path = PathBuf::from("/nonexistent/file.txt");
1017
1018        assert!(table.get_by_path(&path).is_none());
1019    }
1020
1021    #[test]
1022    fn test_get_by_path_different_paths() {
1023        let mut table = InodeTable::new();
1024        let path1 = PathBuf::from("/file1.txt");
1025        let path2 = PathBuf::from("/file2.txt");
1026
1027        let ino1 = table.get_or_create_real(path1.clone(), 100);
1028        let ino2 = table.get_or_create_real(path2.clone(), 101);
1029
1030        assert_eq!(table.get_by_path(&path1), Some(ino1));
1031        assert_eq!(table.get_by_path(&path2), Some(ino2));
1032    }
1033
1034    // ========== Integration Tests ==========
1035
1036    #[test]
1037    fn test_mixed_real_and_query_inodes() {
1038        let mut table = InodeTable::new();
1039
1040        let real_ino = table.get_or_create_real(PathBuf::from("/doc.txt"), 100);
1041        let query_ino = table.get_or_create_query_result(QUERY_DIR_INO, "search".to_string());
1042
1043        assert_ne!(real_ino, query_ino);
1044        assert!(table.get(real_ino).is_some());
1045        assert!(table.get(query_ino).is_some());
1046    }
1047
1048    #[test]
1049    fn test_inode_numbers_are_sequential() {
1050        let mut table = InodeTable::new();
1051
1052        let ino1 = table.get_or_create_real(PathBuf::from("/a.txt"), 1);
1053        let ino2 = table.get_or_create_query_result(QUERY_DIR_INO, "q1".to_string());
1054        let ino3 = table.get_or_create_real(PathBuf::from("/b.txt"), 2);
1055
1056        assert_eq!(ino2, ino1 + 1);
1057        assert_eq!(ino3, ino2 + 1);
1058    }
1059
1060    // ========== remove() Tests ==========
1061
1062    #[test]
1063    fn test_remove_real_inode() {
1064        let mut table = InodeTable::new();
1065        let path = PathBuf::from("/test/file.txt");
1066        let underlying_ino = 12345;
1067
1068        let ino = table.get_or_create_real(path.clone(), underlying_ino);
1069        assert!(table.get(ino).is_some());
1070        assert!(table.get_by_path(&path).is_some());
1071
1072        table.remove(ino);
1073
1074        assert!(table.get(ino).is_none());
1075        assert!(table.get_by_path(&path).is_none());
1076    }
1077
1078    #[test]
1079    fn test_remove_query_result_inode() {
1080        let mut table = InodeTable::new();
1081        let query = "test query".to_string();
1082
1083        let ino = table.get_or_create_query_result(QUERY_DIR_INO, query.clone());
1084        assert!(table.get(ino).is_some());
1085
1086        table.remove(ino);
1087
1088        assert!(table.get(ino).is_none());
1089        // Creating the same query again should get a new inode
1090        let new_ino = table.get_or_create_query_result(QUERY_DIR_INO, query);
1091        assert_ne!(ino, new_ino);
1092    }
1093
1094    #[test]
1095    fn test_remove_nonexistent_does_nothing() {
1096        let mut table = InodeTable::new();
1097        table.remove(99999); // Should not panic
1098    }
1099
1100    #[test]
1101    fn test_remove_virtual_inode_does_nothing() {
1102        let mut table = InodeTable::new();
1103        // Virtual inodes shouldn't be removed via this method
1104        table.remove(ROOT_INO);
1105        // Root should still exist (not removed because it's not Real or QueryResult)
1106        assert!(table.get(ROOT_INO).is_some());
1107    }
1108
1109    // ========== update_path() Tests ==========
1110
1111    #[test]
1112    fn test_update_path_basic() {
1113        let mut table = InodeTable::new();
1114        let old_path = PathBuf::from("/old/path.txt");
1115        let new_path = PathBuf::from("/new/path.txt");
1116
1117        let ino = table.get_or_create_real(old_path.clone(), 100);
1118
1119        table.update_path(ino, new_path.clone());
1120
1121        // Old path should not be found
1122        assert!(table.get_by_path(&old_path).is_none());
1123        // New path should be found
1124        assert_eq!(table.get_by_path(&new_path), Some(ino));
1125
1126        // Entry should have new path
1127        let entry = table.get(ino).unwrap();
1128        if let InodeKind::Real { path, .. } = &entry.kind {
1129            assert_eq!(path, &new_path);
1130        } else {
1131            panic!("Expected Real inode kind");
1132        }
1133    }
1134
1135    #[test]
1136    fn test_update_path_preserves_underlying_ino() {
1137        let mut table = InodeTable::new();
1138        let old_path = PathBuf::from("/old.txt");
1139        let new_path = PathBuf::from("/new.txt");
1140        let underlying = 54321_u64;
1141
1142        let ino = table.get_or_create_real(old_path, underlying);
1143        table.update_path(ino, new_path);
1144
1145        let entry = table.get(ino).unwrap();
1146        if let InodeKind::Real { underlying_ino, .. } = &entry.kind {
1147            assert_eq!(*underlying_ino, underlying);
1148        } else {
1149            panic!("Expected Real inode kind");
1150        }
1151    }
1152
1153    #[test]
1154    fn test_update_path_nonexistent_does_nothing() {
1155        let mut table = InodeTable::new();
1156        table.update_path(99999, PathBuf::from("/new.txt")); // Should not panic
1157    }
1158
1159    #[test]
1160    fn test_update_path_virtual_inode_does_nothing() {
1161        let mut table = InodeTable::new();
1162        // Virtual inodes shouldn't be updated
1163        table.update_path(ROOT_INO, PathBuf::from("/new/root"));
1164        // Root should still have its original kind
1165        let root = table.get(ROOT_INO).unwrap();
1166        assert!(matches!(root.kind, InodeKind::Root));
1167    }
1168}