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            // For recursive removal, we need to check if it's a directory
299            // and remove contents first
300            if let Ok(entry) = self.vfs.stat(path).await
301                && entry.is_dir()
302            {
303                // List and remove children
304                if let Ok(entries) = self.vfs.list(path).await {
305                    for entry in entries {
306                        let child_path = path.join(&entry.name);
307                        // Recursive call using Box::pin to handle async recursion
308                        Box::pin(self.remove(&child_path, true)).await?;
309                    }
310                }
311            }
312        }
313        self.vfs.remove(path).await?;
314        Ok(())
315    }
316
317    async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()> {
318        self.vfs.rename(from, to).await?;
319        Ok(())
320    }
321
322    async fn exists(&self, path: &Path) -> bool {
323        self.vfs.exists(path).await
324    }
325
326    // ═══════════════════════════════════════════════════════════════════════════
327    // Symlink Operations
328    // ═══════════════════════════════════════════════════════════════════════════
329
330    async fn lstat(&self, path: &Path) -> BackendResult<DirEntry> {
331        Ok(self.vfs.lstat(path).await?)
332    }
333
334    async fn read_link(&self, path: &Path) -> BackendResult<std::path::PathBuf> {
335        Ok(self.vfs.read_link(path).await?)
336    }
337
338    async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()> {
339        self.vfs.symlink(target, link).await?;
340        Ok(())
341    }
342
343    // ═══════════════════════════════════════════════════════════════════════════
344    // Tool Dispatch
345    // ═══════════════════════════════════════════════════════════════════════════
346
347    async fn call_tool(
348        &self,
349        name: &str,
350        args: ToolArgs,
351        ctx: &mut dyn ToolCtx,
352    ) -> BackendResult<ToolResult> {
353        let registry = self.tools.as_ref().ok_or_else(|| {
354            BackendError::ToolNotFound(format!("no tool registry configured for: {}", name))
355        })?;
356
357        let tool = registry.get(name).ok_or_else(|| {
358            BackendError::ToolNotFound(format!("{}: command not found", name))
359        })?;
360
361        // Execute the tool and convert ExecResult to ToolResult
362        let exec_result = tool.execute(args, ctx).await;
363        Ok(exec_result.into())
364    }
365
366    async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
367        match &self.tools {
368            Some(registry) => {
369                let schemas = registry.schemas();
370                Ok(schemas
371                    .into_iter()
372                    .map(|schema| ToolInfo {
373                        name: schema.name.clone(),
374                        description: schema.description.clone(),
375                        schema,
376                    })
377                    .collect())
378            }
379            None => Ok(Vec::new()),
380        }
381    }
382
383    async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
384        match &self.tools {
385            Some(registry) => match registry.get(name) {
386                Some(tool) => {
387                    let schema = tool.schema();
388                    Ok(Some(ToolInfo {
389                        name: schema.name.clone(),
390                        description: schema.description.clone(),
391                        schema,
392                    }))
393                }
394                None => Ok(None),
395            },
396            None => Ok(None),
397        }
398    }
399
400    // ═══════════════════════════════════════════════════════════════════════════
401    // Backend Information
402    // ═══════════════════════════════════════════════════════════════════════════
403
404    fn read_only(&self) -> bool {
405        self.vfs.read_only()
406    }
407
408    fn backend_type(&self) -> &str {
409        "local"
410    }
411
412    fn mounts(&self) -> Vec<MountInfo> {
413        self.vfs.list_mounts()
414    }
415
416    fn resolve_real_path(&self, path: &Path) -> Option<std::path::PathBuf> {
417        self.vfs.resolve_real_path(path)
418    }
419}
420
421impl std::fmt::Debug for LocalBackend {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        f.debug_struct("LocalBackend")
424            .field("vfs", &self.vfs)
425            .field("has_tools", &self.tools.is_some())
426            .finish()
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::vfs::MemoryFs;
434    use std::path::PathBuf;
435
436    async fn make_backend() -> LocalBackend {
437        let mut vfs = VfsRouter::new();
438        let mem = MemoryFs::new();
439        mem.write(Path::new("test.txt"), b"hello world")
440            .await
441            .unwrap();
442        mem.write(Path::new("lines.txt"), b"line1\nline2\nline3\n")
443            .await
444            .unwrap();
445        mem.mkdir(Path::new("dir")).await.unwrap();
446        mem.write(Path::new("dir/nested.txt"), b"nested content")
447            .await
448            .unwrap();
449        vfs.mount("/", mem);
450        LocalBackend::new(Arc::new(vfs))
451    }
452
453    #[tokio::test]
454    async fn test_read_full() {
455        let backend = make_backend().await;
456        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
457        assert_eq!(content, b"hello world");
458    }
459
460    #[tokio::test]
461    async fn test_read_with_byte_range() {
462        let backend = make_backend().await;
463        let range = ReadRange::bytes(0, 5);
464        let content = backend.read(Path::new("/test.txt"), Some(range)).await.unwrap();
465        assert_eq!(content, b"hello");
466    }
467
468    #[tokio::test]
469    async fn test_read_with_line_range() {
470        let backend = make_backend().await;
471        let range = ReadRange::lines(2, 3);
472        let content = backend.read(Path::new("/lines.txt"), Some(range)).await.unwrap();
473        assert_eq!(std::str::from_utf8(&content).unwrap(), "line2\nline3");
474    }
475
476    #[tokio::test]
477    async fn test_write_overwrite() {
478        let backend = make_backend().await;
479        backend
480            .write(Path::new("/test.txt"), b"new content", WriteMode::Overwrite)
481            .await
482            .unwrap();
483        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
484        assert_eq!(content, b"new content");
485    }
486
487    #[tokio::test]
488    async fn test_write_create_new() {
489        let backend = make_backend().await;
490        backend
491            .write(Path::new("/new.txt"), b"created", WriteMode::CreateNew)
492            .await
493            .unwrap();
494        let content = backend.read(Path::new("/new.txt"), None).await.unwrap();
495        assert_eq!(content, b"created");
496    }
497
498    #[tokio::test]
499    async fn test_write_create_new_fails_if_exists() {
500        let backend = make_backend().await;
501        let result = backend
502            .write(Path::new("/test.txt"), b"fail", WriteMode::CreateNew)
503            .await;
504        assert!(matches!(result, Err(BackendError::AlreadyExists(_))));
505    }
506
507    #[tokio::test]
508    async fn test_write_update_only() {
509        let backend = make_backend().await;
510        backend
511            .write(Path::new("/test.txt"), b"updated", WriteMode::UpdateOnly)
512            .await
513            .unwrap();
514        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
515        assert_eq!(content, b"updated");
516    }
517
518    #[tokio::test]
519    async fn test_write_update_only_fails_if_not_exists() {
520        let backend = make_backend().await;
521        let result = backend
522            .write(Path::new("/nonexistent.txt"), b"fail", WriteMode::UpdateOnly)
523            .await;
524        assert!(matches!(result, Err(BackendError::NotFound(_))));
525    }
526
527    #[tokio::test]
528    async fn test_append() {
529        let backend = make_backend().await;
530        backend.append(Path::new("/test.txt"), b" appended").await.unwrap();
531        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
532        assert_eq!(content, b"hello world appended");
533    }
534
535    #[tokio::test]
536    async fn test_patch_insert() {
537        let backend = make_backend().await;
538        let ops = vec![PatchOp::Insert {
539            offset: 5,
540            content: " there".to_string(),
541        }];
542        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
543        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
544        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello there world");
545    }
546
547    #[tokio::test]
548    async fn test_patch_delete() {
549        let backend = make_backend().await;
550        let ops = vec![PatchOp::Delete {
551            offset: 5,
552            len: 6,
553            expected: None,
554        }];
555        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
556        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
557        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello");
558    }
559
560    #[tokio::test]
561    async fn test_patch_delete_with_cas() {
562        let backend = make_backend().await;
563        let ops = vec![PatchOp::Delete {
564            offset: 0,
565            len: 5,
566            expected: Some("hello".to_string()),
567        }];
568        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
569        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
570        assert_eq!(std::str::from_utf8(&content).unwrap(), " world");
571    }
572
573    #[tokio::test]
574    async fn test_patch_delete_cas_conflict() {
575        let backend = make_backend().await;
576        let ops = vec![PatchOp::Delete {
577            offset: 0,
578            len: 5,
579            expected: Some("wrong".to_string()),
580        }];
581        let result = backend.patch(Path::new("/test.txt"), &ops).await;
582        assert!(matches!(result, Err(BackendError::Conflict(_))));
583    }
584
585    #[tokio::test]
586    async fn test_patch_replace() {
587        let backend = make_backend().await;
588        let ops = vec![PatchOp::Replace {
589            offset: 0,
590            len: 5,
591            content: "hi".to_string(),
592            expected: None,
593        }];
594        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
595        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
596        assert_eq!(std::str::from_utf8(&content).unwrap(), "hi world");
597    }
598
599    #[tokio::test]
600    async fn test_patch_replace_line() {
601        let backend = make_backend().await;
602        let ops = vec![PatchOp::ReplaceLine {
603            line: 2,
604            content: "replaced".to_string(),
605            expected: None,
606        }];
607        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
608        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
609        let text = std::str::from_utf8(&content).unwrap();
610        assert!(text.contains("line1"));
611        assert!(text.contains("replaced"));
612        assert!(text.contains("line3"));
613        assert!(!text.contains("line2"));
614    }
615
616    #[tokio::test]
617    async fn test_patch_delete_line() {
618        let backend = make_backend().await;
619        let ops = vec![PatchOp::DeleteLine {
620            line: 2,
621            expected: None,
622        }];
623        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
624        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
625        let text = std::str::from_utf8(&content).unwrap();
626        assert!(text.contains("line1"));
627        assert!(!text.contains("line2"));
628        assert!(text.contains("line3"));
629    }
630
631    #[tokio::test]
632    async fn test_patch_insert_line() {
633        let backend = make_backend().await;
634        let ops = vec![PatchOp::InsertLine {
635            line: 2,
636            content: "inserted".to_string(),
637        }];
638        backend.patch(Path::new("/lines.txt"), &ops).await.unwrap();
639        let content = backend.read(Path::new("/lines.txt"), None).await.unwrap();
640        let text = std::str::from_utf8(&content).unwrap();
641        let lines: Vec<&str> = text.lines().collect();
642        assert_eq!(lines[0], "line1");
643        assert_eq!(lines[1], "inserted");
644        assert_eq!(lines[2], "line2");
645    }
646
647    #[tokio::test]
648    async fn test_patch_append() {
649        let backend = make_backend().await;
650        let ops = vec![PatchOp::Append {
651            content: "!".to_string(),
652        }];
653        backend.patch(Path::new("/test.txt"), &ops).await.unwrap();
654        let content = backend.read(Path::new("/test.txt"), None).await.unwrap();
655        assert_eq!(std::str::from_utf8(&content).unwrap(), "hello world!");
656    }
657
658    #[tokio::test]
659    async fn test_list() {
660        let backend = make_backend().await;
661        let entries = backend.list(Path::new("/")).await.unwrap();
662        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
663        assert!(names.contains(&"test.txt"));
664        assert!(names.contains(&"lines.txt"));
665        assert!(names.contains(&"dir"));
666    }
667
668    #[tokio::test]
669    async fn test_stat() {
670        let backend = make_backend().await;
671        let info = backend.stat(Path::new("/test.txt")).await.unwrap();
672        assert!(info.is_file());
673        assert_eq!(info.size, 11); // "hello world".len()
674
675        let info = backend.stat(Path::new("/dir")).await.unwrap();
676        assert!(info.is_dir());
677    }
678
679    #[tokio::test]
680    async fn test_mkdir() {
681        let backend = make_backend().await;
682        backend.mkdir(Path::new("/newdir")).await.unwrap();
683        assert!(backend.exists(Path::new("/newdir")).await);
684        let info = backend.stat(Path::new("/newdir")).await.unwrap();
685        assert!(info.is_dir());
686    }
687
688    #[tokio::test]
689    async fn test_remove() {
690        let backend = make_backend().await;
691        assert!(backend.exists(Path::new("/test.txt")).await);
692        backend.remove(Path::new("/test.txt"), false).await.unwrap();
693        assert!(!backend.exists(Path::new("/test.txt")).await);
694    }
695
696    #[tokio::test]
697    async fn test_remove_recursive() {
698        let backend = make_backend().await;
699        assert!(backend.exists(Path::new("/dir/nested.txt")).await);
700        backend.remove(Path::new("/dir"), true).await.unwrap();
701        assert!(!backend.exists(Path::new("/dir")).await);
702        assert!(!backend.exists(Path::new("/dir/nested.txt")).await);
703    }
704
705    #[tokio::test]
706    async fn test_exists() {
707        let backend = make_backend().await;
708        assert!(backend.exists(Path::new("/test.txt")).await);
709        assert!(!backend.exists(Path::new("/nonexistent.txt")).await);
710    }
711
712    #[tokio::test]
713    async fn test_backend_info() {
714        let backend = make_backend().await;
715        assert_eq!(backend.backend_type(), "local");
716        assert!(!backend.read_only());
717        let mounts = backend.mounts();
718        assert!(!mounts.is_empty());
719    }
720
721    #[tokio::test]
722    async fn test_list_includes_symlinks() {
723        use crate::vfs::Filesystem;
724
725        let mut vfs = VfsRouter::new();
726        let mem = MemoryFs::new();
727        mem.write(Path::new("target.txt"), b"content").await.unwrap();
728        mem.symlink(Path::new("target.txt"), Path::new("link.txt")).await.unwrap();
729        vfs.mount("/", mem);
730        let backend = LocalBackend::new(Arc::new(vfs));
731
732        let entries = backend.list(Path::new("/")).await.unwrap();
733
734        let link_entry = entries.iter().find(|e| e.name == "link.txt").unwrap();
735        assert!(link_entry.is_symlink(), "link.txt should be a symlink");
736        assert_eq!(link_entry.symlink_target, Some(PathBuf::from("target.txt")));
737    }
738}