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