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