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