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