1use 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
41pub struct RagFs {
43 source: PathBuf,
45 inodes: Arc<RwLock<InodeTable>>,
47 store: Option<Arc<dyn VectorStore>>,
49 query_executor: Option<Arc<QueryExecutor>>,
51 runtime: Handle,
53 content_cache: Arc<RwLock<HashMap<u64, Vec<u8>>>>,
55 reindex_sender: Option<mpsc::Sender<PathBuf>>,
57 ops_manager: Arc<OpsManager>,
59 safety_manager: Arc<SafetyManager>,
61 semantic_manager: Arc<SemanticManager>,
63}
64
65impl RagFs {
66 #[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 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 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 #[must_use]
166 pub fn source(&self) -> &PathBuf {
167 &self.source
168 }
169
170 fn jail_reindex_path(&self, path: &Path) -> Result<PathBuf, String> {
172 ragfs_core::resolve_under_root(&self.source, path)
173 }
174
175 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 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 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 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 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 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 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
678fn 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 #[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 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 let result = truncate("Line1\n\nLine2\r\n\r\nLine3", 100);
753 assert_eq!(result, "Line1 Line2 Line3");
754 }
755
756 #[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 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 #[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 let config_str = String::from_utf8(config).expect("Valid UTF-8");
818
819 let json: serde_json::Value = serde_json::from_str(&config_str).expect("Valid JSON");
821
822 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 #[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 let status_str = String::from_utf8(status).expect("Valid UTF-8");
869
870 let _json: serde_json::Value = serde_json::from_str(&status_str).expect("Valid JSON");
872 }
873
874 #[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 let result_str = String::from_utf8(result).expect("Valid UTF-8");
893
894 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 #[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 #[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 let attr = fs.make_attr(1, fuser::FileType::RegularFile, 512);
961 assert_eq!(attr.blocks, 1);
962
963 let attr = fs.make_attr(1, fuser::FileType::RegularFile, 513);
965 assert_eq!(attr.blocks, 2);
966
967 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 #[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}