Skip to main content

synwire_core/vfs/
protocol.rs

1//! Virtual filesystem trait.
2
3use crate::BoxFuture;
4use crate::vfs::error::VfsError;
5use crate::vfs::grep_options::GrepOptions;
6use crate::vfs::types::{
7    CommunityEntry, CommunityMembersResult, CommunitySearchOptions, CommunitySearchResult,
8    CommunitySummaryResult, CpOptions, DiffOptions, DiffResult, DirEntry, DiskUsage, DuOptions,
9    EditResult, FileContent, FileInfo, FindEntry, FindOptions, GlobEntry, GrepMatch,
10    HeadTailOptions, HybridSearchOptions, HybridSearchResult, IndexHandle, IndexOptions,
11    IndexStatus, LsOptions, MkdirOptions, MountInfo, ReadRange, RmOptions, SemanticSearchOptions,
12    SemanticSearchResult, TransferResult, TreeEntry, TreeOptions, VfsCapabilities, WordCount,
13    WriteResult,
14};
15
16/// Virtual filesystem interface for agent data operations.
17///
18/// Provides a filesystem-like abstraction over heterogeneous data sources,
19/// allowing LLMs to interact with any data source using familiar operations
20/// (ls, read, write, cd, cp, mv, etc.).
21///
22/// Operations mirror Linux coreutils with their most useful arguments
23/// abstracted into option structs.  Providers declare which operations
24/// they support via [`VfsCapabilities`].  Unsupported operations return
25/// [`VfsError::Unsupported`] by default — override to opt in.
26///
27/// All implementations must be `Send + Sync` and return `BoxFuture` results.
28pub trait Vfs: Send + Sync {
29    // ── Navigation ───────────────────────────────────────────────────────
30
31    /// Return the current working directory.  `pwd`
32    fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>>;
33
34    /// Change the current working directory.  `cd <path>`
35    fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>>;
36
37    // ── Listing ──────────────────────────────────────────────────────────
38
39    /// List directory contents.  `ls [opts] <path>`
40    ///
41    /// Options: `-a` all, `-l` long, `-R` recursive, `-S`/`-t` sort, `-r` reverse.
42    fn ls(&self, path: &str, opts: LsOptions) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>>;
43
44    /// Recursive directory tree.  `tree [opts] <path>`
45    ///
46    /// Options: `-L` max depth, `-d` dirs only, `-a` all.
47    fn tree(&self, path: &str, opts: TreeOptions) -> BoxFuture<'_, Result<TreeEntry, VfsError>> {
48        let _ = (path, opts);
49        Box::pin(async { Err(VfsError::Unsupported("tree".into())) })
50    }
51
52    // ── Reading ──────────────────────────────────────────────────────────
53
54    /// Read entire file contents.  `cat <path>`
55    fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>>;
56
57    /// Read a sub-range of a file by line numbers or byte offsets.
58    ///
59    /// Line numbers are 1-indexed.  Byte offsets are 0-indexed.
60    /// If both line and byte ranges are specified, byte range takes precedence.
61    fn read_range(&self, path: &str, range: ReadRange) -> BoxFuture<'_, Result<String, VfsError>> {
62        let _ = (path, range);
63        Box::pin(async { Err(VfsError::Unsupported("read_range".into())) })
64    }
65
66    /// Read the first N lines or bytes.  `head [opts] <path>`
67    ///
68    /// Options: `-n` lines, `-c` bytes.  Default: 10 lines.
69    fn head(&self, path: &str, opts: HeadTailOptions) -> BoxFuture<'_, Result<String, VfsError>> {
70        let _ = (path, opts);
71        Box::pin(async { Err(VfsError::Unsupported("head".into())) })
72    }
73
74    /// Read the last N lines or bytes.  `tail [opts] <path>`
75    ///
76    /// Options: `-n` lines, `-c` bytes.  Default: 10 lines.
77    fn tail(&self, path: &str, opts: HeadTailOptions) -> BoxFuture<'_, Result<String, VfsError>> {
78        let _ = (path, opts);
79        Box::pin(async { Err(VfsError::Unsupported("tail".into())) })
80    }
81
82    /// File metadata.  `stat <path>`
83    fn stat(&self, path: &str) -> BoxFuture<'_, Result<FileInfo, VfsError>> {
84        let _ = path;
85        Box::pin(async { Err(VfsError::Unsupported("stat".into())) })
86    }
87
88    /// Line, word, and byte counts.  `wc <path>`
89    fn wc(&self, path: &str) -> BoxFuture<'_, Result<WordCount, VfsError>> {
90        let _ = path;
91        Box::pin(async { Err(VfsError::Unsupported("wc".into())) })
92    }
93
94    /// Disk usage.  `du [opts] <path>`
95    ///
96    /// Options: `-s` summary, `-d` max depth.
97    fn du(&self, path: &str, opts: DuOptions) -> BoxFuture<'_, Result<DiskUsage, VfsError>> {
98        let _ = (path, opts);
99        Box::pin(async { Err(VfsError::Unsupported("du".into())) })
100    }
101
102    // ── Writing ──────────────────────────────────────────────────────────
103
104    /// Write bytes to a file (creates or overwrites).  `>`
105    fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>>;
106
107    /// Append bytes to a file (creates if absent).  `>>`
108    fn append(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
109        let _ = (path, content);
110        Box::pin(async { Err(VfsError::Unsupported("append".into())) })
111    }
112
113    /// Create a directory.  `mkdir [opts] <path>`
114    ///
115    /// Options: `-p` create parents, `-m` mode.
116    fn mkdir(&self, path: &str, opts: MkdirOptions) -> BoxFuture<'_, Result<(), VfsError>> {
117        let _ = (path, opts);
118        Box::pin(async { Err(VfsError::Unsupported("mkdir".into())) })
119    }
120
121    /// Create an empty file or update its timestamp.  `touch <path>`
122    fn touch(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
123        let _ = path;
124        Box::pin(async { Err(VfsError::Unsupported("touch".into())) })
125    }
126
127    // ── Editing ──────────────────────────────────────────────────────────
128
129    /// Edit a file by replacing `old` with `new` (first occurrence).  `sed`-like.
130    fn edit(&self, path: &str, old: &str, new: &str)
131    -> BoxFuture<'_, Result<EditResult, VfsError>>;
132
133    /// Compare two files.  `diff [opts] <a> <b>`
134    ///
135    /// Options: `-U` context lines.
136    fn diff(
137        &self,
138        a: &str,
139        b: &str,
140        opts: DiffOptions,
141    ) -> BoxFuture<'_, Result<DiffResult, VfsError>> {
142        let _ = (a, b, opts);
143        Box::pin(async { Err(VfsError::Unsupported("diff".into())) })
144    }
145
146    // ── File management ──────────────────────────────────────────────────
147
148    /// Remove a file or directory.  `rm [opts] <path>`
149    ///
150    /// Options: `-r` recursive, `-f` force.
151    fn rm(&self, path: &str, opts: RmOptions) -> BoxFuture<'_, Result<(), VfsError>>;
152
153    /// Copy `from` to `to`.  `cp [opts] <from> <to>`
154    ///
155    /// Options: `-r` recursive, `-n` no-overwrite.
156    fn cp(
157        &self,
158        from: &str,
159        to: &str,
160        opts: CpOptions,
161    ) -> BoxFuture<'_, Result<TransferResult, VfsError>>;
162
163    /// Move / rename `from` to `to`.  `mv <from> <to>`
164    fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>;
165
166    /// Create a link.  `ln [-s] <target> <link>`
167    fn ln(&self, target: &str, link: &str, symbolic: bool) -> BoxFuture<'_, Result<(), VfsError>> {
168        let _ = (target, link, symbolic);
169        Box::pin(async { Err(VfsError::Unsupported("ln".into())) })
170    }
171
172    /// Change file permissions.  `chmod <mode> <path>`
173    fn chmod(&self, path: &str, mode: u32) -> BoxFuture<'_, Result<(), VfsError>> {
174        let _ = (path, mode);
175        Box::pin(async { Err(VfsError::Unsupported("chmod".into())) })
176    }
177
178    // ── Search ───────────────────────────────────────────────────────────
179
180    /// Search file contents.  `grep [opts] <pattern>`
181    fn grep(
182        &self,
183        pattern: &str,
184        opts: GrepOptions,
185    ) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>>;
186
187    /// Glob for file paths matching a pattern.
188    fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>>;
189
190    /// Search for files by criteria.  `find [opts] <path>`
191    ///
192    /// Options: `-name`, `-type`, `-maxdepth`, `-size`, `-newer`.
193    fn find(
194        &self,
195        path: &str,
196        opts: FindOptions,
197    ) -> BoxFuture<'_, Result<Vec<FindEntry>, VfsError>> {
198        let _ = (path, opts);
199        Box::pin(async { Err(VfsError::Unsupported("find".into())) })
200    }
201
202    // ── Transfer ─────────────────────────────────────────────────────────
203
204    /// Upload a file from `from` (local path) to `to` (VFS path).
205    fn upload(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>;
206
207    /// Download a file from `from` (VFS path) to `to` (local path).
208    fn download(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>>;
209
210    // ── Watch ─────────────────────────────────────────────────────────
211
212    /// Begin watching a path for external modifications.
213    ///
214    /// Called automatically by VFS tools after every read.  Providers
215    /// that support watching record the file's current state (mtime,
216    /// content hash, etc.) so that [`check_stale`](Self::check_stale)
217    /// can detect external changes before an edit or write.
218    fn watch(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
219        let _ = path;
220        Box::pin(async { Ok(()) })
221    }
222
223    /// Check whether a previously-read file has been modified externally.
224    ///
225    /// Returns `Ok(())` if the file is unchanged since the last
226    /// [`watch`](Self::watch) call.  Returns
227    /// [`VfsError::StaleRead`] if the file was modified.  Returns
228    /// `Ok(())` for paths that were never watched (the tool layer
229    /// enforces the "must read before edit" rule separately).
230    fn check_stale(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
231        let _ = path;
232        Box::pin(async { Ok(()) })
233    }
234
235    // ── Semantic index / search ─────────────────────────────────────────
236
237    /// Start indexing a directory for semantic search.
238    ///
239    /// Returns immediately with an [`IndexHandle`] — indexing runs in the
240    /// background.  Poll with [`index_status`](Self::index_status) or
241    /// subscribe to [`IndexEvent`](crate::vfs::types::IndexEvent)s.
242    ///
243    /// Denies indexing `/` (root filesystem) with [`VfsError::IndexDenied`].
244    fn index(
245        &self,
246        path: &str,
247        opts: IndexOptions,
248    ) -> BoxFuture<'_, Result<IndexHandle, VfsError>> {
249        let _ = (path, opts);
250        Box::pin(async { Err(VfsError::Unsupported("index".into())) })
251    }
252
253    /// Check the status of an indexing operation.
254    fn index_status(&self, index_id: &str) -> BoxFuture<'_, Result<IndexStatus, VfsError>> {
255        let _ = index_id;
256        Box::pin(async { Err(VfsError::Unsupported("index_status".into())) })
257    }
258
259    /// Semantic search across indexed content.
260    ///
261    /// Returns ranked results with file paths, line ranges, content, and
262    /// similarity scores.  Returns [`VfsError::IndexNotReady`] if the
263    /// index is still building.
264    fn semantic_search(
265        &self,
266        query: &str,
267        opts: SemanticSearchOptions,
268    ) -> BoxFuture<'_, Result<Vec<SemanticSearchResult>, VfsError>> {
269        let _ = (query, opts);
270        Box::pin(async { Err(VfsError::Unsupported("semantic_search".into())) })
271    }
272
273    /// Hybrid BM25 + vector search across indexed content.
274    ///
275    /// Combines keyword recall (BM25) with semantic similarity (vector) for
276    /// higher-quality results than either signal alone.  The `alpha` parameter
277    /// in [`HybridSearchOptions`] controls the blend.
278    ///
279    /// Returns [`VfsError::Unsupported`] by default.  Providers that back the
280    /// `synwire-index` crate with the `hybrid-search` feature should override
281    /// this method.
282    fn hybrid_search(
283        &self,
284        query: &str,
285        opts: HybridSearchOptions,
286    ) -> BoxFuture<'_, Result<Vec<HybridSearchResult>, VfsError>> {
287        let _ = (query, opts);
288        Box::pin(async { Err(VfsError::Unsupported("hybrid_search".into())) })
289    }
290
291    // ── Code navigation ──────────────────────────────────────────────────
292
293    /// Return only the function and method signatures of a source file,
294    /// stripping all body content.
295    ///
296    /// Each signature line is prefixed with its 1-indexed line number so that
297    /// the LLM can navigate to the relevant section with `read_range`.
298    /// This dramatically reduces token usage compared with reading an entire
299    /// file when only the API surface is needed.
300    ///
301    /// The default implementation returns the full file content unchanged,
302    /// which is always safe but provides no token savings.  Override this
303    /// method in providers that have access to a tree-sitter grammar for the
304    /// file's language.
305    ///
306    /// Returns [`VfsError::Unsupported`] for binary files.
307    fn skeleton<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<String, VfsError>> {
308        // Default: read the file and return its full text content.
309        // Providers with tree-sitter support should override this.
310        Box::pin(async move {
311            let content = self.read(path).await?;
312            String::from_utf8(content.content)
313                .map_err(|_| VfsError::Unsupported("skeleton: binary file".into()))
314        })
315    }
316
317    // ── Community detection ───────────────────────────────────────────────────
318
319    /// List all detected communities with their member counts.
320    ///
321    /// Returns [`VfsError::Unsupported`] by default.  Override in providers
322    /// backed by a community-detection pipeline.
323    fn list_communities(&self) -> BoxFuture<'_, Result<Vec<CommunityEntry>, VfsError>> {
324        Box::pin(async { Err(VfsError::Unsupported("list_communities".into())) })
325    }
326
327    /// List the symbol members of a specific community.
328    ///
329    /// Returns [`VfsError::Unsupported`] by default.
330    fn community_members(
331        &self,
332        community_id: u64,
333    ) -> BoxFuture<'_, Result<CommunityMembersResult, VfsError>> {
334        let _ = community_id;
335        Box::pin(async { Err(VfsError::Unsupported("community_members".into())) })
336    }
337
338    /// Search for communities whose member names match `query`.
339    ///
340    /// Performs a simple substring / keyword search against member names.
341    /// Returns [`VfsError::Unsupported`] by default.
342    fn community_search(
343        &self,
344        query: &str,
345        opts: CommunitySearchOptions,
346    ) -> BoxFuture<'_, Result<Vec<CommunitySearchResult>, VfsError>> {
347        let _ = (query, opts);
348        Box::pin(async { Err(VfsError::Unsupported("community_search".into())) })
349    }
350
351    /// Get (or generate) a natural-language summary for a community.
352    ///
353    /// Returns [`VfsError::Unsupported`] by default.  Providers with an
354    /// embedded [`SamplingProvider`](crate::agents::sampling::SamplingProvider)
355    /// should override this to return an LLM-generated summary.
356    fn community_summary(
357        &self,
358        community_id: u64,
359    ) -> BoxFuture<'_, Result<CommunitySummaryResult, VfsError>> {
360        let _ = community_id;
361        Box::pin(async { Err(VfsError::Unsupported("community_summary".into())) })
362    }
363
364    // ── Capabilities & identity ────────────────────────────────────────
365
366    /// Return the capabilities supported by this provider.
367    fn capabilities(&self) -> VfsCapabilities;
368
369    /// Human-readable provider type name (e.g. `"LocalProvider"`, `"MemoryProvider"`).
370    ///
371    /// Used by the `mount` tool to inform the LLM what kind of data
372    /// source it is interacting with.
373    fn provider_name(&self) -> &'static str {
374        "UnknownProvider"
375    }
376
377    /// Return mount information for this provider.
378    ///
379    /// Simple providers return a single entry at `/`.
380    /// `CompositeProvider` returns one entry per mount.
381    fn mount_info(&self) -> Vec<MountInfo> {
382        let caps = self.capabilities();
383        let cap_names: Vec<String> = capability_names(caps);
384        vec![MountInfo {
385            prefix: "/".to_string(),
386            provider: self.provider_name().to_string(),
387            capabilities: cap_names,
388        }]
389    }
390}
391
392/// Convert capability bitflags to a list of human-readable names.
393pub fn capability_names(caps: VfsCapabilities) -> Vec<String> {
394    let mut names = Vec::new();
395    let flags = [
396        (VfsCapabilities::PWD, "fs.pwd"),
397        (VfsCapabilities::CD, "fs.cd"),
398        (VfsCapabilities::LS, "fs.ls"),
399        (VfsCapabilities::TREE, "fs.tree"),
400        (VfsCapabilities::READ, "fs.read"),
401        (VfsCapabilities::HEAD, "fs.head"),
402        (VfsCapabilities::TAIL, "fs.tail"),
403        (VfsCapabilities::STAT, "fs.stat"),
404        (VfsCapabilities::WC, "fs.wc"),
405        (VfsCapabilities::DU, "fs.du"),
406        (VfsCapabilities::WRITE, "fs.write"),
407        (VfsCapabilities::APPEND, "fs.append"),
408        (VfsCapabilities::MKDIR, "fs.mkdir"),
409        (VfsCapabilities::TOUCH, "fs.touch"),
410        (VfsCapabilities::EDIT, "fs.edit"),
411        (VfsCapabilities::DIFF, "fs.diff"),
412        (VfsCapabilities::RM, "fs.rm"),
413        (VfsCapabilities::CP, "fs.cp"),
414        (VfsCapabilities::MV, "fs.mv"),
415        (VfsCapabilities::LN, "fs.ln"),
416        (VfsCapabilities::CHMOD, "fs.chmod"),
417        (VfsCapabilities::GREP, "fs.grep"),
418        (VfsCapabilities::GLOB, "fs.glob"),
419        (VfsCapabilities::FIND, "fs.find"),
420        (VfsCapabilities::UPLOAD, "fs.upload"),
421        (VfsCapabilities::DOWNLOAD, "fs.download"),
422        (VfsCapabilities::EXEC, "fs.exec"),
423        (VfsCapabilities::WATCH, "fs.watch"),
424        (VfsCapabilities::INDEX, "index.build"),
425        (VfsCapabilities::SEMANTIC_SEARCH, "code.search_semantic"),
426        (VfsCapabilities::SKELETON, "fs.skeleton"),
427        (VfsCapabilities::HYBRID_SEARCH, "code.search_hybrid"),
428        (VfsCapabilities::LIST_COMMUNITIES, "code.list_communities"),
429        (VfsCapabilities::COMMUNITY_MEMBERS, "code.community_members"),
430        (
431            VfsCapabilities::COMMUNITY_SEARCH,
432            "code.search_by_community",
433        ),
434        (VfsCapabilities::COMMUNITY_SUMMARY, "code.community_summary"),
435    ];
436    for (flag, name) in flags {
437        if caps.contains(flag) {
438            names.push((*name).to_string());
439        }
440    }
441    names
442}