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