Skip to main content

ragfs_fuse/filesystem/
mod.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 ragfs_core::{Embedder, VectorStore};
8use ragfs_query::QueryExecutor;
9use std::collections::HashMap;
10use std::ffi::OsStr;
11use std::fs;
12use std::os::unix::fs::MetadataExt;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use tokio::runtime::Handle;
17use tokio::sync::{RwLock, mpsc};
18use tracing::debug;
19
20use crate::inode::{
21    APPROVE_FILE_INO, CLEANUP_FILE_INO, CONFIG_FILE_INO, DEDUPE_FILE_INO, HELP_FILE_INO,
22    HISTORY_FILE_INO, INDEX_FILE_INO, InodeKind, InodeTable, OPS_BATCH_INO, OPS_CREATE_INO,
23    OPS_DELETE_INO, OPS_DIR_INO, OPS_MOVE_INO, OPS_RESULT_INO, ORGANIZE_FILE_INO, PENDING_DIR_INO,
24    QUERY_DIR_INO, RAGFS_DIR_INO, REINDEX_FILE_INO, REJECT_FILE_INO, ROOT_INO, SAFETY_DIR_INO,
25    SEARCH_DIR_INO, SEMANTIC_DIR_INO, SIMILAR_DIR_INO, SIMILAR_OPS_FILE_INO, TRASH_DIR_INO,
26    UNDO_FILE_INO,
27};
28use crate::ops::OpsManager;
29use crate::safety::SafetyManager;
30use crate::semantic::SemanticManager;
31
32mod ops;
33mod passthrough;
34mod query;
35mod safety;
36mod semantic;
37
38pub(crate) const TTL: Duration = Duration::from_secs(1);
39pub(crate) const BLOCK_SIZE: u64 = 512;
40
41/// RAGFS FUSE filesystem.
42pub struct RagFs {
43    /// Source directory being indexed
44    source: PathBuf,
45    /// Inode table
46    inodes: Arc<RwLock<InodeTable>>,
47    /// Vector store for queries and stats
48    store: Option<Arc<dyn VectorStore>>,
49    /// Query executor
50    query_executor: Option<Arc<QueryExecutor>>,
51    /// Tokio runtime handle for async operations
52    runtime: Handle,
53    /// Cache for virtual file contents (query results, index status)
54    content_cache: Arc<RwLock<HashMap<u64, Vec<u8>>>>,
55    /// Channel sender for reindex requests
56    reindex_sender: Option<mpsc::Sender<PathBuf>>,
57    /// Operations manager for agent file management
58    ops_manager: Arc<OpsManager>,
59    /// Safety manager for trash/history/undo
60    safety_manager: Arc<SafetyManager>,
61    /// Semantic manager for intelligent operations
62    semantic_manager: Arc<SemanticManager>,
63}
64
65impl RagFs {
66    /// Create a new RAGFS filesystem (basic, for passthrough only).
67    #[must_use]
68    pub fn new(source: PathBuf) -> Self {
69        let safety_manager = Arc::new(SafetyManager::new(&source, None));
70        let ops_manager = Arc::new(OpsManager::with_safety(
71            source.clone(),
72            None,
73            None,
74            safety_manager.clone(),
75        ));
76        let semantic_manager = Arc::new(SemanticManager::with_ops(
77            source.clone(),
78            None,
79            None,
80            None,
81            ops_manager.clone(),
82        ));
83        Self {
84            source,
85            inodes: Arc::new(RwLock::new(InodeTable::new())),
86            store: None,
87            query_executor: None,
88            runtime: Handle::current(),
89            content_cache: Arc::new(RwLock::new(HashMap::new())),
90            reindex_sender: None,
91            ops_manager,
92            safety_manager,
93            semantic_manager,
94        }
95    }
96
97    /// Create a new RAGFS filesystem with full RAG capabilities.
98    ///
99    /// Hybrid search defaults to on (same as `[query].hybrid` in config.toml).
100    pub fn with_rag(
101        source: PathBuf,
102        store: Arc<dyn VectorStore>,
103        embedder: Arc<dyn Embedder>,
104        runtime: Handle,
105        reindex_sender: Option<mpsc::Sender<PathBuf>>,
106    ) -> Self {
107        Self::with_rag_query(
108            source,
109            store,
110            embedder,
111            runtime,
112            reindex_sender,
113            10,
114            true,
115            100,
116        )
117    }
118
119    /// Create a RAG-enabled filesystem with query settings from config / CLI.
120    pub fn with_rag_query(
121        source: PathBuf,
122        store: Arc<dyn VectorStore>,
123        embedder: Arc<dyn Embedder>,
124        runtime: Handle,
125        reindex_sender: Option<mpsc::Sender<PathBuf>>,
126        default_limit: usize,
127        hybrid: bool,
128        max_limit: usize,
129    ) -> Self {
130        let query_executor = Arc::new(
131            QueryExecutor::new(store.clone(), embedder.clone(), default_limit, hybrid)
132                .with_max_limit(max_limit),
133        );
134
135        let safety_manager = Arc::new(SafetyManager::new(&source, None));
136        let ops_manager = Arc::new(OpsManager::with_safety(
137            source.clone(),
138            Some(store.clone()),
139            reindex_sender.clone(),
140            safety_manager.clone(),
141        ));
142        let semantic_manager = Arc::new(SemanticManager::with_ops(
143            source.clone(),
144            Some(store.clone()),
145            Some(embedder.clone()),
146            None,
147            ops_manager.clone(),
148        ));
149
150        Self {
151            source,
152            inodes: Arc::new(RwLock::new(InodeTable::new())),
153            store: Some(store),
154            query_executor: Some(query_executor),
155            runtime,
156            content_cache: Arc::new(RwLock::new(HashMap::new())),
157            reindex_sender,
158            ops_manager,
159            safety_manager,
160            semantic_manager,
161        }
162    }
163
164    /// Get the source directory.
165    #[must_use]
166    pub fn source(&self) -> &PathBuf {
167        &self.source
168    }
169
170    /// Resolve a `.reindex` path and reject anything that escapes the source root.
171    fn jail_reindex_path(&self, path: &Path) -> Result<PathBuf, String> {
172        ragfs_core::resolve_under_root(&self.source, path)
173    }
174
175    /// Convert a real path to a FUSE inode.
176    fn real_path_to_attr(&self, path: &PathBuf, ino: u64) -> Option<FileAttr> {
177        let metadata = fs::metadata(path).ok()?;
178        let kind = if metadata.is_dir() {
179            FileType::Directory
180        } else if metadata.is_file() {
181            FileType::RegularFile
182        } else if metadata.file_type().is_symlink() {
183            FileType::Symlink
184        } else {
185            return None;
186        };
187
188        let atime = metadata.accessed().unwrap_or(SystemTime::UNIX_EPOCH);
189        let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
190        let ctime = UNIX_EPOCH + Duration::from_secs(metadata.ctime() as u64);
191
192        Some(FileAttr {
193            ino,
194            size: metadata.len(),
195            blocks: metadata.len().div_ceil(BLOCK_SIZE),
196            atime,
197            mtime,
198            ctime,
199            crtime: ctime,
200            kind,
201            perm: (metadata.mode() & 0o7777) as u16,
202            nlink: metadata.nlink() as u32,
203            uid: metadata.uid(),
204            gid: metadata.gid(),
205            rdev: metadata.rdev() as u32,
206            blksize: BLOCK_SIZE as u32,
207            flags: 0,
208        })
209    }
210
211    #[allow(unsafe_code)]
212    fn make_attr(&self, ino: u64, kind: FileType, size: u64) -> FileAttr {
213        let now = SystemTime::now();
214        // SAFETY: getuid() and getgid() are always safe to call
215        let uid = unsafe { libc::getuid() };
216        let gid = unsafe { libc::getgid() };
217        FileAttr {
218            ino,
219            size,
220            blocks: size.div_ceil(BLOCK_SIZE),
221            atime: now,
222            mtime: now,
223            ctime: now,
224            crtime: now,
225            kind,
226            perm: if kind == FileType::Directory {
227                0o755
228            } else {
229                0o644
230            },
231            nlink: if kind == FileType::Directory { 2 } else { 1 },
232            uid,
233            gid,
234            rdev: 0,
235            blksize: BLOCK_SIZE as u32,
236            flags: 0,
237        }
238    }
239
240    /// Get index status as JSON.
241    fn get_index_status(&self) -> Vec<u8> {
242        if let Some(ref store) = self.store {
243            let store = store.clone();
244            let result = self.runtime.block_on(async { store.stats().await });
245
246            match result {
247                Ok(stats) => {
248                    let json = serde_json::json!({
249                        "status": "indexed",
250                        "total_files": stats.total_files,
251                        "total_chunks": stats.total_chunks,
252                        "index_size_bytes": stats.index_size_bytes,
253                        "last_updated": stats.last_updated.map(|t| t.to_rfc3339()),
254                    });
255                    serde_json::to_string_pretty(&json)
256                        .unwrap_or_default()
257                        .into_bytes()
258                }
259                Err(e) => {
260                    let json = serde_json::json!({
261                        "status": "error",
262                        "error": e.to_string(),
263                    });
264                    serde_json::to_string_pretty(&json)
265                        .unwrap_or_default()
266                        .into_bytes()
267                }
268            }
269        } else {
270            let json = serde_json::json!({
271                "status": "not_initialized",
272                "message": "No store configured",
273            });
274            serde_json::to_string_pretty(&json)
275                .unwrap_or_default()
276                .into_bytes()
277        }
278    }
279
280    /// Execute a query and return results as JSON.
281    fn execute_query(&self, query: &str) -> Vec<u8> {
282        if let Some(ref executor) = self.query_executor {
283            let executor = executor.clone();
284            let query_str = query.to_string();
285            let query_for_result = query_str.clone();
286            let result = self
287                .runtime
288                .block_on(async move { executor.execute(&query_str).await });
289
290            match result {
291                Ok(results) => {
292                    let json_results: Vec<_> = results
293                        .iter()
294                        .map(|r| {
295                            serde_json::json!({
296                                "file": r.file_path.to_string_lossy(),
297                                "score": r.score,
298                                "content": truncate(&r.content, 500),
299                                "byte_range": [r.byte_range.start, r.byte_range.end],
300                                "line_range": r.line_range.as_ref().map(|lr| [lr.start, lr.end]),
301                            })
302                        })
303                        .collect();
304
305                    let json = serde_json::json!({
306                        "query": query_for_result,
307                        "results": json_results,
308                    });
309                    serde_json::to_string_pretty(&json)
310                        .unwrap_or_default()
311                        .into_bytes()
312                }
313                Err(e) => {
314                    let json = serde_json::json!({
315                        "query": query_for_result,
316                        "error": e.to_string(),
317                    });
318                    serde_json::to_string_pretty(&json)
319                        .unwrap_or_default()
320                        .into_bytes()
321                }
322            }
323        } else {
324            let json = serde_json::json!({
325                "error": "Query executor not configured",
326            });
327            serde_json::to_string_pretty(&json)
328                .unwrap_or_default()
329                .into_bytes()
330        }
331    }
332
333    /// Get configuration as JSON.
334    ///
335    /// This is mount wiring, not `~/.config/ragfs/config.toml`.
336    fn get_config(&self) -> Vec<u8> {
337        let json = serde_json::json!({
338            "api_version": "0.2",
339            "source": self.source.to_string_lossy(),
340            "store_configured": self.store.is_some(),
341            "query_executor_configured": self.query_executor.is_some(),
342            "interfaces": {
343                "query": true,
344                "ops": true,
345                "safety": true,
346                "semantic": true
347            },
348            "note": "Mount wiring only. User TOML is not echoed here.",
349        });
350        serde_json::to_string_pretty(&json)
351            .unwrap_or_default()
352            .into_bytes()
353    }
354
355    /// Resolve a parent inode to a real path.
356    /// Returns None if the parent doesn't exist or is not a directory.
357    fn resolve_parent_path(&self, parent: u64) -> Option<PathBuf> {
358        if parent == ROOT_INO {
359            return Some(self.source.clone());
360        }
361
362        let inodes = self.runtime.block_on(self.inodes.read());
363        if let Some(entry) = inodes.get(parent)
364            && let InodeKind::Real { path, .. } = &entry.kind
365        {
366            Some(path.clone())
367        } else {
368            None
369        }
370    }
371
372    /// Get help content for the virtual control directory.
373    fn get_help_content(&self) -> Vec<u8> {
374        r#"RAGFS Virtual Control Directory
375================================
376
377The .ragfs directory is the agent control plane: search, file ops,
378soft-delete, and propose/approve semantic plans.
379
380Search and index
381----------------
382  .index          Read index statistics (JSON).
383  .config         Read mount wiring (JSON). Includes api_version.
384                  This is not ~/.config/ragfs/config.toml.
385  .reindex        Write a path to queue reindex (relative paths join source).
386                  Absolute paths and escapes that leave the source are rejected.
387                  Example: echo "src/main.rs" > .ragfs/.reindex
388  .query/<q>      Semantic search. Filename is the query.
389                  Returns JSON results.
390                  Example: cat ".ragfs/.query/authentication"
391  .search/<q>     Symlinks to matching files.
392  .similar/<path> Symlinks to files similar to <path>.
393  .help           This file.
394
395Agent operations (.ops/)
396------------------------
397  .ops/.create    Write: path<newline>content
398  .ops/.delete    Write: path  (soft-delete when safety is on)
399  .ops/.move      Write: src<newline>dst
400  .ops/.batch     Write: JSON BatchRequest (create/delete/move/copy/write/mkdir/symlink)
401  .ops/.result    Read:  JSON OperationResult of the last op (global; not per-agent)
402
403  echo -e "notes.md\n# Hello" > .ragfs/.ops/.create
404  cat .ragfs/.ops/.result
405
406Safety (.safety/)
407-----------------
408  .safety/.trash/   Soft-deleted files (recoverable)
409  .safety/.history  Audit log (JSONL)
410  .safety/.undo     Write the history operation_id (same as undo_id in .result)
411
412  echo "<undo_id>" > .ragfs/.safety/.undo
413
414Semantic (.semantic/) — Beta
415----------------------------
416  .semantic/.organize  Write OrganizeRequest JSON → plan in .pending/
417  .semantic/.similar   Write a path → similar files JSON
418  .semantic/.cleanup   Read cleanup analysis (duplicates today)
419  .semantic/.dedupe    Read duplicate groups
420  .semantic/.pending/  Proposed plans
421  .semantic/.approve   Write plan_id to execute
422  .semantic/.reject    Write plan_id to cancel
423
424Limits (honest)
425---------------
426  - .ops/.semantic/.reindex paths are jailed to the source root. Absolute paths
427    and `..`/symlink hops that leave the source are rejected.
428  - .ops/.result is the last operation only; concurrent agents overwrite it.
429  - Semantic ByProject/cleanup is Beta: organize may only mkdir; cleanup is mostly duplicates.
430  - allow_other (if enabled) exposes .ops/.safety/.semantic to every local user.
431  - Code chunking is pattern-based (function/class signatures), not tree-sitter.
432  - Default extractors: UTF-8 text/code, PDF, images. Binary .doc is not supported.
433"#
434        .as_bytes()
435        .to_vec()
436    }
437}
438
439pub(crate) fn reply_file_bytes(reply: ReplyData, content: &[u8], offset: i64, size: u32) {
440    let offset = offset as usize;
441    let size = size as usize;
442    if offset >= content.len() {
443        reply.data(&[]);
444    } else {
445        let end = (offset + size).min(content.len());
446        reply.data(&content[offset..end]);
447    }
448}
449
450impl Filesystem for RagFs {
451    fn init(
452        &mut self,
453        _req: &Request<'_>,
454        _config: &mut fuser::KernelConfig,
455    ) -> Result<(), libc::c_int> {
456        debug!("FUSE init for source: {:?}", self.source);
457        Ok(())
458    }
459
460    fn destroy(&mut self) {
461        debug!("FUSE destroy");
462    }
463
464    fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) {
465        let name_str = name.to_string_lossy();
466        debug!("lookup: parent={}, name={}", parent, name_str);
467
468        match parent {
469            ROOT_INO => self.lookup_root(&name_str, reply),
470            RAGFS_DIR_INO => self.lookup_ragfs_dir(&name_str, reply),
471            SEMANTIC_DIR_INO => self.lookup_semantic_dir(&name_str, reply),
472            PENDING_DIR_INO => self.lookup_pending_dir(&name_str, reply),
473            SAFETY_DIR_INO => self.lookup_safety_dir(&name_str, reply),
474            OPS_DIR_INO => self.lookup_ops_dir(&name_str, reply),
475            QUERY_DIR_INO => self.lookup_query_dir(&name_str, reply),
476            _ => self.lookup_passthrough(parent, &name_str, reply),
477        }
478    }
479
480    fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
481        debug!("getattr: ino={}", ino);
482
483        match ino {
484            ROOT_INO => {
485                let attr = self.make_attr(ROOT_INO, FileType::Directory, 0);
486                reply.attr(&TTL, &attr);
487            }
488            RAGFS_DIR_INO => {
489                let attr = self.make_attr(RAGFS_DIR_INO, FileType::Directory, 0);
490                reply.attr(&TTL, &attr);
491            }
492            QUERY_DIR_INO | SEARCH_DIR_INO | SIMILAR_DIR_INO | INDEX_FILE_INO | CONFIG_FILE_INO
493            | REINDEX_FILE_INO | HELP_FILE_INO => self.getattr_query(ino, reply),
494            OPS_DIR_INO | OPS_CREATE_INO | OPS_DELETE_INO | OPS_MOVE_INO | OPS_BATCH_INO
495            | OPS_RESULT_INO => self.getattr_ops(ino, reply),
496            SAFETY_DIR_INO | TRASH_DIR_INO | HISTORY_FILE_INO | UNDO_FILE_INO => {
497                self.getattr_safety(ino, reply);
498            }
499            SEMANTIC_DIR_INO | PENDING_DIR_INO | SIMILAR_OPS_FILE_INO | CLEANUP_FILE_INO
500            | DEDUPE_FILE_INO | ORGANIZE_FILE_INO | APPROVE_FILE_INO | REJECT_FILE_INO => {
501                self.getattr_semantic(ino, reply);
502            }
503            _ => self.getattr_cached_or_passthrough(ino, reply),
504        }
505    }
506
507    fn read(
508        &mut self,
509        _req: &Request<'_>,
510        ino: u64,
511        _fh: u64,
512        offset: i64,
513        size: u32,
514        _flags: i32,
515        _lock_owner: Option<u64>,
516        reply: ReplyData,
517    ) {
518        debug!("read: ino={}, offset={}, size={}", ino, offset, size);
519
520        let cache = self.runtime.block_on(self.content_cache.read());
521        if let Some(content) = cache.get(&ino) {
522            reply_file_bytes(reply, content, offset, size);
523            return;
524        }
525        drop(cache);
526
527        match ino {
528            INDEX_FILE_INO | CONFIG_FILE_INO | REINDEX_FILE_INO | HELP_FILE_INO => {
529                self.read_query(ino, offset, size, reply);
530            }
531            OPS_RESULT_INO | OPS_CREATE_INO | OPS_DELETE_INO | OPS_MOVE_INO | OPS_BATCH_INO => {
532                self.read_ops(ino, offset, size, reply);
533            }
534            HISTORY_FILE_INO | UNDO_FILE_INO => self.read_safety(ino, offset, size, reply),
535            SIMILAR_OPS_FILE_INO | CLEANUP_FILE_INO | DEDUPE_FILE_INO | ORGANIZE_FILE_INO
536            | APPROVE_FILE_INO | REJECT_FILE_INO => self.read_semantic(ino, offset, size, reply),
537            _ => self.read_passthrough(ino, offset, size, reply),
538        }
539    }
540
541    fn readdir(
542        &mut self,
543        _req: &Request<'_>,
544        ino: u64,
545        _fh: u64,
546        offset: i64,
547        reply: ReplyDirectory,
548    ) {
549        debug!("readdir: ino={}, offset={}", ino, offset);
550
551        match ino {
552            ROOT_INO => self.readdir_root(offset, reply),
553            RAGFS_DIR_INO => self.readdir_ragfs(offset, reply),
554            QUERY_DIR_INO | SEARCH_DIR_INO | SIMILAR_DIR_INO => {
555                self.readdir_query(ino, offset, reply);
556            }
557            OPS_DIR_INO => self.readdir_ops(offset, reply),
558            SAFETY_DIR_INO | TRASH_DIR_INO => self.readdir_safety(ino, offset, reply),
559            SEMANTIC_DIR_INO | PENDING_DIR_INO => self.readdir_semantic(ino, offset, reply),
560            _ => self.readdir_passthrough(ino, offset, reply),
561        }
562    }
563
564    fn open(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) {
565        debug!("open: ino={}, flags={}", ino, flags);
566        reply.opened(0, 0);
567    }
568
569    fn opendir(&mut self, _req: &Request<'_>, ino: u64, flags: i32, reply: ReplyOpen) {
570        debug!("opendir: ino={}, flags={}", ino, flags);
571        reply.opened(0, 0);
572    }
573
574    fn write(
575        &mut self,
576        _req: &Request<'_>,
577        ino: u64,
578        _fh: u64,
579        _offset: i64,
580        data: &[u8],
581        _write_flags: u32,
582        _flags: i32,
583        _lock_owner: Option<u64>,
584        reply: ReplyWrite,
585    ) {
586        debug!("write: ino={}, len={}", ino, data.len());
587
588        match ino {
589            REINDEX_FILE_INO => self.write_reindex(data, reply),
590            OPS_CREATE_INO | OPS_DELETE_INO | OPS_MOVE_INO | OPS_BATCH_INO => {
591                self.write_ops(ino, data, reply);
592            }
593            UNDO_FILE_INO => self.write_safety(ino, data, reply),
594            ORGANIZE_FILE_INO | SIMILAR_OPS_FILE_INO | APPROVE_FILE_INO | REJECT_FILE_INO => {
595                self.write_semantic(ino, data, reply);
596            }
597            _ => self.write_passthrough(ino, data, reply),
598        }
599    }
600
601    fn forget(&mut self, _req: &Request<'_>, ino: u64, nlookup: u64) {
602        debug!("forget: ino={}, nlookup={}", ino, nlookup);
603        let mut inodes = self.runtime.block_on(self.inodes.write());
604        inodes.forget(ino, nlookup);
605    }
606
607    fn create(
608        &mut self,
609        _req: &Request<'_>,
610        parent: u64,
611        name: &OsStr,
612        mode: u32,
613        umask: u32,
614        flags: i32,
615        reply: ReplyCreate,
616    ) {
617        self.create_passthrough(parent, name, mode, umask, flags, reply);
618    }
619
620    fn unlink(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
621        self.unlink_passthrough(parent, name, reply);
622    }
623
624    fn mkdir(
625        &mut self,
626        _req: &Request<'_>,
627        parent: u64,
628        name: &OsStr,
629        mode: u32,
630        umask: u32,
631        reply: ReplyEntry,
632    ) {
633        self.mkdir_passthrough(parent, name, mode, umask, reply);
634    }
635
636    fn rmdir(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) {
637        self.rmdir_passthrough(parent, name, reply);
638    }
639
640    fn rename(
641        &mut self,
642        _req: &Request<'_>,
643        parent: u64,
644        name: &OsStr,
645        newparent: u64,
646        newname: &OsStr,
647        flags: u32,
648        reply: ReplyEmpty,
649    ) {
650        self.rename_passthrough(parent, name, newparent, newname, flags, reply);
651    }
652
653    fn setattr(
654        &mut self,
655        _req: &Request<'_>,
656        ino: u64,
657        mode: Option<u32>,
658        uid: Option<u32>,
659        gid: Option<u32>,
660        size: Option<u64>,
661        atime: Option<TimeOrNow>,
662        mtime: Option<TimeOrNow>,
663        ctime: Option<SystemTime>,
664        fh: Option<u64>,
665        crtime: Option<SystemTime>,
666        chgtime: Option<SystemTime>,
667        bkuptime: Option<SystemTime>,
668        flags: Option<u32>,
669        reply: ReplyAttr,
670    ) {
671        self.setattr_passthrough(
672            ino, mode, uid, gid, size, atime, mtime, ctime, fh, crtime, chgtime, bkuptime, flags,
673            reply,
674        );
675    }
676}
677
678/// Truncate a string to max length, adding ellipsis if needed.
679fn truncate(s: &str, max_len: usize) -> String {
680    let s = s.replace('\n', " ").replace('\r', "");
681    if s.len() <= max_len {
682        s
683    } else {
684        format!("{}...", &s[..max_len.saturating_sub(3)])
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    // ========== truncate() Helper Function Tests ==========
693
694    #[test]
695    fn test_truncate_short_string() {
696        let result = truncate("Hello", 10);
697        assert_eq!(result, "Hello");
698    }
699
700    #[test]
701    fn test_truncate_exact_length() {
702        let result = truncate("Hello", 5);
703        assert_eq!(result, "Hello");
704    }
705
706    #[test]
707    fn test_truncate_long_string() {
708        let result = truncate("Hello, World!", 8);
709        assert_eq!(result, "Hello...");
710    }
711
712    #[test]
713    fn test_truncate_removes_newlines() {
714        let result = truncate("Hello\nWorld\nTest", 100);
715        assert_eq!(result, "Hello World Test");
716    }
717
718    #[test]
719    fn test_truncate_removes_carriage_returns() {
720        // \n is replaced with space, \r is deleted
721        let result = truncate("Hello\r\nWorld", 100);
722        assert_eq!(result, "Hello World");
723    }
724
725    #[test]
726    fn test_truncate_empty_string() {
727        let result = truncate("", 10);
728        assert_eq!(result, "");
729    }
730
731    #[test]
732    fn test_truncate_very_short_max() {
733        let result = truncate("Hello", 3);
734        assert_eq!(result, "...");
735    }
736
737    #[test]
738    fn test_truncate_max_zero() {
739        let result = truncate("Hello", 0);
740        assert_eq!(result, "...");
741    }
742
743    #[test]
744    fn test_truncate_unicode() {
745        let result = truncate("こんにちは世界", 100);
746        assert_eq!(result, "こんにちは世界");
747    }
748
749    #[test]
750    fn test_truncate_with_mixed_whitespace() {
751        // \n\n -> "  ", \r\n\r\n -> "  " (two \n->space, two \r->deleted)
752        let result = truncate("Line1\n\nLine2\r\n\r\nLine3", 100);
753        assert_eq!(result, "Line1  Line2  Line3");
754    }
755
756    // ========== RagFs Construction Tests ==========
757
758    #[tokio::test]
759    async fn test_ragfs_new() {
760        let source = PathBuf::from("/tmp/test");
761        let fs = RagFs::new(source.clone());
762
763        assert_eq!(fs.source(), &source);
764        assert!(fs.store.is_none());
765        assert!(fs.query_executor.is_none());
766    }
767
768    #[tokio::test]
769    async fn test_ragfs_source_getter() {
770        let source = PathBuf::from("/my/test/directory");
771        let fs = RagFs::new(source.clone());
772
773        assert_eq!(fs.source(), &source);
774    }
775
776    #[tokio::test]
777    async fn test_ragfs_inode_table_initialized() {
778        let fs = RagFs::new(PathBuf::from("/tmp/test"));
779
780        let inodes = fs.inodes.read().await;
781        // Virtual inodes should be initialized
782        assert!(inodes.get(ROOT_INO).is_some());
783        assert!(inodes.get(RAGFS_DIR_INO).is_some());
784        assert!(inodes.get(QUERY_DIR_INO).is_some());
785    }
786
787    #[tokio::test]
788    async fn test_ragfs_content_cache_empty() {
789        let fs = RagFs::new(PathBuf::from("/tmp/test"));
790
791        let cache = fs.content_cache.read().await;
792        assert!(cache.is_empty());
793    }
794
795    // ========== get_config() Tests ==========
796
797    #[tokio::test]
798    async fn test_get_config_without_rag() {
799        let fs = RagFs::new(PathBuf::from("/tmp/test-config"));
800        let config = fs.get_config();
801
802        let json: serde_json::Value = serde_json::from_slice(&config).expect("Valid JSON");
803
804        assert_eq!(json["source"], "/tmp/test-config");
805        assert_eq!(json["store_configured"], false);
806        assert_eq!(json["query_executor_configured"], false);
807        assert_eq!(json["api_version"], "0.2");
808        assert_eq!(json["interfaces"]["ops"], true);
809    }
810
811    #[tokio::test]
812    async fn test_get_config_returns_valid_json() {
813        let fs = RagFs::new(PathBuf::from("/test/path"));
814        let config = fs.get_config();
815
816        // Should be valid UTF-8
817        let config_str = String::from_utf8(config).expect("Valid UTF-8");
818
819        // Should be parseable JSON
820        let json: serde_json::Value = serde_json::from_str(&config_str).expect("Valid JSON");
821
822        // Should have expected fields
823        assert!(json.get("source").is_some());
824        assert!(json.get("store_configured").is_some());
825        assert!(json.get("query_executor_configured").is_some());
826        assert_eq!(json["api_version"], "0.2");
827        assert!(json.get("interfaces").is_some());
828    }
829
830    #[tokio::test]
831    async fn test_help_mentions_agent_interfaces() {
832        let fs = RagFs::new(PathBuf::from("/tmp/test-help"));
833        let help = String::from_utf8(fs.get_help_content()).expect("Valid UTF-8");
834        assert!(help.contains(".ops/"), "help must document .ops/");
835        assert!(help.contains(".safety/"), "help must document .safety/");
836        assert!(help.contains(".semantic/"), "help must document .semantic/");
837        assert!(help.contains(".undo"), "help must document undo");
838        assert!(help.contains("api_version"));
839        assert!(
840            help.contains("jailed to the source root"),
841            "help must document the source-root path jail"
842        );
843        assert!(
844            !help.contains("not path-jailed"),
845            "help must not claim .reindex is unjailed"
846        );
847    }
848
849    // ========== get_index_status() Tests ==========
850
851    #[tokio::test]
852    async fn test_get_index_status_without_store() {
853        let fs = RagFs::new(PathBuf::from("/tmp/test"));
854        let status = fs.get_index_status();
855
856        let json: serde_json::Value = serde_json::from_slice(&status).expect("Valid JSON");
857
858        assert_eq!(json["status"], "not_initialized");
859        assert_eq!(json["message"], "No store configured");
860    }
861
862    #[tokio::test]
863    async fn test_get_index_status_returns_valid_json() {
864        let fs = RagFs::new(PathBuf::from("/test/path"));
865        let status = fs.get_index_status();
866
867        // Should be valid UTF-8
868        let status_str = String::from_utf8(status).expect("Valid UTF-8");
869
870        // Should be parseable JSON
871        let _json: serde_json::Value = serde_json::from_str(&status_str).expect("Valid JSON");
872    }
873
874    // ========== execute_query() Tests ==========
875
876    #[tokio::test]
877    async fn test_execute_query_without_executor() {
878        let fs = RagFs::new(PathBuf::from("/tmp/test"));
879        let result = fs.execute_query("test query");
880
881        let json: serde_json::Value = serde_json::from_slice(&result).expect("Valid JSON");
882
883        assert_eq!(json["error"], "Query executor not configured");
884    }
885
886    #[tokio::test]
887    async fn test_execute_query_returns_valid_json() {
888        let fs = RagFs::new(PathBuf::from("/test/path"));
889        let result = fs.execute_query("any query");
890
891        // Should be valid UTF-8
892        let result_str = String::from_utf8(result).expect("Valid UTF-8");
893
894        // Should be parseable JSON
895        let _json: serde_json::Value = serde_json::from_str(&result_str).expect("Valid JSON");
896    }
897
898    #[tokio::test]
899    async fn test_jail_reindex_rejects_absolute_outside_source() {
900        let temp = tempfile::TempDir::new().unwrap();
901        let fs = RagFs::new(temp.path().to_path_buf());
902        let outside = tempfile::TempDir::new().unwrap();
903        let err = fs
904            .jail_reindex_path(&outside.path().join("secret.txt"))
905            .unwrap_err();
906        assert!(err.contains("escapes"), "{err}");
907    }
908
909    #[tokio::test]
910    async fn test_jail_reindex_allows_relative_inside_source() {
911        let temp = tempfile::TempDir::new().unwrap();
912        let fs = RagFs::new(temp.path().to_path_buf());
913        let resolved = fs.jail_reindex_path(Path::new("src/main.rs")).unwrap();
914        assert!(resolved.starts_with(temp.path().canonicalize().unwrap()));
915    }
916
917    // ========== Constants Tests ==========
918
919    #[test]
920    fn test_ttl_is_reasonable() {
921        assert_eq!(TTL, Duration::from_secs(1));
922    }
923
924    #[test]
925    fn test_block_size_is_standard() {
926        assert_eq!(BLOCK_SIZE, 512);
927    }
928
929    // ========== make_attr() Tests ==========
930
931    #[tokio::test]
932    async fn test_make_attr_directory() {
933        let fs = RagFs::new(PathBuf::from("/tmp/test"));
934        let attr = fs.make_attr(100, fuser::FileType::Directory, 0);
935
936        assert_eq!(attr.ino, 100);
937        assert_eq!(attr.size, 0);
938        assert_eq!(attr.kind, fuser::FileType::Directory);
939        assert_eq!(attr.perm, 0o755);
940        assert_eq!(attr.nlink, 2);
941    }
942
943    #[tokio::test]
944    async fn test_make_attr_regular_file() {
945        let fs = RagFs::new(PathBuf::from("/tmp/test"));
946        let attr = fs.make_attr(200, fuser::FileType::RegularFile, 1024);
947
948        assert_eq!(attr.ino, 200);
949        assert_eq!(attr.size, 1024);
950        assert_eq!(attr.kind, fuser::FileType::RegularFile);
951        assert_eq!(attr.perm, 0o644);
952        assert_eq!(attr.nlink, 1);
953    }
954
955    #[tokio::test]
956    async fn test_make_attr_blocks_calculation() {
957        let fs = RagFs::new(PathBuf::from("/tmp/test"));
958
959        // Test exact block boundary
960        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 512);
961        assert_eq!(attr.blocks, 1);
962
963        // Test one byte over
964        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 513);
965        assert_eq!(attr.blocks, 2);
966
967        // Test empty file
968        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 0);
969        assert_eq!(attr.blocks, 0);
970    }
971
972    #[tokio::test]
973    async fn test_make_attr_has_current_uid_gid() {
974        let fs = RagFs::new(PathBuf::from("/tmp/test"));
975        let attr = fs.make_attr(1, fuser::FileType::RegularFile, 0);
976
977        // Should have current user's uid/gid
978        #[allow(unsafe_code)]
979        let expected_uid = unsafe { libc::getuid() };
980        #[allow(unsafe_code)]
981        let expected_gid = unsafe { libc::getgid() };
982
983        assert_eq!(attr.uid, expected_uid);
984        assert_eq!(attr.gid, expected_gid);
985    }
986}