Skip to main content

kaish_kernel/backend/
local.rs

1//! LocalBackend implementation wrapping VfsRouter.
2//!
3//! This is the default backend for standalone kaish operation.
4//! It delegates file operations to VfsRouter and tool dispatch to ToolRegistry.
5
6use async_trait::async_trait;
7use std::path::Path;
8use std::sync::Arc;
9
10use super::{
11    BackendError, BackendResult, ConflictError, KernelBackend, PatchOp, ReadRange,
12    ToolInfo, ToolResult, WriteMode,
13};
14use crate::tools::{ToolArgs, ToolCtx, ToolRegistry};
15use crate::vfs::{DirEntry, Filesystem, MountInfo, VfsRouter};
16
17/// Local backend implementation using VfsRouter and ToolRegistry.
18///
19/// This is the default backend for standalone kaish operation. It:
20/// - Delegates file operations to `VfsRouter` (handles mount points)
21/// - Delegates tool dispatch to `ToolRegistry` (builtins, MCP, user tools)
22pub struct LocalBackend {
23    /// Virtual filesystem router with mount points.
24    vfs: Arc<VfsRouter>,
25    /// Tool registry for external tool dispatch.
26    tools: Option<Arc<ToolRegistry>>,
27}
28
29impl LocalBackend {
30    /// Create a new LocalBackend with the given VFS.
31    pub fn new(vfs: Arc<VfsRouter>) -> Self {
32        Self { vfs, tools: None }
33    }
34
35    /// Create a LocalBackend with both VFS and tool registry.
36    pub fn with_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
37        Self {
38            vfs,
39            tools: Some(tools),
40        }
41    }
42
43    /// Get the underlying VfsRouter.
44    pub fn vfs(&self) -> &Arc<VfsRouter> {
45        &self.vfs
46    }
47
48    /// Get the underlying ToolRegistry (if set).
49    pub fn tools(&self) -> Option<&Arc<ToolRegistry>> {
50        self.tools.as_ref()
51    }
52
53    /// Apply a single patch operation to file content.
54    ///
55    /// This is public for use by VirtualOverlayBackend.
56    pub fn apply_patch_op(content: &mut String, op: &PatchOp) -> BackendResult<()> {
57        match op {
58            PatchOp::Insert { offset, content: insert_content } => {
59                if *offset > content.len() {
60                    return Err(BackendError::InvalidOperation(format!(
61                        "insert offset {} exceeds content length {}",
62                        offset,
63                        content.len()
64                    )));
65                }
66                content.insert_str(*offset, insert_content);
67            }
68
69            PatchOp::Delete { offset, len, expected } => {
70                let end = offset.saturating_add(*len);
71                if end > content.len() {
72                    return Err(BackendError::InvalidOperation(format!(
73                        "delete range {}..{} exceeds content length {}",
74                        offset, end, content.len()
75                    )));
76                }
77                // CAS check
78                if let Some(expected_content) = expected {
79                    let actual = &content[*offset..end];
80                    if actual != expected_content {
81                        return Err(BackendError::Conflict(ConflictError {
82                            location: format!("offset {}", offset),
83                            expected: expected_content.clone(),
84                            actual: actual.to_string(),
85                        }));
86                    }
87                }
88                content.drain(*offset..end);
89            }
90
91            PatchOp::Replace {
92                offset,
93                len,
94                content: replace_content,
95                expected,
96            } => {
97                let end = offset.saturating_add(*len);
98                if end > content.len() {
99                    return Err(BackendError::InvalidOperation(format!(
100                        "replace range {}..{} exceeds content length {}",
101                        offset, end, content.len()
102                    )));
103                }
104                // CAS check
105                if let Some(expected_content) = expected {
106                    let actual = &content[*offset..end];
107                    if actual != expected_content {
108                        return Err(BackendError::Conflict(ConflictError {
109                            location: format!("offset {}", offset),
110                            expected: expected_content.clone(),
111                            actual: actual.to_string(),
112                        }));
113                    }
114                }
115                content.replace_range(*offset..end, replace_content);
116            }
117
118            PatchOp::InsertLine { line, content: insert_content } => {
119                let lines: Vec<&str> = content.lines().collect();
120                let line_idx = line.saturating_sub(1); // Convert to 0-indexed
121                if line_idx > lines.len() {
122                    return Err(BackendError::InvalidOperation(format!(
123                        "line {} exceeds line count {}",
124                        line,
125                        lines.len()
126                    )));
127                }
128                let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
129                new_lines.insert(line_idx, insert_content.clone());
130                *content = new_lines.join("\n");
131                // Preserve trailing newline if original had one
132                if !content.is_empty() && !content.ends_with('\n') {
133                    content.push('\n');
134                }
135            }
136
137            PatchOp::DeleteLine { line, expected } => {
138                let lines: Vec<&str> = content.lines().collect();
139                let line_idx = line.saturating_sub(1); // Convert to 0-indexed
140                if line_idx >= lines.len() {
141                    return Err(BackendError::InvalidOperation(format!(
142                        "line {} exceeds line count {}",
143                        line,
144                        lines.len()
145                    )));
146                }
147                // CAS check
148                if let Some(expected_content) = expected {
149                    let actual = lines[line_idx];
150                    if actual != expected_content {
151                        return Err(BackendError::Conflict(ConflictError {
152                            location: format!("line {}", line),
153                            expected: expected_content.clone(),
154                            actual: actual.to_string(),
155                        }));
156                    }
157                }
158                let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
159                new_lines.remove(line_idx);
160                *content = new_lines.join("\n");
161                if !content.is_empty() && !content.ends_with('\n') {
162                    content.push('\n');
163                }
164            }
165
166            PatchOp::ReplaceLine {
167                line,
168                content: replace_content,
169                expected,
170            } => {
171                let lines: Vec<&str> = content.lines().collect();
172                let line_idx = line.saturating_sub(1); // Convert to 0-indexed
173                if line_idx >= lines.len() {
174                    return Err(BackendError::InvalidOperation(format!(
175                        "line {} exceeds line count {}",
176                        line,
177                        lines.len()
178                    )));
179                }
180                // CAS check
181                if let Some(expected_content) = expected {
182                    let actual = lines[line_idx];
183                    if actual != expected_content {
184                        return Err(BackendError::Conflict(ConflictError {
185                            location: format!("line {}", line),
186                            expected: expected_content.clone(),
187                            actual: actual.to_string(),
188                        }));
189                    }
190                }
191                let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
192                new_lines[line_idx] = replace_content.clone();
193                *content = new_lines.join("\n");
194                if !content.is_empty() && !content.ends_with('\n') {
195                    content.push('\n');
196                }
197            }
198
199            PatchOp::Append { content: append_content } => {
200                content.push_str(append_content);
201            }
202        }
203        Ok(())
204    }
205
206}
207
208#[async_trait]
209impl KernelBackend for LocalBackend {
210    // ═══════════════════════════════════════════════════════════════════════════
211    // File Operations
212    // ═══════════════════════════════════════════════════════════════════════════
213
214    async fn read(&self, path: &Path, range: Option<ReadRange>) -> BackendResult<Vec<u8>> {
215        // Range handling lives in the VFS layer (`Filesystem::read_range`) so
216        // range-aware backends like DevFs's /dev/zero see the byte count.
217        Ok(self.vfs.read_range(path, range).await?)
218    }
219
220    async fn write(&self, path: &Path, content: &[u8], mode: WriteMode) -> BackendResult<()> {
221        match mode {
222            WriteMode::CreateNew => {
223                // Check if file exists
224                if self.vfs.exists(path).await {
225                    return Err(BackendError::AlreadyExists(path.display().to_string()));
226                }
227                self.vfs.write(path, content).await?;
228            }
229            WriteMode::Overwrite | WriteMode::Truncate => {
230                self.vfs.write(path, content).await?;
231            }
232            WriteMode::UpdateOnly => {
233                if !self.vfs.exists(path).await {
234                    return Err(BackendError::NotFound(path.display().to_string()));
235                }
236                self.vfs.write(path, content).await?;
237            }
238            // WriteMode is #[non_exhaustive] — treat unknown modes as Overwrite
239            _ => {
240                self.vfs.write(path, content).await?;
241            }
242        }
243        Ok(())
244    }
245
246    async fn append(&self, path: &Path, content: &[u8]) -> BackendResult<()> {
247        // Read existing content
248        let mut existing = match self.vfs.read(path).await {
249            Ok(data) => data,
250            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
251            Err(e) => return Err(e.into()),
252        };
253        existing.extend_from_slice(content);
254        self.vfs.write(path, &existing).await?;
255        Ok(())
256    }
257
258    async fn patch(&self, path: &Path, ops: &[PatchOp]) -> BackendResult<()> {
259        // Read existing content
260        let data = self.vfs.read(path).await?;
261        let mut content = String::from_utf8(data)
262            .map_err(|e| BackendError::InvalidOperation(format!("file is not valid UTF-8: {}", e)))?;
263
264        // Apply each patch operation
265        for op in ops {
266            Self::apply_patch_op(&mut content, op)?;
267        }
268
269        // Write back
270        self.vfs.write(path, content.as_bytes()).await?;
271        Ok(())
272    }
273
274    // ═══════════════════════════════════════════════════════════════════════════
275    // Directory Operations
276    // ═══════════════════════════════════════════════════════════════════════════
277
278    async fn list(&self, path: &Path) -> BackendResult<Vec<DirEntry>> {
279        Ok(self.vfs.list(path).await?)
280    }
281
282    async fn stat(&self, path: &Path) -> BackendResult<DirEntry> {
283        Ok(self.vfs.stat(path).await?)
284    }
285
286    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> BackendResult<()> {
287        self.vfs.set_mtime(path, mtime).await?;
288        Ok(())
289    }
290
291    async fn mkdir(&self, path: &Path) -> BackendResult<()> {
292        self.vfs.mkdir(path).await?;
293        Ok(())
294    }
295
296    async fn remove(&self, path: &Path, recursive: bool) -> BackendResult<()> {
297        if recursive {
298            // lstat, never stat: a symlink-to-dir must be unlinked, not
299            // descended into. lstat reports the link itself (is_dir() == false
300            // for a symlink), so we skip recursion and just unlink it below —
301            // never deleting the link target's contents.
302            if let Ok(entry) = self.vfs.lstat(path).await
303                && entry.is_dir()
304            {
305                // List and remove children
306                if let Ok(entries) = self.vfs.list(path).await {
307                    for entry in entries {
308                        let child_path = path.join(&entry.name);
309                        // Recursive call using Box::pin to handle async recursion
310                        Box::pin(self.remove(&child_path, true)).await?;
311                    }
312                }
313            }
314        }
315        self.vfs.remove(path).await?;
316        Ok(())
317    }
318
319    async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()> {
320        self.vfs.rename(from, to).await?;
321        Ok(())
322    }
323
324    async fn exists(&self, path: &Path) -> bool {
325        self.vfs.exists(path).await
326    }
327
328    // ═══════════════════════════════════════════════════════════════════════════
329    // Symlink Operations
330    // ═══════════════════════════════════════════════════════════════════════════
331
332    async fn lstat(&self, path: &Path) -> BackendResult<DirEntry> {
333        Ok(self.vfs.lstat(path).await?)
334    }
335
336    async fn read_link(&self, path: &Path) -> BackendResult<std::path::PathBuf> {
337        Ok(self.vfs.read_link(path).await?)
338    }
339
340    async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()> {
341        self.vfs.symlink(target, link).await?;
342        Ok(())
343    }
344
345    // ═══════════════════════════════════════════════════════════════════════════
346    // Tool Dispatch
347    // ═══════════════════════════════════════════════════════════════════════════
348
349    async fn call_tool(
350        &self,
351        name: &str,
352        args: ToolArgs,
353        ctx: &mut dyn ToolCtx,
354    ) -> BackendResult<ToolResult> {
355        let registry = self.tools.as_ref().ok_or_else(|| {
356            BackendError::ToolNotFound(format!("no tool registry configured for: {}", name))
357        })?;
358
359        let tool = registry.get(name).ok_or_else(|| {
360            BackendError::ToolNotFound(format!("{}: command not found", name))
361        })?;
362
363        // Execute the tool and convert ExecResult to ToolResult
364        let exec_result = tool.execute(args, ctx).await;
365        Ok(exec_result.into())
366    }
367
368    async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
369        match &self.tools {
370            Some(registry) => {
371                let schemas = registry.schemas();
372                Ok(schemas
373                    .into_iter()
374                    .map(|schema| ToolInfo {
375                        name: schema.name.clone(),
376                        description: schema.description.clone(),
377                        schema,
378                    })
379                    .collect())
380            }
381            None => Ok(Vec::new()),
382        }
383    }
384
385    async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
386        match &self.tools {
387            Some(registry) => match registry.get(name) {
388                Some(tool) => {
389                    let schema = tool.schema();
390                    Ok(Some(ToolInfo {
391                        name: schema.name.clone(),
392                        description: schema.description.clone(),
393                        schema,
394                    }))
395                }
396                None => Ok(None),
397            },
398            None => Ok(None),
399        }
400    }
401
402    // ═══════════════════════════════════════════════════════════════════════════
403    // Backend Information
404    // ═══════════════════════════════════════════════════════════════════════════
405
406    fn read_only(&self) -> bool {
407        self.vfs.read_only()
408    }
409
410    fn backend_type(&self) -> &str {
411        "local"
412    }
413
414    fn mounts(&self) -> Vec<MountInfo> {
415        self.vfs.list_mounts()
416    }
417
418    fn resolve_real_path(&self, path: &Path) -> Option<std::path::PathBuf> {
419        self.vfs.resolve_real_path(path)
420    }
421}
422
423impl std::fmt::Debug for LocalBackend {
424    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425        f.debug_struct("LocalBackend")
426            .field("vfs", &self.vfs)
427            .field("has_tools", &self.tools.is_some())
428            .finish()
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::vfs::MemoryFs;
436    use std::path::PathBuf;
437
438    async fn make_backend() -> LocalBackend {
439        let mut vfs = VfsRouter::new();
440        let mem = MemoryFs::new();
441        mem.write(Path::new("test.txt"), b"hello world")
442            .await
443            .unwrap();
444        mem.write(Path::new("lines.txt"), b"line1\nline2\nline3\n")
445            .await
446            .unwrap();
447        mem.mkdir(Path::new("dir")).await.unwrap();
448        mem.write(Path::new("dir/nested.txt"), b"nested content")
449            .await
450            .unwrap();
451        vfs.mount("/", mem);
452        LocalBackend::new(Arc::new(vfs))
453    }
454
455    #[tokio::test]
456    async fn test_read_full() {
457        let backend = make_backend().await;
458        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
459        assert_eq!(content, b"hello world");
460    }
461
462    #[tokio::test]
463    async fn test_read_with_byte_range() {
464        let backend = make_backend().await;
465        let range = ReadRange::bytes(0, 5);
466        let content = backend.read(Path::new("/test.txt"), Some(range)).await.unwrap();
467        assert_eq!(content, b"hello");
468    }
469
470    #[tokio::test]
471    async fn test_read_with_line_range() {
472        let backend = make_backend().await;
473        let range = ReadRange::lines(2, 3);
474        let content = backend.read(Path::new("/lines.txt"), Some(range)).await.unwrap();
475        assert_eq!(std::str::from_utf8(&content).unwrap(), "line2\nline3");
476    }
477
478    #[tokio::test]
479    async fn test_write_overwrite() {
480        let backend = make_backend().await;
481        backend
482            .write(Path::new("/test.txt"), b"new content", WriteMode::Overwrite)
483            .await
484            .unwrap();
485        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
486        assert_eq!(content, b"new content");
487    }
488
489    #[tokio::test]
490    async fn test_write_create_new() {
491        let backend = make_backend().await;
492        backend
493            .write(Path::new("/new.txt"), b"created", WriteMode::CreateNew)
494            .await
495            .unwrap();
496        let content = backend.read(Path::new("/new.txt"), None).await.unwrap();
497        assert_eq!(content, b"created");
498    }
499
500    #[tokio::test]
501    async fn test_write_create_new_fails_if_exists() {
502        let backend = make_backend().await;
503        let result = backend
504            .write(Path::new("/test.txt"), b"fail", WriteMode::CreateNew)
505            .await;
506        assert!(matches!(result, Err(BackendError::AlreadyExists(_))));
507    }
508
509    #[tokio::test]
510    async fn test_write_update_only() {
511        let backend = make_backend().await;
512        backend
513            .write(Path::new("/test.txt"), b"updated", WriteMode::UpdateOnly)
514            .await
515            .unwrap();
516        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
517        assert_eq!(content, b"updated");
518    }
519
520    #[tokio::test]
521    async fn test_write_update_only_fails_if_not_exists() {
522        let backend = make_backend().await;
523        let result = backend
524            .write(Path::new("/nonexistent.txt"), b"fail", WriteMode::UpdateOnly)
525            .await;
526        assert!(matches!(result, Err(BackendError::NotFound(_))));
527    }
528
529    #[tokio::test]
530    async fn test_append() {
531        let backend = make_backend().await;
532        backend.append(Path::new("/test.txt"), b" appended").await.unwrap();
533        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
534        assert_eq!(content, b"hello world appended");
535    }
536
537    #[tokio::test]
538    async fn test_patch_insert() {
539        let backend = make_backend().await;
540        let ops = vec![PatchOp::Insert {
541            offset: 5,
542            content: " there".to_string(),
543        }];
544        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
545        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
546        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello there world");
547    }
548
549    #[tokio::test]
550    async fn test_patch_delete() {
551        let backend = make_backend().await;
552        let ops = vec![PatchOp::Delete {
553            offset: 5,
554            len: 6,
555            expected: None,
556        }];
557        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
558        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
559        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello");
560    }
561
562    #[tokio::test]
563    async fn test_patch_delete_with_cas() {
564        let backend = make_backend().await;
565        let ops = vec![PatchOp::Delete {
566            offset: 0,
567            len: 5,
568            expected: Some("hello".to_string()),
569        }];
570        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
571        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
572        assert_eq!(std::str::from_utf8(&content).unwrap(), " world");
573    }
574
575    #[tokio::test]
576    async fn test_patch_delete_cas_conflict() {
577        let backend = make_backend().await;
578        let ops = vec![PatchOp::Delete {
579            offset: 0,
580            len: 5,
581            expected: Some("wrong".to_string()),
582        }];
583        let result = backend.patch(Path::new("/test.txt"), &ops).await;
584        assert!(matches!(result, Err(BackendError::Conflict(_))));
585    }
586
587    #[tokio::test]
588    async fn test_patch_replace() {
589        let backend = make_backend().await;
590        let ops = vec![PatchOp::Replace {
591            offset: 0,
592            len: 5,
593            content: "hi".to_string(),
594            expected: None,
595        }];
596        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
597        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
598        assert_eq!(std::str::from_utf8(&content).unwrap(), "hi world");
599    }
600
601    #[tokio::test]
602    async fn test_patch_replace_line() {
603        let backend = make_backend().await;
604        let ops = vec![PatchOp::ReplaceLine {
605            line: 2,
606            content: "replaced".to_string(),
607            expected: None,
608        }];
609        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
610        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
611        let text = std::str::from_utf8(&content).unwrap();
612        assert!(text.contains("line1"));
613        assert!(text.contains("replaced"));
614        assert!(text.contains("line3"));
615        assert!(!text.contains("line2"));
616    }
617
618    #[tokio::test]
619    async fn test_patch_delete_line() {
620        let backend = make_backend().await;
621        let ops = vec![PatchOp::DeleteLine {
622            line: 2,
623            expected: None,
624        }];
625        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
626        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
627        let text = std::str::from_utf8(&content).unwrap();
628        assert!(text.contains("line1"));
629        assert!(!text.contains("line2"));
630        assert!(text.contains("line3"));
631    }
632
633    #[tokio::test]
634    async fn test_patch_insert_line() {
635        let backend = make_backend().await;
636        let ops = vec![PatchOp::InsertLine {
637            line: 2,
638            content: "inserted".to_string(),
639        }];
640        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
641        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
642        let text = std::str::from_utf8(&content).unwrap();
643        let lines: Vec<&str> = text.lines().collect();
644        assert_eq!(lines[0], "line1");
645        assert_eq!(lines[1], "inserted");
646        assert_eq!(lines[2], "line2");
647    }
648
649    #[tokio::test]
650    async fn test_patch_append() {
651        let backend = make_backend().await;
652        let ops = vec![PatchOp::Append {
653            content: "!".to_string(),
654        }];
655        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
656        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
657        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello world!");
658    }
659
660    #[tokio::test]
661    async fn test_list() {
662        let backend = make_backend().await;
663        let entries = backend.list(Path::new("/")).await.unwrap();
664        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
665        assert!(names.contains(&"test.txt"));
666        assert!(names.contains(&"lines.txt"));
667        assert!(names.contains(&"dir"));
668    }
669
670    #[tokio::test]
671    async fn test_stat() {
672        let backend = make_backend().await;
673        let info = backend.stat(Path::new("/test.txt")).await.unwrap();
674        assert!(info.is_file());
675        assert_eq!(info.size, 11); // "hello world".len()
676
677        let info = backend.stat(Path::new("/dir")).await.unwrap();
678        assert!(info.is_dir());
679    }
680
681    #[tokio::test]
682    async fn test_mkdir() {
683        let backend = make_backend().await;
684        backend.mkdir(Path::new("/newdir")).await.unwrap();
685        assert!(backend.exists(Path::new("/newdir")).await);
686        let info = backend.stat(Path::new("/newdir")).await.unwrap();
687        assert!(info.is_dir());
688    }
689
690    #[tokio::test]
691    async fn test_remove() {
692        let backend = make_backend().await;
693        assert!(backend.exists(Path::new("/test.txt")).await);
694        backend.remove(Path::new("/test.txt"), false).await.unwrap();
695        assert!(!backend.exists(Path::new("/test.txt")).await);
696    }
697
698    #[tokio::test]
699    async fn test_remove_recursive() {
700        let backend = make_backend().await;
701        assert!(backend.exists(Path::new("/dir/nested.txt")).await);
702        backend.remove(Path::new("/dir"), true).await.unwrap();
703        assert!(!backend.exists(Path::new("/dir")).await);
704        assert!(!backend.exists(Path::new("/dir/nested.txt")).await);
705    }
706
707    #[tokio::test]
708    async fn test_exists() {
709        let backend = make_backend().await;
710        assert!(backend.exists(Path::new("/test.txt")).await);
711        assert!(!backend.exists(Path::new("/nonexistent.txt")).await);
712    }
713
714    #[tokio::test]
715    async fn test_backend_info() {
716        let backend = make_backend().await;
717        assert_eq!(backend.backend_type(), "local");
718        assert!(!backend.read_only());
719        let mounts = backend.mounts();
720        assert!(!mounts.is_empty());
721    }
722
723    #[tokio::test]
724    async fn test_list_includes_symlinks() {
725        use crate::vfs::Filesystem;
726
727        let mut vfs = VfsRouter::new();
728        let mem = MemoryFs::new();
729        mem.write(Path::new("target.txt"), b"content").await.unwrap();
730        mem.symlink(Path::new("target.txt"), Path::new("link.txt")).await.unwrap();
731        vfs.mount("/", mem);
732        let backend = LocalBackend::new(Arc::new(vfs));
733
734        let entries = backend.list(Path::new("/")).await.unwrap();
735
736        let link_entry = entries.iter().find(|e| e.name == "link.txt").unwrap();
737        assert!(link_entry.is_symlink(), "link.txt should be a symlink");
738        assert_eq!(link_entry.symlink_target, Some(PathBuf::from("target.txt")));
739    }
740}