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}