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