ragfs_fuse/
filesystem.rs

1//! FUSE filesystem implementation.
2
3use fuser::{
4    FileAttr, FileType, Filesystem, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty,
5    ReplyEntry, ReplyOpen, ReplyWrite, Request, TimeOrNow,
6};
7use libc::{EEXIST, EINVAL, EIO, EISDIR, ENOENT, ENOSYS, ENOTDIR, ENOTEMPTY, EPERM};
8use ragfs_core::{Embedder, VectorStore};
9use ragfs_query::QueryExecutor;
10use std::collections::HashMap;
11use std::ffi::OsStr;
12use std::fs;
13use std::os::unix::fs::MetadataExt;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use tokio::runtime::Handle;
18use tokio::sync::{RwLock, mpsc};
19use tracing::{debug, info, warn};
20
21use crate::inode::{
22    APPROVE_FILE_INO, CLEANUP_FILE_INO, CONFIG_FILE_INO, DEDUPE_FILE_INO, FIRST_REAL_INO,
23    HELP_FILE_INO, HISTORY_FILE_INO, INDEX_FILE_INO, InodeKind, InodeTable, OPS_BATCH_INO,
24    OPS_CREATE_INO, OPS_DELETE_INO, OPS_DIR_INO, OPS_MOVE_INO, OPS_RESULT_INO, ORGANIZE_FILE_INO,
25    PENDING_DIR_INO, QUERY_DIR_INO, RAGFS_DIR_INO, REINDEX_FILE_INO, REJECT_FILE_INO, ROOT_INO,
26    SAFETY_DIR_INO, SEARCH_DIR_INO, SEMANTIC_DIR_INO, SIMILAR_DIR_INO, SIMILAR_OPS_FILE_INO,
27    TRASH_DIR_INO, UNDO_FILE_INO,
28};
29use crate::ops::OpsManager;
30use crate::safety::SafetyManager;
31use crate::semantic::SemanticManager;
32
33const TTL: Duration = Duration::from_secs(1);
34const BLOCK_SIZE: u64 = 512;
35
36/// RAGFS FUSE filesystem.
37pub struct RagFs {
38    /// Source directory being indexed
39    source: PathBuf,
40    /// Inode table
41    inodes: Arc<RwLock<InodeTable>>,
42    /// Vector store for queries and stats
43    store: Option<Arc<dyn VectorStore>>,
44    /// Query executor
45    query_executor: Option<Arc<QueryExecutor>>,
46    /// Tokio runtime handle for async operations
47    runtime: Handle,
48    /// Cache for virtual file contents (query results, index status)
49    content_cache: Arc<RwLock<HashMap<u64, Vec<u8>>>>,
50    /// Channel sender for reindex requests
51    reindex_sender: Option<mpsc::Sender<PathBuf>>,
52    /// Operations manager for agent file management
53    ops_manager: Arc<OpsManager>,
54    /// Safety manager for trash/history/undo
55    safety_manager: Arc<SafetyManager>,
56    /// Semantic manager for intelligent operations
57    semantic_manager: Arc<SemanticManager>,
58}
59
60impl RagFs {
61    /// Create a new RAGFS filesystem (basic, for passthrough only).
62    #[must_use]
63    pub fn new(source: PathBuf) -> Self {
64        let safety_manager = Arc::new(SafetyManager::new(&source, None));
65        let ops_manager = Arc::new(OpsManager::with_safety(
66            source.clone(),
67            None,
68            None,
69            safety_manager.clone(),
70        ));
71        let semantic_manager = Arc::new(SemanticManager::with_ops(
72            source.clone(),
73            None,
74            None,
75            None,
76            ops_manager.clone(),
77        ));
78        Self {
79            source,
80            inodes: Arc::new(RwLock::new(InodeTable::new())),
81            store: None,
82            query_executor: None,
83            runtime: Handle::current(),
84            content_cache: Arc::new(RwLock::new(HashMap::new())),
85            reindex_sender: None,
86            ops_manager,
87            safety_manager,
88            semantic_manager,
89        }
90    }
91
92    /// Create a new RAGFS filesystem with full RAG capabilities.
93    pub fn with_rag(
94        source: PathBuf,
95        store: Arc<dyn VectorStore>,
96        embedder: Arc<dyn Embedder>,
97        runtime: Handle,
98        reindex_sender: Option<mpsc::Sender<PathBuf>>,
99    ) -> Self {
100        let query_executor = Arc::new(QueryExecutor::new(
101            store.clone(),
102            embedder.clone(),
103            10,    // default limit
104            false, // hybrid search
105        ));
106
107        let safety_manager = Arc::new(SafetyManager::new(&source, None));
108        let ops_manager = Arc::new(OpsManager::with_safety(
109            source.clone(),
110            Some(store.clone()),
111            reindex_sender.clone(),
112            safety_manager.clone(),
113        ));
114        let semantic_manager = Arc::new(SemanticManager::with_ops(
115            source.clone(),
116            Some(store.clone()),
117            Some(embedder.clone()),
118            None,
119            ops_manager.clone(),
120        ));
121
122        Self {
123            source,
124            inodes: Arc::new(RwLock::new(InodeTable::new())),
125            store: Some(store),
126            query_executor: Some(query_executor),
127            runtime,
128            content_cache: Arc::new(RwLock::new(HashMap::new())),
129            reindex_sender,
130            ops_manager,
131            safety_manager,
132            semantic_manager,
133        }
134    }
135
136    /// Get the source directory.
137    #[must_use]
138    pub fn source(&self) -> &PathBuf {
139        &self.source
140    }
141
142    /// Convert a real path to a FUSE inode.
143    fn real_path_to_attr(&self, path: &PathBuf, ino: u64) -> Option<FileAttr> {
144        let metadata = fs::metadata(path).ok()?;
145        let kind = if metadata.is_dir() {
146            FileType::Directory
147        } else if metadata.is_file() {
148            FileType::RegularFile
149        } else if metadata.file_type().is_symlink() {
150            FileType::Symlink
151        } else {
152            return None;
153        };
154
155        let atime = metadata.accessed().unwrap_or(SystemTime::UNIX_EPOCH);
156        let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
157        let ctime = UNIX_EPOCH + Duration::from_secs(metadata.ctime() as u64);
158
159        Some(FileAttr {
160            ino,
161            size: metadata.len(),
162            blocks: metadata.len().div_ceil(BLOCK_SIZE),
163            atime,
164            mtime,
165            ctime,
166            crtime: ctime,
167            kind,
168            perm: (metadata.mode() & 0o7777) as u16,
169            nlink: metadata.nlink() as u32,
170            uid: metadata.uid(),
171            gid: metadata.gid(),
172            rdev: metadata.rdev() as u32,
173            blksize: BLOCK_SIZE as u32,
174            flags: 0,
175        })
176    }
177
178    #[allow(unsafe_code)]
179    fn make_attr(&self, ino: u64, kind: FileType, size: u64) -> FileAttr {
180        let now = SystemTime::now();
181        // SAFETY: getuid() and getgid() are always safe to call
182        let uid = unsafe { libc::getuid() };
183        let gid = unsafe { libc::getgid() };
184        FileAttr {
185            ino,
186            size,
187            blocks: size.div_ceil(BLOCK_SIZE),
188            atime: now,
189            mtime: now,
190            ctime: now,
191            crtime: now,
192            kind,
193            perm: if kind == FileType::Directory {
194                0o755
195            } else {
196                0o644
197            },
198            nlink: if kind == FileType::Directory { 2 } else { 1 },
199            uid,
200            gid,
201            rdev: 0,
202            blksize: BLOCK_SIZE as u32,
203            flags: 0,
204        }
205    }
206
207    /// Get index status as JSON.
208    fn get_index_status(&self) -> Vec<u8> {
209        if let Some(ref store) = self.store {
210            let store = store.clone();
211            let result = self.runtime.block_on(async { store.stats().await });
212
213            match result {
214                Ok(stats) => {
215                    let json = serde_json::json!({
216                        "status": "indexed",
217                        "total_files": stats.total_files,
218                        "total_chunks": stats.total_chunks,
219                        "index_size_bytes": stats.index_size_bytes,
220                        "last_updated": stats.last_updated.map(|t| t.to_rfc3339()),
221                    });
222                    serde_json::to_string_pretty(&json)
223                        .unwrap_or_default()
224                        .into_bytes()
225                }
226                Err(e) => {
227                    let json = serde_json::json!({
228                        "status": "error",
229                        "error": e.to_string(),
230                    });
231                    serde_json::to_string_pretty(&json)
232                        .unwrap_or_default()
233                        .into_bytes()
234                }
235            }
236        } else {
237            let json = serde_json::json!({
238                "status": "not_initialized",
239                "message": "No store configured",
240            });
241            serde_json::to_string_pretty(&json)
242                .unwrap_or_default()
243                .into_bytes()
244        }
245    }
246
247    /// Execute a query and return results as JSON.
248    fn execute_query(&self, query: &str) -> Vec<u8> {
249        if let Some(ref executor) = self.query_executor {
250            let executor = executor.clone();
251            let query_str = query.to_string();
252            let query_for_result = query_str.clone();
253            let result = self
254                .runtime
255                .block_on(async move { executor.execute(&query_str).await });
256
257            match result {
258                Ok(results) => {
259                    let json_results: Vec<_> = results
260                        .iter()
261                        .map(|r| {
262                            serde_json::json!({
263                                "file": r.file_path.to_string_lossy(),
264                                "score": r.score,
265                                "content": truncate(&r.content, 500),
266                                "byte_range": [r.byte_range.start, r.byte_range.end],
267                                "line_range": r.line_range.as_ref().map(|lr| [lr.start, lr.end]),
268                            })
269                        })
270                        .collect();
271
272                    let json = serde_json::json!({
273                        "query": query_for_result,
274                        "results": json_results,
275                    });
276                    serde_json::to_string_pretty(&json)
277                        .unwrap_or_default()
278                        .into_bytes()
279                }
280                Err(e) => {
281                    let json = serde_json::json!({
282                        "query": query_for_result,
283                        "error": e.to_string(),
284                    });
285                    serde_json::to_string_pretty(&json)
286                        .unwrap_or_default()
287                        .into_bytes()
288                }
289            }
290        } else {
291            let json = serde_json::json!({
292                "error": "Query executor not configured",
293            });
294            serde_json::to_string_pretty(&json)
295                .unwrap_or_default()
296                .into_bytes()
297        }
298    }
299
300    /// Get configuration as JSON.
301    fn get_config(&self) -> Vec<u8> {
302        let json = serde_json::json!({
303            "source": self.source.to_string_lossy(),
304            "store_configured": self.store.is_some(),
305            "query_executor_configured": self.query_executor.is_some(),
306        });
307        serde_json::to_string_pretty(&json)
308            .unwrap_or_default()
309            .into_bytes()
310    }
311
312    /// Resolve a parent inode to a real path.
313    /// Returns None if the parent doesn't exist or is not a directory.
314    fn resolve_parent_path(&self, parent: u64) -> Option<PathBuf> {
315        if parent == ROOT_INO {
316            return Some(self.source.clone());
317        }
318
319        let inodes = self.runtime.block_on(self.inodes.read());
320        if let Some(entry) = inodes.get(parent)
321            && let InodeKind::Real { path, .. } = &entry.kind
322        {
323            Some(path.clone())
324        } else {
325            None
326        }
327    }
328
329    /// Get help content for the virtual control directory.
330    fn get_help_content(&self) -> Vec<u8> {
331        r#"RAGFS Virtual Control Directory
332================================
333
334The .ragfs directory provides a virtual interface to RAGFS functionality.
335
336Available paths:
337
338  .index          Read to get index statistics (JSON)
339                  Shows file count, chunk count, and last update time.
340
341  .config         Read to get current configuration (JSON)
342                  Shows source directory and component status.
343
344  .reindex        Write a file path to trigger reindexing.
345                  Example: echo "src/main.rs" > .ragfs/.reindex
346
347  .query/<q>      Read to execute a semantic search query.
348                  The filename is the query string.
349                  Returns JSON with matching results.
350                  Example: cat .ragfs/.query/authentication
351
352  .search/        Directory for search results (symlinks).
353                  Access .search/<query>/ to get symlinks to matching files.
354
355  .similar/       Directory for finding similar files.
356                  Access .similar/<path>/ to get symlinks to similar files.
357
358  .help           This help file.
359
360Examples:
361
362  # Check index status
363  cat .ragfs/.index
364
365  # Search for files about authentication
366  cat ".ragfs/.query/how to authenticate users"
367
368  # Trigger reindex of a specific file
369  echo "src/lib.rs" > .ragfs/.reindex
370
371  # View configuration
372  cat .ragfs/.config
373"#
374        .as_bytes()
375        .to_vec()
376    }
377}
378
379impl Filesystem for RagFs {
380    fn init(
381        &mut self,
382        _req: &Request<'_>,
383        _config: &mut fuser::KernelConfig,
384    ) -> Result<(), libc::c_int> {
385        debug!("FUSE init for source: {:?}", self.source);
386        Ok(())
387    }
388
389    fn destroy(&mut self) {
390        debug!("FUSE destroy");
391    }
392
393    fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) {
394        let name_str = name.to_string_lossy();
395        debug!("lookup: parent={}, name={}", parent, name_str);
396
397        // Handle root directory lookups
398        if parent == ROOT_INO {
399            if name_str == ".ragfs" {
400                let attr = self.make_attr(RAGFS_DIR_INO, FileType::Directory, 0);
401                reply.entry(&TTL, &attr, 0);
402                return;
403            }
404
405            // Try to find real file/directory in source
406            let real_path = self.source.join(&*name_str);
407            if real_path.exists() {
408                let metadata = if let Ok(m) = fs::metadata(&real_path) {
409                    m
410                } else {
411                    reply.error(ENOENT);
412                    return;
413                };
414
415                let mut inodes = self.runtime.block_on(self.inodes.write());
416                let ino = inodes.get_or_create_real(real_path.clone(), metadata.ino());
417                drop(inodes);
418
419                if let Some(attr) = self.real_path_to_attr(&real_path, ino) {
420                    reply.entry(&TTL, &attr, 0);
421                    return;
422                }
423            }
424        }
425
426        // Handle .ragfs directory lookups
427        if parent == RAGFS_DIR_INO {
428            match name_str.as_ref() {
429                ".query" => {
430                    let attr = self.make_attr(QUERY_DIR_INO, FileType::Directory, 0);
431                    reply.entry(&TTL, &attr, 0);
432                    return;
433                }
434                ".search" => {
435                    let attr = self.make_attr(SEARCH_DIR_INO, FileType::Directory, 0);
436                    reply.entry(&TTL, &attr, 0);
437                    return;
438                }
439                ".index" => {
440                    let content = self.get_index_status();
441                    let attr =
442                        self.make_attr(INDEX_FILE_INO, FileType::RegularFile, content.len() as u64);
443                    let mut cache = self.runtime.block_on(self.content_cache.write());
444                    cache.insert(INDEX_FILE_INO, content);
445                    reply.entry(&TTL, &attr, 0);
446                    return;
447                }
448                ".config" => {
449                    let content = self.get_config();
450                    let attr = self.make_attr(
451                        CONFIG_FILE_INO,
452                        FileType::RegularFile,
453                        content.len() as u64,
454                    );
455                    let mut cache = self.runtime.block_on(self.content_cache.write());
456                    cache.insert(CONFIG_FILE_INO, content);
457                    reply.entry(&TTL, &attr, 0);
458                    return;
459                }
460                ".reindex" => {
461                    let attr = self.make_attr(REINDEX_FILE_INO, FileType::RegularFile, 0);
462                    reply.entry(&TTL, &attr, 0);
463                    return;
464                }
465                ".similar" => {
466                    let attr = self.make_attr(SIMILAR_DIR_INO, FileType::Directory, 0);
467                    reply.entry(&TTL, &attr, 0);
468                    return;
469                }
470                ".help" => {
471                    let content = self.get_help_content();
472                    let attr =
473                        self.make_attr(HELP_FILE_INO, FileType::RegularFile, content.len() as u64);
474                    let mut cache = self.runtime.block_on(self.content_cache.write());
475                    cache.insert(HELP_FILE_INO, content);
476                    reply.entry(&TTL, &attr, 0);
477                    return;
478                }
479                ".ops" => {
480                    let attr = self.make_attr(OPS_DIR_INO, FileType::Directory, 0);
481                    reply.entry(&TTL, &attr, 0);
482                    return;
483                }
484                ".safety" => {
485                    let attr = self.make_attr(SAFETY_DIR_INO, FileType::Directory, 0);
486                    reply.entry(&TTL, &attr, 0);
487                    return;
488                }
489                ".semantic" => {
490                    let attr = self.make_attr(SEMANTIC_DIR_INO, FileType::Directory, 0);
491                    reply.entry(&TTL, &attr, 0);
492                    return;
493                }
494                _ => {}
495            }
496        }
497
498        // Handle .semantic directory lookups
499        if parent == SEMANTIC_DIR_INO {
500            match name_str.as_ref() {
501                ".organize" => {
502                    let attr = self.make_attr(ORGANIZE_FILE_INO, FileType::RegularFile, 0);
503                    reply.entry(&TTL, &attr, 0);
504                    return;
505                }
506                ".similar" => {
507                    let content = self
508                        .runtime
509                        .block_on(self.semantic_manager.get_similar_json());
510                    let attr = self.make_attr(
511                        SIMILAR_OPS_FILE_INO,
512                        FileType::RegularFile,
513                        content.len() as u64,
514                    );
515                    let mut cache = self.runtime.block_on(self.content_cache.write());
516                    cache.insert(SIMILAR_OPS_FILE_INO, content);
517                    reply.entry(&TTL, &attr, 0);
518                    return;
519                }
520                ".cleanup" => {
521                    let content = self
522                        .runtime
523                        .block_on(self.semantic_manager.get_cleanup_json());
524                    let attr = self.make_attr(
525                        CLEANUP_FILE_INO,
526                        FileType::RegularFile,
527                        content.len() as u64,
528                    );
529                    let mut cache = self.runtime.block_on(self.content_cache.write());
530                    cache.insert(CLEANUP_FILE_INO, content);
531                    reply.entry(&TTL, &attr, 0);
532                    return;
533                }
534                ".dedupe" => {
535                    let content = self
536                        .runtime
537                        .block_on(self.semantic_manager.get_dedupe_json());
538                    let attr = self.make_attr(
539                        DEDUPE_FILE_INO,
540                        FileType::RegularFile,
541                        content.len() as u64,
542                    );
543                    let mut cache = self.runtime.block_on(self.content_cache.write());
544                    cache.insert(DEDUPE_FILE_INO, content);
545                    reply.entry(&TTL, &attr, 0);
546                    return;
547                }
548                ".pending" => {
549                    let attr = self.make_attr(PENDING_DIR_INO, FileType::Directory, 0);
550                    reply.entry(&TTL, &attr, 0);
551                    return;
552                }
553                ".approve" => {
554                    let attr = self.make_attr(APPROVE_FILE_INO, FileType::RegularFile, 0);
555                    reply.entry(&TTL, &attr, 0);
556                    return;
557                }
558                ".reject" => {
559                    let attr = self.make_attr(REJECT_FILE_INO, FileType::RegularFile, 0);
560                    reply.entry(&TTL, &attr, 0);
561                    return;
562                }
563                _ => {}
564            }
565        }
566
567        // Handle .pending directory lookups (semantic plans)
568        if parent == PENDING_DIR_INO {
569            // Lookup a specific pending plan by ID
570            let plan_ids = self
571                .runtime
572                .block_on(self.semantic_manager.get_pending_plan_ids());
573            if plan_ids.contains(&name_str.to_string()) {
574                let content = self
575                    .runtime
576                    .block_on(self.semantic_manager.get_plan_json(&name_str));
577                // Use dynamic inode for pending plans
578                let mut inodes = self.runtime.block_on(self.inodes.write());
579                let ino = inodes.get_or_create_query_result(PENDING_DIR_INO, name_str.to_string());
580                let attr = self.make_attr(ino, FileType::RegularFile, content.len() as u64);
581                let mut cache = self.runtime.block_on(self.content_cache.write());
582                cache.insert(ino, content);
583                reply.entry(&TTL, &attr, 0);
584                return;
585            }
586        }
587
588        // Handle .safety directory lookups
589        if parent == SAFETY_DIR_INO {
590            match name_str.as_ref() {
591                ".trash" => {
592                    let attr = self.make_attr(TRASH_DIR_INO, FileType::Directory, 0);
593                    reply.entry(&TTL, &attr, 0);
594                    return;
595                }
596                ".history" => {
597                    let content = self.safety_manager.get_history_json(Some(100));
598                    let attr = self.make_attr(
599                        HISTORY_FILE_INO,
600                        FileType::RegularFile,
601                        content.len() as u64,
602                    );
603                    let mut cache = self.runtime.block_on(self.content_cache.write());
604                    cache.insert(HISTORY_FILE_INO, content);
605                    reply.entry(&TTL, &attr, 0);
606                    return;
607                }
608                ".undo" => {
609                    let attr = self.make_attr(UNDO_FILE_INO, FileType::RegularFile, 0);
610                    reply.entry(&TTL, &attr, 0);
611                    return;
612                }
613                _ => {}
614            }
615        }
616
617        // Handle .ops directory lookups
618        if parent == OPS_DIR_INO {
619            match name_str.as_ref() {
620                ".create" => {
621                    let attr = self.make_attr(OPS_CREATE_INO, FileType::RegularFile, 0);
622                    reply.entry(&TTL, &attr, 0);
623                    return;
624                }
625                ".delete" => {
626                    let attr = self.make_attr(OPS_DELETE_INO, FileType::RegularFile, 0);
627                    reply.entry(&TTL, &attr, 0);
628                    return;
629                }
630                ".move" => {
631                    let attr = self.make_attr(OPS_MOVE_INO, FileType::RegularFile, 0);
632                    reply.entry(&TTL, &attr, 0);
633                    return;
634                }
635                ".batch" => {
636                    let attr = self.make_attr(OPS_BATCH_INO, FileType::RegularFile, 0);
637                    reply.entry(&TTL, &attr, 0);
638                    return;
639                }
640                ".result" => {
641                    let content = self.runtime.block_on(self.ops_manager.get_last_result());
642                    let attr =
643                        self.make_attr(OPS_RESULT_INO, FileType::RegularFile, content.len() as u64);
644                    let mut cache = self.runtime.block_on(self.content_cache.write());
645                    cache.insert(OPS_RESULT_INO, content);
646                    reply.entry(&TTL, &attr, 0);
647                    return;
648                }
649                _ => {}
650            }
651        }
652
653        // Handle .query directory lookups (dynamic query files)
654        if parent == QUERY_DIR_INO {
655            let query = name_str.to_string();
656            let content = self.execute_query(&query);
657
658            let mut inodes = self.runtime.block_on(self.inodes.write());
659            let ino = inodes.get_or_create_query_result(QUERY_DIR_INO, query);
660            drop(inodes);
661
662            let attr = self.make_attr(ino, FileType::RegularFile, content.len() as u64);
663            let mut cache = self.runtime.block_on(self.content_cache.write());
664            cache.insert(ino, content);
665
666            reply.entry(&TTL, &attr, 0);
667            return;
668        }
669
670        // Handle lookups in real directories
671        let inodes = self.runtime.block_on(self.inodes.read());
672        if let Some(entry) = inodes.get(parent)
673            && let InodeKind::Real { path, .. } = &entry.kind
674        {
675            let real_path = path.join(&*name_str);
676            if real_path.exists() {
677                drop(inodes);
678                let metadata = if let Ok(m) = fs::metadata(&real_path) {
679                    m
680                } else {
681                    reply.error(ENOENT);
682                    return;
683                };
684
685                let mut inodes = self.runtime.block_on(self.inodes.write());
686                let ino = inodes.get_or_create_real(real_path.clone(), metadata.ino());
687                drop(inodes);
688
689                if let Some(attr) = self.real_path_to_attr(&real_path, ino) {
690                    reply.entry(&TTL, &attr, 0);
691                    return;
692                }
693            }
694        }
695
696        reply.error(ENOENT);
697    }
698
699    fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
700        debug!("getattr: ino={}", ino);
701
702        match ino {
703            ROOT_INO => {
704                let attr = self.make_attr(ROOT_INO, FileType::Directory, 0);
705                reply.attr(&TTL, &attr);
706            }
707            RAGFS_DIR_INO => {
708                let attr = self.make_attr(RAGFS_DIR_INO, FileType::Directory, 0);
709                reply.attr(&TTL, &attr);
710            }
711            QUERY_DIR_INO | SEARCH_DIR_INO | SIMILAR_DIR_INO => {
712                let attr = self.make_attr(ino, FileType::Directory, 0);
713                reply.attr(&TTL, &attr);
714            }
715            INDEX_FILE_INO => {
716                let content = self.get_index_status();
717                let size = content.len() as u64;
718                let mut cache = self.runtime.block_on(self.content_cache.write());
719                cache.insert(INDEX_FILE_INO, content);
720                let attr = self.make_attr(ino, FileType::RegularFile, size);
721                reply.attr(&TTL, &attr);
722            }
723            CONFIG_FILE_INO => {
724                let content = self.get_config();
725                let size = content.len() as u64;
726                let mut cache = self.runtime.block_on(self.content_cache.write());
727                cache.insert(CONFIG_FILE_INO, content);
728                let attr = self.make_attr(ino, FileType::RegularFile, size);
729                reply.attr(&TTL, &attr);
730            }
731            REINDEX_FILE_INO => {
732                let attr = self.make_attr(ino, FileType::RegularFile, 0);
733                reply.attr(&TTL, &attr);
734            }
735            HELP_FILE_INO => {
736                let content = self.get_help_content();
737                let size = content.len() as u64;
738                let mut cache = self.runtime.block_on(self.content_cache.write());
739                cache.insert(HELP_FILE_INO, content);
740                let attr = self.make_attr(ino, FileType::RegularFile, size);
741                reply.attr(&TTL, &attr);
742            }
743            // .ops/ directory and files
744            OPS_DIR_INO => {
745                let attr = self.make_attr(ino, FileType::Directory, 0);
746                reply.attr(&TTL, &attr);
747            }
748            OPS_CREATE_INO | OPS_DELETE_INO | OPS_MOVE_INO | OPS_BATCH_INO => {
749                // Write-only files have size 0
750                let attr = self.make_attr(ino, FileType::RegularFile, 0);
751                reply.attr(&TTL, &attr);
752            }
753            OPS_RESULT_INO => {
754                let content = self.runtime.block_on(self.ops_manager.get_last_result());
755                let size = content.len() as u64;
756                let mut cache = self.runtime.block_on(self.content_cache.write());
757                cache.insert(OPS_RESULT_INO, content);
758                let attr = self.make_attr(ino, FileType::RegularFile, size);
759                reply.attr(&TTL, &attr);
760            }
761            // .safety/ directory and files
762            SAFETY_DIR_INO | TRASH_DIR_INO => {
763                let attr = self.make_attr(ino, FileType::Directory, 0);
764                reply.attr(&TTL, &attr);
765            }
766            HISTORY_FILE_INO => {
767                let content = self.safety_manager.get_history_json(Some(100));
768                let size = content.len() as u64;
769                let mut cache = self.runtime.block_on(self.content_cache.write());
770                cache.insert(HISTORY_FILE_INO, content);
771                let attr = self.make_attr(ino, FileType::RegularFile, size);
772                reply.attr(&TTL, &attr);
773            }
774            UNDO_FILE_INO => {
775                // Write-only file
776                let attr = self.make_attr(ino, FileType::RegularFile, 0);
777                reply.attr(&TTL, &attr);
778            }
779            // .semantic/ directory and files
780            SEMANTIC_DIR_INO | PENDING_DIR_INO => {
781                let attr = self.make_attr(ino, FileType::Directory, 0);
782                reply.attr(&TTL, &attr);
783            }
784            SIMILAR_OPS_FILE_INO => {
785                let content = self
786                    .runtime
787                    .block_on(self.semantic_manager.get_similar_json());
788                let size = content.len() as u64;
789                let mut cache = self.runtime.block_on(self.content_cache.write());
790                cache.insert(SIMILAR_OPS_FILE_INO, content);
791                let attr = self.make_attr(ino, FileType::RegularFile, size);
792                reply.attr(&TTL, &attr);
793            }
794            CLEANUP_FILE_INO => {
795                let content = self
796                    .runtime
797                    .block_on(self.semantic_manager.get_cleanup_json());
798                let size = content.len() as u64;
799                let mut cache = self.runtime.block_on(self.content_cache.write());
800                cache.insert(CLEANUP_FILE_INO, content);
801                let attr = self.make_attr(ino, FileType::RegularFile, size);
802                reply.attr(&TTL, &attr);
803            }
804            DEDUPE_FILE_INO => {
805                let content = self
806                    .runtime
807                    .block_on(self.semantic_manager.get_dedupe_json());
808                let size = content.len() as u64;
809                let mut cache = self.runtime.block_on(self.content_cache.write());
810                cache.insert(DEDUPE_FILE_INO, content);
811                let attr = self.make_attr(ino, FileType::RegularFile, size);
812                reply.attr(&TTL, &attr);
813            }
814            ORGANIZE_FILE_INO | APPROVE_FILE_INO | REJECT_FILE_INO => {
815                // Write-only files
816                let attr = self.make_attr(ino, FileType::RegularFile, 0);
817                reply.attr(&TTL, &attr);
818            }
819            _ => {
820                // Check if it's a cached query result
821                let cache = self.runtime.block_on(self.content_cache.read());
822                if let Some(content) = cache.get(&ino) {
823                    let attr = self.make_attr(ino, FileType::RegularFile, content.len() as u64);
824                    reply.attr(&TTL, &attr);
825                    return;
826                }
827                drop(cache);
828
829                // Check if it's a real file
830                let inodes = self.runtime.block_on(self.inodes.read());
831                if let Some(entry) = inodes.get(ino)
832                    && let InodeKind::Real { path, .. } = &entry.kind
833                    && let Some(attr) = self.real_path_to_attr(path, ino)
834                {
835                    reply.attr(&TTL, &attr);
836                    return;
837                }
838                reply.error(ENOENT);
839            }
840        }
841    }
842
843    fn read(
844        &mut self,
845        _req: &Request<'_>,
846        ino: u64,
847        _fh: u64,
848        offset: i64,
849        size: u32,
850        _flags: i32,
851        _lock_owner: Option<u64>,
852        reply: ReplyData,
853    ) {
854        debug!("read: ino={}, offset={}, size={}", ino, offset, size);
855
856        // Check content cache first (for virtual files)
857        let cache = self.runtime.block_on(self.content_cache.read());
858        if let Some(content) = cache.get(&ino) {
859            let offset = offset as usize;
860            let size = size as usize;
861            if offset >= content.len() {
862                reply.data(&[]);
863            } else {
864                let end = (offset + size).min(content.len());
865                reply.data(&content[offset..end]);
866            }
867            return;
868        }
869        drop(cache);
870
871        // Handle virtual files by inode
872        match ino {
873            INDEX_FILE_INO => {
874                let content = self.get_index_status();
875                let offset = offset as usize;
876                let size = size as usize;
877                if offset >= content.len() {
878                    reply.data(&[]);
879                } else {
880                    let end = (offset + size).min(content.len());
881                    reply.data(&content[offset..end]);
882                }
883                return;
884            }
885            CONFIG_FILE_INO => {
886                let content = self.get_config();
887                let offset = offset as usize;
888                let size = size as usize;
889                if offset >= content.len() {
890                    reply.data(&[]);
891                } else {
892                    let end = (offset + size).min(content.len());
893                    reply.data(&content[offset..end]);
894                }
895                return;
896            }
897            REINDEX_FILE_INO => {
898                reply.data(&[]);
899                return;
900            }
901            HELP_FILE_INO => {
902                let content = self.get_help_content();
903                let offset = offset as usize;
904                let size = size as usize;
905                if offset >= content.len() {
906                    reply.data(&[]);
907                } else {
908                    let end = (offset + size).min(content.len());
909                    reply.data(&content[offset..end]);
910                }
911                return;
912            }
913            OPS_RESULT_INO => {
914                let content = self.runtime.block_on(self.ops_manager.get_last_result());
915                let offset = offset as usize;
916                let size = size as usize;
917                if offset >= content.len() {
918                    reply.data(&[]);
919                } else {
920                    let end = (offset + size).min(content.len());
921                    reply.data(&content[offset..end]);
922                }
923                return;
924            }
925            // Write-only .ops/ files return empty
926            OPS_CREATE_INO | OPS_DELETE_INO | OPS_MOVE_INO | OPS_BATCH_INO => {
927                reply.data(&[]);
928                return;
929            }
930            HISTORY_FILE_INO => {
931                let content = self.safety_manager.get_history_json(Some(100));
932                let offset = offset as usize;
933                let size = size as usize;
934                if offset >= content.len() {
935                    reply.data(&[]);
936                } else {
937                    let end = (offset + size).min(content.len());
938                    reply.data(&content[offset..end]);
939                }
940                return;
941            }
942            // Write-only .safety/ files return empty
943            UNDO_FILE_INO => {
944                reply.data(&[]);
945                return;
946            }
947            // .semantic/ read-only files
948            SIMILAR_OPS_FILE_INO => {
949                let content = self
950                    .runtime
951                    .block_on(self.semantic_manager.get_similar_json());
952                let offset = offset as usize;
953                let size = size as usize;
954                if offset >= content.len() {
955                    reply.data(&[]);
956                } else {
957                    let end = (offset + size).min(content.len());
958                    reply.data(&content[offset..end]);
959                }
960                return;
961            }
962            CLEANUP_FILE_INO => {
963                let content = self
964                    .runtime
965                    .block_on(self.semantic_manager.get_cleanup_json());
966                let offset = offset as usize;
967                let size = size as usize;
968                if offset >= content.len() {
969                    reply.data(&[]);
970                } else {
971                    let end = (offset + size).min(content.len());
972                    reply.data(&content[offset..end]);
973                }
974                return;
975            }
976            DEDUPE_FILE_INO => {
977                let content = self
978                    .runtime
979                    .block_on(self.semantic_manager.get_dedupe_json());
980                let offset = offset as usize;
981                let size = size as usize;
982                if offset >= content.len() {
983                    reply.data(&[]);
984                } else {
985                    let end = (offset + size).min(content.len());
986                    reply.data(&content[offset..end]);
987                }
988                return;
989            }
990            // Write-only .semantic/ files return empty
991            ORGANIZE_FILE_INO | APPROVE_FILE_INO | REJECT_FILE_INO => {
992                reply.data(&[]);
993                return;
994            }
995            _ => {}
996        }
997
998        // Try to read real file
999        let inodes = self.runtime.block_on(self.inodes.read());
1000        if let Some(entry) = inodes.get(ino)
1001            && let InodeKind::Real { path, .. } = &entry.kind
1002        {
1003            let path = path.clone();
1004            drop(inodes);
1005
1006            match fs::read(&path) {
1007                Ok(content) => {
1008                    let offset = offset as usize;
1009                    let size = size as usize;
1010                    if offset >= content.len() {
1011                        reply.data(&[]);
1012                    } else {
1013                        let end = (offset + size).min(content.len());
1014                        reply.data(&content[offset..end]);
1015                    }
1016                    return;
1017                }
1018                Err(e) => {
1019                    warn!("Failed to read file {:?}: {}", path, e);
1020                    reply.error(EIO);
1021                    return;
1022                }
1023            }
1024        }
1025
1026        reply.error(ENOENT);
1027    }
1028
1029    fn readdir(
1030        &mut self,
1031        _req: &Request<'_>,
1032        ino: u64,
1033        _fh: u64,
1034        offset: i64,
1035        mut reply: ReplyDirectory,
1036    ) {
1037        debug!("readdir: ino={}, offset={}", ino, offset);
1038
1039        match ino {
1040            ROOT_INO => {
1041                let mut entries = vec![
1042                    (ROOT_INO, FileType::Directory, ".".to_string()),
1043                    (ROOT_INO, FileType::Directory, "..".to_string()),
1044                    (RAGFS_DIR_INO, FileType::Directory, ".ragfs".to_string()),
1045                ];
1046
1047                // Add real files/directories from source
1048                if let Ok(read_dir) = fs::read_dir(&self.source) {
1049                    for entry in read_dir.flatten() {
1050                        let name = entry.file_name().to_string_lossy().to_string();
1051                        if name.starts_with('.') {
1052                            continue; // Skip hidden files
1053                        }
1054                        let file_type = if entry.path().is_dir() {
1055                            FileType::Directory
1056                        } else {
1057                            FileType::RegularFile
1058                        };
1059
1060                        let metadata = match entry.metadata() {
1061                            Ok(m) => m,
1062                            Err(_) => continue,
1063                        };
1064
1065                        let mut inodes = self.runtime.block_on(self.inodes.write());
1066                        let entry_ino = inodes.get_or_create_real(entry.path(), metadata.ino());
1067                        entries.push((entry_ino, file_type, name));
1068                    }
1069                }
1070
1071                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1072                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1073                        break;
1074                    }
1075                }
1076                reply.ok();
1077            }
1078            RAGFS_DIR_INO => {
1079                let entries = [
1080                    (RAGFS_DIR_INO, FileType::Directory, "."),
1081                    (ROOT_INO, FileType::Directory, ".."),
1082                    (QUERY_DIR_INO, FileType::Directory, ".query"),
1083                    (SEARCH_DIR_INO, FileType::Directory, ".search"),
1084                    (INDEX_FILE_INO, FileType::RegularFile, ".index"),
1085                    (CONFIG_FILE_INO, FileType::RegularFile, ".config"),
1086                    (REINDEX_FILE_INO, FileType::RegularFile, ".reindex"),
1087                    (HELP_FILE_INO, FileType::RegularFile, ".help"),
1088                    (SIMILAR_DIR_INO, FileType::Directory, ".similar"),
1089                    (OPS_DIR_INO, FileType::Directory, ".ops"),
1090                    (SAFETY_DIR_INO, FileType::Directory, ".safety"),
1091                    (SEMANTIC_DIR_INO, FileType::Directory, ".semantic"),
1092                ];
1093
1094                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1095                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1096                        break;
1097                    }
1098                }
1099                reply.ok();
1100            }
1101            SAFETY_DIR_INO => {
1102                let entries = [
1103                    (SAFETY_DIR_INO, FileType::Directory, "."),
1104                    (RAGFS_DIR_INO, FileType::Directory, ".."),
1105                    (TRASH_DIR_INO, FileType::Directory, ".trash"),
1106                    (HISTORY_FILE_INO, FileType::RegularFile, ".history"),
1107                    (UNDO_FILE_INO, FileType::RegularFile, ".undo"),
1108                ];
1109
1110                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1111                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1112                        break;
1113                    }
1114                }
1115                reply.ok();
1116            }
1117            TRASH_DIR_INO => {
1118                // List trash entries dynamically
1119                let mut entries: Vec<(u64, FileType, String)> = vec![
1120                    (TRASH_DIR_INO, FileType::Directory, ".".to_string()),
1121                    (SAFETY_DIR_INO, FileType::Directory, "..".to_string()),
1122                ];
1123
1124                // Add trash entries from SafetyManager
1125                let trash = self.runtime.block_on(self.safety_manager.list_trash());
1126                for entry in trash {
1127                    // Use a dynamic inode (starting from a high number to avoid conflicts)
1128                    let entry_ino =
1129                        FIRST_REAL_INO + 500_000 + (entry.id.as_u128() % 100_000) as u64;
1130                    entries.push((entry_ino, FileType::RegularFile, entry.id.to_string()));
1131                }
1132
1133                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1134                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1135                        break;
1136                    }
1137                }
1138                reply.ok();
1139            }
1140            OPS_DIR_INO => {
1141                let entries = [
1142                    (OPS_DIR_INO, FileType::Directory, "."),
1143                    (RAGFS_DIR_INO, FileType::Directory, ".."),
1144                    (OPS_CREATE_INO, FileType::RegularFile, ".create"),
1145                    (OPS_DELETE_INO, FileType::RegularFile, ".delete"),
1146                    (OPS_MOVE_INO, FileType::RegularFile, ".move"),
1147                    (OPS_BATCH_INO, FileType::RegularFile, ".batch"),
1148                    (OPS_RESULT_INO, FileType::RegularFile, ".result"),
1149                ];
1150
1151                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1152                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1153                        break;
1154                    }
1155                }
1156                reply.ok();
1157            }
1158            SEMANTIC_DIR_INO => {
1159                let entries = [
1160                    (SEMANTIC_DIR_INO, FileType::Directory, "."),
1161                    (RAGFS_DIR_INO, FileType::Directory, ".."),
1162                    (ORGANIZE_FILE_INO, FileType::RegularFile, ".organize"),
1163                    (SIMILAR_OPS_FILE_INO, FileType::RegularFile, ".similar"),
1164                    (CLEANUP_FILE_INO, FileType::RegularFile, ".cleanup"),
1165                    (DEDUPE_FILE_INO, FileType::RegularFile, ".dedupe"),
1166                    (PENDING_DIR_INO, FileType::Directory, ".pending"),
1167                    (APPROVE_FILE_INO, FileType::RegularFile, ".approve"),
1168                    (REJECT_FILE_INO, FileType::RegularFile, ".reject"),
1169                ];
1170
1171                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1172                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1173                        break;
1174                    }
1175                }
1176                reply.ok();
1177            }
1178            PENDING_DIR_INO => {
1179                // List pending plans dynamically
1180                let mut entries: Vec<(u64, FileType, String)> = vec![
1181                    (PENDING_DIR_INO, FileType::Directory, ".".to_string()),
1182                    (SEMANTIC_DIR_INO, FileType::Directory, "..".to_string()),
1183                ];
1184
1185                // Add pending plan entries from SemanticManager
1186                let plan_ids = self
1187                    .runtime
1188                    .block_on(self.semantic_manager.get_pending_plan_ids());
1189                for plan_id in plan_ids {
1190                    // Use a dynamic inode
1191                    let mut inodes = self.runtime.block_on(self.inodes.write());
1192                    let entry_ino =
1193                        inodes.get_or_create_query_result(PENDING_DIR_INO, plan_id.clone());
1194                    entries.push((entry_ino, FileType::RegularFile, plan_id));
1195                }
1196
1197                for (i, (ino, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
1198                    if reply.add(*ino, (i + 1) as i64, *kind, name) {
1199                        break;
1200                    }
1201                }
1202                reply.ok();
1203            }
1204            QUERY_DIR_INO | SEARCH_DIR_INO | SIMILAR_DIR_INO => {
1205                // These directories are empty - files are created dynamically on lookup
1206                let entries = [
1207                    (ino, FileType::Directory, "."),
1208                    (RAGFS_DIR_INO, FileType::Directory, ".."),
1209                ];
1210
1211                for (i, (entry_ino, kind, name)) in entries.iter().enumerate().skip(offset as usize)
1212                {
1213                    if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1214                        break;
1215                    }
1216                }
1217                reply.ok();
1218            }
1219            _ => {
1220                // Try to read real directory
1221                let inodes = self.runtime.block_on(self.inodes.read());
1222                if let Some(entry) = inodes.get(ino)
1223                    && let InodeKind::Real { path, .. } = &entry.kind
1224                {
1225                    let path = path.clone();
1226                    let parent_ino = entry.parent;
1227                    drop(inodes);
1228
1229                    if path.is_dir() {
1230                        let mut entries = vec![
1231                            (ino, FileType::Directory, ".".to_string()),
1232                            (parent_ino, FileType::Directory, "..".to_string()),
1233                        ];
1234
1235                        if let Ok(read_dir) = fs::read_dir(&path) {
1236                            for dir_entry in read_dir.flatten() {
1237                                let name = dir_entry.file_name().to_string_lossy().to_string();
1238                                if name.starts_with('.') {
1239                                    continue;
1240                                }
1241                                let file_type = if dir_entry.path().is_dir() {
1242                                    FileType::Directory
1243                                } else {
1244                                    FileType::RegularFile
1245                                };
1246
1247                                let metadata = match dir_entry.metadata() {
1248                                    Ok(m) => m,
1249                                    Err(_) => continue,
1250                                };
1251
1252                                let mut inodes = self.runtime.block_on(self.inodes.write());
1253                                let entry_ino =
1254                                    inodes.get_or_create_real(dir_entry.path(), metadata.ino());
1255                                entries.push((entry_ino, file_type, name));
1256                            }
1257                        }
1258
1259                        for (i, (entry_ino, kind, name)) in
1260                            entries.iter().enumerate().skip(offset as usize)
1261                        {
1262                            if reply.add(*entry_ino, (i + 1) as i64, *kind, name) {
1263                                break;
1264                            }
1265                        }
1266                        reply.ok();
1267                        return;
1268                    }
1269                }
1270                reply.error(ENOENT);
1271            }
1272        }
1273    }
1274
1275    fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) {
1276        debug!("open: ino={}, flags={}", ino, flags);
1277        // Allow opening any file for now
1278        reply.opened(0, 0);
1279    }
1280
1281    fn opendir(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) {
1282        debug!("opendir: ino={}, flags={}", ino, flags);
1283        reply.opened(0, 0);
1284    }
1285
1286    fn write(
1287        &mut self,
1288        _req: &Request<'_>,
1289        ino: u64,
1290        _fh: u64,
1291        _offset: i64,
1292        data: &[u8],
1293        _write_flags: u32,
1294        _flags: i32,
1295        _lock_owner: Option<u64>,
1296        reply: ReplyWrite,
1297    ) {
1298        debug!("write: ino={}, len={}", ino, data.len());
1299
1300        // Handle .reindex writes
1301        if ino == REINDEX_FILE_INO {
1302            let path_str = String::from_utf8_lossy(data).trim().to_string();
1303
1304            if path_str.is_empty() {
1305                debug!("Empty reindex request, ignoring");
1306                reply.written(data.len() as u32);
1307                return;
1308            }
1309
1310            let path = PathBuf::from(&path_str);
1311
1312            // Convert relative paths to absolute paths relative to source
1313            let absolute_path = if path.is_absolute() {
1314                path
1315            } else {
1316                self.source.join(&path)
1317            };
1318
1319            info!("Reindex requested for: {:?}", absolute_path);
1320
1321            // Send reindex request if sender is configured
1322            if let Some(ref sender) = self.reindex_sender {
1323                let sender = sender.clone();
1324                let path_to_send = absolute_path.clone();
1325
1326                // Use runtime to send asynchronously
1327                self.runtime.spawn(async move {
1328                    if let Err(e) = sender.send(path_to_send).await {
1329                        warn!("Failed to send reindex request: {}", e);
1330                    }
1331                });
1332
1333                debug!("Reindex request sent for: {:?}", absolute_path);
1334            } else {
1335                warn!("Reindex requested but no sender configured");
1336            }
1337
1338            reply.written(data.len() as u32);
1339            return;
1340        }
1341
1342        // Handle .ops/ virtual file writes
1343        if ino == OPS_CREATE_INO {
1344            let data_str = String::from_utf8_lossy(data).to_string();
1345            let ops_manager = self.ops_manager.clone();
1346            self.runtime.block_on(async move {
1347                ops_manager.parse_and_create(&data_str).await;
1348            });
1349            reply.written(data.len() as u32);
1350            return;
1351        }
1352
1353        if ino == OPS_DELETE_INO {
1354            let data_str = String::from_utf8_lossy(data).to_string();
1355            let ops_manager = self.ops_manager.clone();
1356            self.runtime.block_on(async move {
1357                ops_manager.parse_and_delete(&data_str).await;
1358            });
1359            reply.written(data.len() as u32);
1360            return;
1361        }
1362
1363        if ino == OPS_MOVE_INO {
1364            let data_str = String::from_utf8_lossy(data).to_string();
1365            let ops_manager = self.ops_manager.clone();
1366            self.runtime.block_on(async move {
1367                ops_manager.parse_and_move(&data_str).await;
1368            });
1369            reply.written(data.len() as u32);
1370            return;
1371        }
1372
1373        if ino == OPS_BATCH_INO {
1374            let data_str = String::from_utf8_lossy(data).to_string();
1375            let ops_manager = self.ops_manager.clone();
1376            self.runtime.block_on(async move {
1377                ops_manager.parse_and_batch(&data_str).await;
1378            });
1379            reply.written(data.len() as u32);
1380            return;
1381        }
1382
1383        // Handle .safety/.undo writes
1384        if ino == UNDO_FILE_INO {
1385            let data_str = String::from_utf8_lossy(data).trim().to_string();
1386            if let Ok(operation_id) = uuid::Uuid::parse_str(&data_str) {
1387                let safety_manager = self.safety_manager.clone();
1388                let result = self
1389                    .runtime
1390                    .block_on(async move { safety_manager.undo(operation_id).await });
1391                match result {
1392                    Ok(msg) => info!("Undo successful: {}", msg),
1393                    Err(e) => warn!("Undo failed: {}", e),
1394                }
1395            } else {
1396                warn!("Invalid operation ID for undo: {}", data_str);
1397            }
1398            reply.written(data.len() as u32);
1399            return;
1400        }
1401
1402        // Handle .semantic/.organize writes
1403        if ino == ORGANIZE_FILE_INO {
1404            let data_str = String::from_utf8_lossy(data).to_string();
1405            let semantic_manager = self.semantic_manager.clone();
1406            let result = self.runtime.block_on(async move {
1407                match serde_json::from_str::<crate::semantic::OrganizeRequest>(&data_str) {
1408                    Ok(request) => semantic_manager.create_organize_plan(request).await,
1409                    Err(e) => Err(format!("Invalid OrganizeRequest JSON: {e}")),
1410                }
1411            });
1412            match result {
1413                Ok(plan) => info!("Created organization plan: {}", plan.id),
1414                Err(e) => warn!("Failed to create organization plan: {}", e),
1415            }
1416            reply.written(data.len() as u32);
1417            return;
1418        }
1419
1420        // Handle .semantic/.similar writes
1421        if ino == SIMILAR_OPS_FILE_INO {
1422            let data_str = String::from_utf8_lossy(data).trim().to_string();
1423            let path = PathBuf::from(&data_str);
1424            let semantic_manager = self.semantic_manager.clone();
1425            let result = self
1426                .runtime
1427                .block_on(async move { semantic_manager.find_similar(&path).await });
1428            match result {
1429                Ok(r) => info!("Found {} similar files to {}", r.similar.len(), data_str),
1430                Err(e) => warn!("Failed to find similar files: {}", e),
1431            }
1432            reply.written(data.len() as u32);
1433            return;
1434        }
1435
1436        // Handle .semantic/.approve writes
1437        if ino == APPROVE_FILE_INO {
1438            let data_str = String::from_utf8_lossy(data).trim().to_string();
1439            if let Ok(plan_id) = uuid::Uuid::parse_str(&data_str) {
1440                let semantic_manager = self.semantic_manager.clone();
1441                let result = self
1442                    .runtime
1443                    .block_on(async move { semantic_manager.approve_plan(plan_id).await });
1444                match result {
1445                    Ok(plan) => info!("Approved plan: {}", plan.id),
1446                    Err(e) => warn!("Failed to approve plan: {}", e),
1447                }
1448            } else {
1449                warn!("Invalid plan ID for approve: {}", data_str);
1450            }
1451            reply.written(data.len() as u32);
1452            return;
1453        }
1454
1455        // Handle .semantic/.reject writes
1456        if ino == REJECT_FILE_INO {
1457            let data_str = String::from_utf8_lossy(data).trim().to_string();
1458            if let Ok(plan_id) = uuid::Uuid::parse_str(&data_str) {
1459                let semantic_manager = self.semantic_manager.clone();
1460                let result = self
1461                    .runtime
1462                    .block_on(async move { semantic_manager.reject_plan(plan_id).await });
1463                match result {
1464                    Ok(plan) => info!("Rejected plan: {}", plan.id),
1465                    Err(e) => warn!("Failed to reject plan: {}", e),
1466                }
1467            } else {
1468                warn!("Invalid plan ID for reject: {}", data_str);
1469            }
1470            reply.written(data.len() as u32);
1471            return;
1472        }
1473
1474        // Real file writes (passthrough)
1475        let inodes = self.runtime.block_on(self.inodes.read());
1476        if let Some(entry) = inodes.get(ino)
1477            && let InodeKind::Real { path, .. } = &entry.kind
1478        {
1479            let path = path.clone();
1480            drop(inodes);
1481
1482            match fs::write(&path, data) {
1483                Ok(()) => {
1484                    reply.written(data.len() as u32);
1485                    return;
1486                }
1487                Err(e) => {
1488                    warn!("Failed to write file {:?}: {}", path, e);
1489                    reply.error(EIO);
1490                    return;
1491                }
1492            }
1493        }
1494
1495        reply.error(ENOSYS);
1496    }
1497
1498    fn forget(&mut self, _req: &Request<'_>, ino: u64, nlookup: u64) {
1499        debug!("forget: ino={}, nlookup={}", ino, nlookup);
1500        let mut inodes = self.runtime.block_on(self.inodes.write());
1501        inodes.forget(ino, nlookup);
1502    }
1503
1504    fn create(
1505        &mut self,
1506        _req: &Request<'_>,
1507        parent: u64,
1508        name: &OsStr,
1509        mode: u32,
1510        _umask: u32,
1511        flags: i32,
1512        reply: ReplyCreate,
1513    ) {
1514        let name_str = name.to_string_lossy();
1515        debug!(
1516            "create: parent={}, name={}, mode={:o}",
1517            parent, name_str, mode
1518        );
1519
1520        // Prevent creating files in virtual directories
1521        if parent < FIRST_REAL_INO && parent != ROOT_INO {
1522            reply.error(EPERM);
1523            return;
1524        }
1525
1526        // Resolve parent to path
1527        let Some(parent_path) = self.resolve_parent_path(parent) else {
1528            reply.error(ENOENT);
1529            return;
1530        };
1531
1532        let new_path = parent_path.join(&*name_str);
1533
1534        // Check if file already exists
1535        if new_path.exists() {
1536            reply.error(EEXIST);
1537            return;
1538        }
1539
1540        // Create the file
1541        match fs::File::create(&new_path) {
1542            Ok(_) => {
1543                // Get metadata for the new file
1544                let metadata = match fs::metadata(&new_path) {
1545                    Ok(m) => m,
1546                    Err(e) => {
1547                        warn!("Failed to get metadata for new file {:?}: {}", new_path, e);
1548                        reply.error(EIO);
1549                        return;
1550                    }
1551                };
1552
1553                // Create inode entry
1554                let mut inodes = self.runtime.block_on(self.inodes.write());
1555                let ino = inodes.get_or_create_real(new_path.clone(), metadata.ino());
1556                drop(inodes);
1557
1558                // Trigger reindex for the new file
1559                if let Some(ref sender) = self.reindex_sender {
1560                    let sender = sender.clone();
1561                    let path_to_send = new_path.clone();
1562                    self.runtime.spawn(async move {
1563                        if let Err(e) = sender.send(path_to_send).await {
1564                            warn!("Failed to send reindex request: {}", e);
1565                        }
1566                    });
1567                }
1568
1569                if let Some(attr) = self.real_path_to_attr(&new_path, ino) {
1570                    reply.created(&TTL, &attr, 0, 0, flags as u32);
1571                } else {
1572                    reply.error(EIO);
1573                }
1574            }
1575            Err(e) => {
1576                warn!("Failed to create file {:?}: {}", new_path, e);
1577                reply.error(EIO);
1578            }
1579        }
1580    }
1581
1582    fn unlink(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
1583        let name_str = name.to_string_lossy();
1584        debug!("unlink: parent={}, name={}", parent, name_str);
1585
1586        // Prevent unlinking from virtual directories
1587        if parent < FIRST_REAL_INO && parent != ROOT_INO {
1588            reply.error(EPERM);
1589            return;
1590        }
1591
1592        // Resolve parent to path
1593        let Some(parent_path) = self.resolve_parent_path(parent) else {
1594            reply.error(ENOENT);
1595            return;
1596        };
1597
1598        let file_path = parent_path.join(&*name_str);
1599
1600        // Check if path exists and is a file
1601        if !file_path.exists() {
1602            reply.error(ENOENT);
1603            return;
1604        }
1605
1606        if file_path.is_dir() {
1607            reply.error(EISDIR);
1608            return;
1609        }
1610
1611        // Delete from vector store first
1612        if let Some(ref store) = self.store {
1613            let store = store.clone();
1614            let path_for_delete = file_path.clone();
1615            let result = self
1616                .runtime
1617                .block_on(async move { store.delete_by_file_path(&path_for_delete).await });
1618            if let Err(e) = result {
1619                warn!("Failed to delete from store {:?}: {}", file_path, e);
1620                // Continue with file deletion anyway
1621            }
1622        }
1623
1624        // Delete the file
1625        match fs::remove_file(&file_path) {
1626            Ok(()) => {
1627                // Remove inode entry
1628                let mut inodes = self.runtime.block_on(self.inodes.write());
1629                if let Some(ino) = inodes.get_by_path(&file_path) {
1630                    inodes.remove(ino);
1631                }
1632                info!("Deleted file: {:?}", file_path);
1633                reply.ok();
1634            }
1635            Err(e) => {
1636                warn!("Failed to delete file {:?}: {}", file_path, e);
1637                reply.error(EIO);
1638            }
1639        }
1640    }
1641
1642    fn mkdir(
1643        &mut self,
1644        _req: &Request<'_>,
1645        parent: u64,
1646        name: &OsStr,
1647        mode: u32,
1648        _umask: u32,
1649        reply: ReplyEntry,
1650    ) {
1651        let name_str = name.to_string_lossy();
1652        debug!(
1653            "mkdir: parent={}, name={}, mode={:o}",
1654            parent, name_str, mode
1655        );
1656
1657        // Prevent creating directories in virtual directories
1658        if parent < FIRST_REAL_INO && parent != ROOT_INO {
1659            reply.error(EPERM);
1660            return;
1661        }
1662
1663        // Resolve parent to path
1664        let Some(parent_path) = self.resolve_parent_path(parent) else {
1665            reply.error(ENOENT);
1666            return;
1667        };
1668
1669        let new_path = parent_path.join(&*name_str);
1670
1671        // Check if path already exists
1672        if new_path.exists() {
1673            reply.error(EEXIST);
1674            return;
1675        }
1676
1677        // Create the directory
1678        match fs::create_dir(&new_path) {
1679            Ok(()) => {
1680                // Get metadata
1681                let metadata = match fs::metadata(&new_path) {
1682                    Ok(m) => m,
1683                    Err(e) => {
1684                        warn!("Failed to get metadata for new dir {:?}: {}", new_path, e);
1685                        reply.error(EIO);
1686                        return;
1687                    }
1688                };
1689
1690                // Create inode entry
1691                let mut inodes = self.runtime.block_on(self.inodes.write());
1692                let ino = inodes.get_or_create_real(new_path.clone(), metadata.ino());
1693                drop(inodes);
1694
1695                if let Some(attr) = self.real_path_to_attr(&new_path, ino) {
1696                    info!("Created directory: {:?}", new_path);
1697                    reply.entry(&TTL, &attr, 0);
1698                } else {
1699                    reply.error(EIO);
1700                }
1701            }
1702            Err(e) => {
1703                warn!("Failed to create directory {:?}: {}", new_path, e);
1704                reply.error(EIO);
1705            }
1706        }
1707    }
1708
1709    fn rmdir(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
1710        let name_str = name.to_string_lossy();
1711        debug!("rmdir: parent={}, name={}", parent, name_str);
1712
1713        // Prevent removing directories from virtual areas
1714        if parent < FIRST_REAL_INO && parent != ROOT_INO {
1715            reply.error(EPERM);
1716            return;
1717        }
1718
1719        // Resolve parent to path
1720        let Some(parent_path) = self.resolve_parent_path(parent) else {
1721            reply.error(ENOENT);
1722            return;
1723        };
1724
1725        let dir_path = parent_path.join(&*name_str);
1726
1727        // Check if path exists and is a directory
1728        if !dir_path.exists() {
1729            reply.error(ENOENT);
1730            return;
1731        }
1732
1733        if !dir_path.is_dir() {
1734            reply.error(ENOTDIR);
1735            return;
1736        }
1737
1738        // Remove the directory (will fail if not empty)
1739        match fs::remove_dir(&dir_path) {
1740            Ok(()) => {
1741                // Remove inode entry
1742                let mut inodes = self.runtime.block_on(self.inodes.write());
1743                if let Some(ino) = inodes.get_by_path(&dir_path) {
1744                    inodes.remove(ino);
1745                }
1746                info!("Removed directory: {:?}", dir_path);
1747                reply.ok();
1748            }
1749            Err(e) => {
1750                if e.kind() == std::io::ErrorKind::DirectoryNotEmpty
1751                    || e.raw_os_error() == Some(libc::ENOTEMPTY)
1752                {
1753                    reply.error(ENOTEMPTY);
1754                } else {
1755                    warn!("Failed to remove directory {:?}: {}", dir_path, e);
1756                    reply.error(EIO);
1757                }
1758            }
1759        }
1760    }
1761
1762    fn rename(
1763        &mut self,
1764        _req: &Request<'_>,
1765        parent: u64,
1766        name: &OsStr,
1767        newparent: u64,
1768        newname: &OsStr,
1769        _flags: u32,
1770        reply: ReplyEmpty,
1771    ) {
1772        let name_str = name.to_string_lossy();
1773        let newname_str = newname.to_string_lossy();
1774        debug!(
1775            "rename: parent={}, name={}, newparent={}, newname={}",
1776            parent, name_str, newparent, newname_str
1777        );
1778
1779        // Prevent renaming from/to virtual directories
1780        if (parent < FIRST_REAL_INO && parent != ROOT_INO)
1781            || (newparent < FIRST_REAL_INO && newparent != ROOT_INO)
1782        {
1783            reply.error(EPERM);
1784            return;
1785        }
1786
1787        // Resolve source path
1788        let Some(src_parent_path) = self.resolve_parent_path(parent) else {
1789            reply.error(ENOENT);
1790            return;
1791        };
1792
1793        // Resolve destination path
1794        let Some(dst_parent_path) = self.resolve_parent_path(newparent) else {
1795            reply.error(ENOENT);
1796            return;
1797        };
1798
1799        let src_path = src_parent_path.join(&*name_str);
1800        let dst_path = dst_parent_path.join(&*newname_str);
1801
1802        // Check source exists
1803        if !src_path.exists() {
1804            reply.error(ENOENT);
1805            return;
1806        }
1807
1808        // Perform rename
1809        match fs::rename(&src_path, &dst_path) {
1810            Ok(()) => {
1811                // Update vector store path
1812                if let Some(ref store) = self.store {
1813                    let store = store.clone();
1814                    let src = src_path.clone();
1815                    let dst = dst_path.clone();
1816                    let result = self
1817                        .runtime
1818                        .block_on(async move { store.update_file_path(&src, &dst).await });
1819                    if let Err(e) = result {
1820                        warn!(
1821                            "Failed to update store path {:?} -> {:?}: {}",
1822                            src_path, dst_path, e
1823                        );
1824                    }
1825                }
1826
1827                // Update inode table
1828                let mut inodes = self.runtime.block_on(self.inodes.write());
1829                if let Some(ino) = inodes.get_by_path(&src_path) {
1830                    inodes.update_path(ino, dst_path.clone());
1831                }
1832                drop(inodes);
1833
1834                info!("Renamed: {:?} -> {:?}", src_path, dst_path);
1835                reply.ok();
1836            }
1837            Err(e) => {
1838                warn!("Failed to rename {:?} -> {:?}: {}", src_path, dst_path, e);
1839                reply.error(EIO);
1840            }
1841        }
1842    }
1843
1844    fn setattr(
1845        &mut self,
1846        _req: &Request<'_>,
1847        ino: u64,
1848        _mode: Option<u32>,
1849        _uid: Option<u32>,
1850        _gid: Option<u32>,
1851        size: Option<u64>,
1852        _atime: Option<TimeOrNow>,
1853        _mtime: Option<TimeOrNow>,
1854        _ctime: Option<SystemTime>,
1855        _fh: Option<u64>,
1856        _crtime: Option<SystemTime>,
1857        _chgtime: Option<SystemTime>,
1858        _bkuptime: Option<SystemTime>,
1859        _flags: Option<u32>,
1860        reply: ReplyAttr,
1861    ) {
1862        debug!("setattr: ino={}, size={:?}", ino, size);
1863
1864        // Don't allow setattr on virtual files
1865        if ino < FIRST_REAL_INO {
1866            reply.error(EPERM);
1867            return;
1868        }
1869
1870        // Get the file path
1871        let inodes = self.runtime.block_on(self.inodes.read());
1872        let Some(entry) = inodes.get(ino) else {
1873            drop(inodes);
1874            reply.error(ENOENT);
1875            return;
1876        };
1877        let InodeKind::Real { path, .. } = &entry.kind else {
1878            drop(inodes);
1879            reply.error(EINVAL);
1880            return;
1881        };
1882        let path = path.clone();
1883        drop(inodes);
1884
1885        // Handle truncate
1886        if let Some(new_size) = size {
1887            match fs::OpenOptions::new().write(true).open(&path) {
1888                Ok(file) => {
1889                    if let Err(e) = file.set_len(new_size) {
1890                        warn!("Failed to truncate {:?}: {}", path, e);
1891                        reply.error(EIO);
1892                        return;
1893                    }
1894
1895                    // Trigger reindex after truncate
1896                    if let Some(ref sender) = self.reindex_sender {
1897                        let sender = sender.clone();
1898                        let path_to_send = path.clone();
1899                        self.runtime.spawn(async move {
1900                            if let Err(e) = sender.send(path_to_send).await {
1901                                warn!("Failed to send reindex request: {}", e);
1902                            }
1903                        });
1904                    }
1905                }
1906                Err(e) => {
1907                    warn!("Failed to open {:?} for truncate: {}", path, e);
1908                    reply.error(EIO);
1909                    return;
1910                }
1911            }
1912        }
1913
1914        // Return updated attributes
1915        if let Some(attr) = self.real_path_to_attr(&path, ino) {
1916            reply.attr(&TTL, &attr);
1917        } else {
1918            reply.error(EIO);
1919        }
1920    }
1921}
1922
1923/// Truncate a string to max length, adding ellipsis if needed.
1924fn truncate(s: &str, max_len: usize) -> String {
1925    let s = s.replace('\n', " ").replace('\r', "");
1926    if s.len() <= max_len {
1927        s
1928    } else {
1929        format!("{}...", &s[..max_len.saturating_sub(3)])
1930    }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936
1937    // ========== truncate() Helper Function Tests ==========
1938
1939    #[test]
1940    fn test_truncate_short_string() {
1941        let result = truncate("Hello", 10);
1942        assert_eq!(result, "Hello");
1943    }
1944
1945    #[test]
1946    fn test_truncate_exact_length() {
1947        let result = truncate("Hello", 5);
1948        assert_eq!(result, "Hello");
1949    }
1950
1951    #[test]
1952    fn test_truncate_long_string() {
1953        let result = truncate("Hello, World!", 8);
1954        assert_eq!(result, "Hello...");
1955    }
1956
1957    #[test]
1958    fn test_truncate_removes_newlines() {
1959        let result = truncate("Hello\nWorld\nTest", 100);
1960        assert_eq!(result, "Hello World Test");
1961    }
1962
1963    #[test]
1964    fn test_truncate_removes_carriage_returns() {
1965        // \n is replaced with space, \r is deleted
1966        let result = truncate("Hello\r\nWorld", 100);
1967        assert_eq!(result, "Hello World");
1968    }
1969
1970    #[test]
1971    fn test_truncate_empty_string() {
1972        let result = truncate("", 10);
1973        assert_eq!(result, "");
1974    }
1975
1976    #[test]
1977    fn test_truncate_very_short_max() {
1978        let result = truncate("Hello", 3);
1979        assert_eq!(result, "...");
1980    }
1981
1982    #[test]
1983    fn test_truncate_max_zero() {
1984        let result = truncate("Hello", 0);
1985        assert_eq!(result, "...");
1986    }
1987
1988    #[test]
1989    fn test_truncate_unicode() {
1990        let result = truncate("こんにちは世界", 100);
1991        assert_eq!(result, "こんにちは世界");
1992    }
1993
1994    #[test]
1995    fn test_truncate_with_mixed_whitespace() {
1996        // \n\n -> "  ", \r\n\r\n -> "  " (two \n->space, two \r->deleted)
1997        let result = truncate("Line1\n\nLine2\r\n\r\nLine3", 100);
1998        assert_eq!(result, "Line1  Line2  Line3");
1999    }
2000
2001    // ========== RagFs Construction Tests ==========
2002
2003    #[tokio::test]
2004    async fn test_ragfs_new() {
2005        let source = PathBuf::from("/tmp/test");
2006        let fs = RagFs::new(source.clone());
2007
2008        assert_eq!(fs.source(), &source);
2009        assert!(fs.store.is_none());
2010        assert!(fs.query_executor.is_none());
2011    }
2012
2013    #[tokio::test]
2014    async fn test_ragfs_source_getter() {
2015        let source = PathBuf::from("/my/test/directory");
2016        let fs = RagFs::new(source.clone());
2017
2018        assert_eq!(fs.source(), &source);
2019    }
2020
2021    #[tokio::test]
2022    async fn test_ragfs_inode_table_initialized() {
2023        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2024
2025        let inodes = fs.inodes.read().await;
2026        // Virtual inodes should be initialized
2027        assert!(inodes.get(ROOT_INO).is_some());
2028        assert!(inodes.get(RAGFS_DIR_INO).is_some());
2029        assert!(inodes.get(QUERY_DIR_INO).is_some());
2030    }
2031
2032    #[tokio::test]
2033    async fn test_ragfs_content_cache_empty() {
2034        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2035
2036        let cache = fs.content_cache.read().await;
2037        assert!(cache.is_empty());
2038    }
2039
2040    // ========== get_config() Tests ==========
2041
2042    #[tokio::test]
2043    async fn test_get_config_without_rag() {
2044        let fs = RagFs::new(PathBuf::from("/tmp/test-config"));
2045        let config = fs.get_config();
2046
2047        let json: serde_json::Value = serde_json::from_slice(&config).expect("Valid JSON");
2048
2049        assert_eq!(json["source"], "/tmp/test-config");
2050        assert_eq!(json["store_configured"], false);
2051        assert_eq!(json["query_executor_configured"], false);
2052    }
2053
2054    #[tokio::test]
2055    async fn test_get_config_returns_valid_json() {
2056        let fs = RagFs::new(PathBuf::from("/test/path"));
2057        let config = fs.get_config();
2058
2059        // Should be valid UTF-8
2060        let config_str = String::from_utf8(config).expect("Valid UTF-8");
2061
2062        // Should be parseable JSON
2063        let json: serde_json::Value = serde_json::from_str(&config_str).expect("Valid JSON");
2064
2065        // Should have expected fields
2066        assert!(json.get("source").is_some());
2067        assert!(json.get("store_configured").is_some());
2068        assert!(json.get("query_executor_configured").is_some());
2069    }
2070
2071    // ========== get_index_status() Tests ==========
2072
2073    #[tokio::test]
2074    async fn test_get_index_status_without_store() {
2075        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2076        let status = fs.get_index_status();
2077
2078        let json: serde_json::Value = serde_json::from_slice(&status).expect("Valid JSON");
2079
2080        assert_eq!(json["status"], "not_initialized");
2081        assert_eq!(json["message"], "No store configured");
2082    }
2083
2084    #[tokio::test]
2085    async fn test_get_index_status_returns_valid_json() {
2086        let fs = RagFs::new(PathBuf::from("/test/path"));
2087        let status = fs.get_index_status();
2088
2089        // Should be valid UTF-8
2090        let status_str = String::from_utf8(status).expect("Valid UTF-8");
2091
2092        // Should be parseable JSON
2093        let _json: serde_json::Value = serde_json::from_str(&status_str).expect("Valid JSON");
2094    }
2095
2096    // ========== execute_query() Tests ==========
2097
2098    #[tokio::test]
2099    async fn test_execute_query_without_executor() {
2100        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2101        let result = fs.execute_query("test query");
2102
2103        let json: serde_json::Value = serde_json::from_slice(&result).expect("Valid JSON");
2104
2105        assert_eq!(json["error"], "Query executor not configured");
2106    }
2107
2108    #[tokio::test]
2109    async fn test_execute_query_returns_valid_json() {
2110        let fs = RagFs::new(PathBuf::from("/test/path"));
2111        let result = fs.execute_query("any query");
2112
2113        // Should be valid UTF-8
2114        let result_str = String::from_utf8(result).expect("Valid UTF-8");
2115
2116        // Should be parseable JSON
2117        let _json: serde_json::Value = serde_json::from_str(&result_str).expect("Valid JSON");
2118    }
2119
2120    // ========== Constants Tests ==========
2121
2122    #[test]
2123    fn test_ttl_is_reasonable() {
2124        assert_eq!(TTL, Duration::from_secs(1));
2125    }
2126
2127    #[test]
2128    fn test_block_size_is_standard() {
2129        assert_eq!(BLOCK_SIZE, 512);
2130    }
2131
2132    // ========== make_attr() Tests ==========
2133
2134    #[tokio::test]
2135    async fn test_make_attr_directory() {
2136        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2137        let attr = fs.make_attr(100, fuser::FileType::Directory, 0);
2138
2139        assert_eq!(attr.ino, 100);
2140        assert_eq!(attr.size, 0);
2141        assert_eq!(attr.kind, fuser::FileType::Directory);
2142        assert_eq!(attr.perm, 0o755);
2143        assert_eq!(attr.nlink, 2);
2144    }
2145
2146    #[tokio::test]
2147    async fn test_make_attr_regular_file() {
2148        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2149        let attr = fs.make_attr(200, fuser::FileType::RegularFile, 1024);
2150
2151        assert_eq!(attr.ino, 200);
2152        assert_eq!(attr.size, 1024);
2153        assert_eq!(attr.kind, fuser::FileType::RegularFile);
2154        assert_eq!(attr.perm, 0o644);
2155        assert_eq!(attr.nlink, 1);
2156    }
2157
2158    #[tokio::test]
2159    async fn test_make_attr_blocks_calculation() {
2160        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2161
2162        // Test exact block boundary
2163        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 512);
2164        assert_eq!(attr.blocks, 1);
2165
2166        // Test one byte over
2167        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 513);
2168        assert_eq!(attr.blocks, 2);
2169
2170        // Test empty file
2171        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 0);
2172        assert_eq!(attr.blocks, 0);
2173    }
2174
2175    #[tokio::test]
2176    async fn test_make_attr_has_current_uid_gid() {
2177        let fs = RagFs::new(PathBuf::from("/tmp/test"));
2178        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 0);
2179
2180        // Should have current user's uid/gid
2181        #[allow(unsafe_code)]
2182        let expected_uid = unsafe { libc::getuid() };
2183        #[allow(unsafe_code)]
2184        let expected_gid = unsafe { libc::getgid() };
2185
2186        assert_eq!(attr.uid, expected_uid);
2187        assert_eq!(attr.gid, expected_gid);
2188    }
2189}