Skip to main content

kaish_kernel/backend/
mod.rs

1//! KernelBackend trait for abstracting kaish's I/O layer.
2//!
3//! This module defines the `KernelBackend` trait which provides a unified interface
4//! for file operations and tool dispatch. Two implementations are provided:
5//!
6//! - `LocalBackend`: Default implementation wrapping VfsRouter for local filesystem access
7//! - (Future) `KaijutsuBackend`: CRDT-backed blocks when running under kaijutsu
8//!
9//! # Architecture
10//!
11//! ```text
12//! Builtins (cat, ls, echo, etc.)
13//!     ↓
14//! ctx.backend: Arc<dyn KernelBackend>
15//!     ↓
16//! ┌─────────────────────────────────────────────┐
17//! │  LocalBackend (default)  │  KaijutsuBackend │
18//! │  - wraps VfsRouter       │  - CRDT blocks   │
19//! │  - local ToolRegistry    │  - parent tools  │
20//! └─────────────────────────────────────────────┘
21//! ```
22
23mod local;
24mod overlay;
25
26pub use local::LocalBackend;
27pub use overlay::VirtualOverlayBackend;
28
29#[cfg(test)]
30pub mod testing;
31
32#[cfg(test)]
33pub use testing::MockBackend;
34
35use async_trait::async_trait;
36use serde_json::Value as JsonValue;
37use std::path::{Path, PathBuf};
38use thiserror::Error;
39
40use crate::interpreter::{value_to_json, ExecResult, OutputData};
41use crate::tools::{ExecContext, ToolArgs, ToolSchema};
42use crate::vfs::MountInfo;
43
44/// Result type for backend operations.
45pub type BackendResult<T> = Result<T, BackendError>;
46
47/// Backend operation errors.
48#[derive(Debug, Clone, Error)]
49pub enum BackendError {
50    #[error("not found: {0}")]
51    NotFound(String),
52    #[error("already exists: {0}")]
53    AlreadyExists(String),
54    #[error("permission denied: {0}")]
55    PermissionDenied(String),
56    #[error("is a directory: {0}")]
57    IsDirectory(String),
58    #[error("not a directory: {0}")]
59    NotDirectory(String),
60    #[error("read-only filesystem")]
61    ReadOnly,
62    #[error("conflict: {0}")]
63    Conflict(ConflictError),
64    #[error("tool not found: {0}")]
65    ToolNotFound(String),
66    #[error("io error: {0}")]
67    Io(String),
68    #[error("invalid operation: {0}")]
69    InvalidOperation(String),
70}
71
72impl From<std::io::Error> for BackendError {
73    fn from(err: std::io::Error) -> Self {
74        use std::io::ErrorKind;
75        match err.kind() {
76            ErrorKind::NotFound => BackendError::NotFound(err.to_string()),
77            ErrorKind::AlreadyExists => BackendError::AlreadyExists(err.to_string()),
78            ErrorKind::PermissionDenied => BackendError::PermissionDenied(err.to_string()),
79            ErrorKind::IsADirectory => BackendError::IsDirectory(err.to_string()),
80            ErrorKind::NotADirectory => BackendError::NotDirectory(err.to_string()),
81            ErrorKind::ReadOnlyFilesystem => BackendError::ReadOnly,
82            _ => BackendError::Io(err.to_string()),
83        }
84    }
85}
86
87/// Error when CAS (compare-and-set) check fails during patching.
88#[derive(Debug, Clone, Error)]
89#[error("conflict at {location}: expected {expected:?}, found {actual:?}")]
90pub struct ConflictError {
91    /// Location of the conflict (e.g., "offset 42" or "line 7")
92    pub location: String,
93    /// Expected content at that location
94    pub expected: String,
95    /// Actual content found at that location
96    pub actual: String,
97}
98
99/// Generic patch operation for file modifications.
100///
101/// Maps to POSIX operations, CRDTs, or REST APIs. All positional ops
102/// support compare-and-set (CAS) via optional `expected` field.
103/// If `expected` is Some, the operation fails with ConflictError if the
104/// current content at that position doesn't match.
105///
106/// # Line Ending Normalization
107///
108/// Line-based operations (`InsertLine`, `DeleteLine`, `ReplaceLine`) normalize
109/// line endings to Unix-style (`\n`). Files with `\r\n` (Windows) line endings
110/// will be converted to `\n` after a line-based patch. This is intentional for
111/// kaish's Unix-first design. Use byte-based operations (`Insert`, `Delete`,
112/// `Replace`) to preserve original line endings.
113#[derive(Debug, Clone)]
114pub enum PatchOp {
115    /// Insert content at byte offset.
116    Insert { offset: usize, content: String },
117
118    /// Delete bytes from offset to offset+len.
119    /// `expected`: if Some, must match content being deleted (CAS)
120    Delete {
121        offset: usize,
122        len: usize,
123        expected: Option<String>,
124    },
125
126    /// Replace content at offset.
127    /// `expected`: if Some, must match content being replaced (CAS)
128    Replace {
129        offset: usize,
130        len: usize,
131        content: String,
132        expected: Option<String>,
133    },
134
135    /// Insert a line at line number (1-indexed).
136    InsertLine { line: usize, content: String },
137
138    /// Delete a line at line number (1-indexed).
139    /// `expected`: if Some, must match line being deleted (CAS)
140    DeleteLine { line: usize, expected: Option<String> },
141
142    /// Replace a line at line number (1-indexed).
143    /// `expected`: if Some, must match line being replaced (CAS)
144    ReplaceLine {
145        line: usize,
146        content: String,
147        expected: Option<String>,
148    },
149
150    /// Append content to end of file (no CAS needed - always safe).
151    Append { content: String },
152}
153
154/// Range specification for partial file reads.
155#[derive(Debug, Clone, Default)]
156pub struct ReadRange {
157    /// Start line (1-indexed). If set, read from this line.
158    pub start_line: Option<usize>,
159    /// End line (1-indexed, inclusive). If set, read until this line.
160    pub end_line: Option<usize>,
161    /// Byte offset to start reading from.
162    pub offset: Option<u64>,
163    /// Maximum number of bytes to read.
164    pub limit: Option<u64>,
165}
166
167impl ReadRange {
168    /// Create a range for reading specific lines.
169    pub fn lines(start: usize, end: usize) -> Self {
170        Self {
171            start_line: Some(start),
172            end_line: Some(end),
173            ..Default::default()
174        }
175    }
176
177    /// Create a range for reading bytes at an offset.
178    pub fn bytes(offset: u64, limit: u64) -> Self {
179        Self {
180            offset: Some(offset),
181            limit: Some(limit),
182            ..Default::default()
183        }
184    }
185}
186
187/// Write mode for file operations.
188#[derive(Debug, Clone, Copy, Default)]
189pub enum WriteMode {
190    /// Fail if file already exists.
191    CreateNew,
192    /// Overwrite existing file (default, like `>`).
193    #[default]
194    Overwrite,
195    /// Fail if file does not exist.
196    UpdateOnly,
197    /// Explicitly truncate file before writing.
198    Truncate,
199}
200
201/// Information about a file or directory entry.
202#[derive(Debug, Clone)]
203pub struct EntryInfo {
204    /// Entry name (file or directory name).
205    pub name: String,
206    /// True if this is a directory.
207    pub is_dir: bool,
208    /// True if this is a file.
209    pub is_file: bool,
210    /// True if this is a symbolic link.
211    pub is_symlink: bool,
212    /// Size in bytes.
213    pub size: u64,
214    /// Last modification time (Unix timestamp in seconds).
215    pub modified: Option<u64>,
216    /// Unix permissions (e.g., 0o644).
217    pub permissions: Option<u32>,
218    /// For symlinks, the target path.
219    pub symlink_target: Option<std::path::PathBuf>,
220}
221
222impl EntryInfo {
223    /// Create a new directory entry.
224    pub fn directory(name: impl Into<String>) -> Self {
225        Self {
226            name: name.into(),
227            is_dir: true,
228            is_file: false,
229            is_symlink: false,
230            size: 0,
231            modified: None,
232            permissions: None,
233            symlink_target: None,
234        }
235    }
236
237    /// Create a new file entry.
238    pub fn file(name: impl Into<String>, size: u64) -> Self {
239        Self {
240            name: name.into(),
241            is_dir: false,
242            is_file: true,
243            is_symlink: false,
244            size,
245            modified: None,
246            permissions: None,
247            symlink_target: None,
248        }
249    }
250
251    /// Create a new symlink entry.
252    pub fn symlink(name: impl Into<String>, target: impl Into<std::path::PathBuf>) -> Self {
253        Self {
254            name: name.into(),
255            is_dir: false,
256            is_file: false,
257            is_symlink: true,
258            size: 0,
259            modified: None,
260            permissions: None,
261            symlink_target: Some(target.into()),
262        }
263    }
264}
265
266/// Result from tool execution via backend.
267#[derive(Debug, Clone)]
268pub struct ToolResult {
269    /// Exit code (0 = success).
270    pub code: i32,
271    /// Standard output.
272    pub stdout: String,
273    /// Standard error.
274    pub stderr: String,
275    /// Structured data (if any).
276    pub data: Option<JsonValue>,
277    /// Structured output data for rendering (preserved from ExecResult).
278    pub output: Option<OutputData>,
279}
280
281impl ToolResult {
282    /// Create a successful result.
283    pub fn success(stdout: impl Into<String>) -> Self {
284        Self {
285            code: 0,
286            stdout: stdout.into(),
287            stderr: String::new(),
288            data: None,
289            output: None,
290        }
291    }
292
293    /// Create a failed result.
294    pub fn failure(code: i32, stderr: impl Into<String>) -> Self {
295        Self {
296            code,
297            stdout: String::new(),
298            stderr: stderr.into(),
299            data: None,
300            output: None,
301        }
302    }
303
304    /// Create a result with structured data.
305    pub fn with_data(stdout: impl Into<String>, data: JsonValue) -> Self {
306        Self {
307            code: 0,
308            stdout: stdout.into(),
309            stderr: String::new(),
310            data: Some(data),
311            output: None,
312        }
313    }
314
315    /// Check if the tool execution succeeded.
316    pub fn ok(&self) -> bool {
317        self.code == 0
318    }
319}
320
321impl From<ExecResult> for ToolResult {
322    fn from(exec: ExecResult) -> Self {
323        // Saturating cast: codes outside i32 range clamp to i32::MIN/MAX
324        let code = exec.code.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
325
326        // Convert ast::Value to serde_json::Value if present
327        let data = exec.data.map(|v| value_to_json(&v));
328
329        Self {
330            code,
331            stdout: exec.out,
332            stderr: exec.err,
333            data,
334            output: exec.output,
335        }
336    }
337}
338
339/// Information about an available tool.
340#[derive(Debug, Clone)]
341pub struct ToolInfo {
342    /// Tool name.
343    pub name: String,
344    /// Tool description.
345    pub description: String,
346    /// Full tool schema.
347    pub schema: ToolSchema,
348}
349
350/// Abstract backend interface for file operations and tool dispatch.
351///
352/// This trait abstracts kaish's I/O layer, enabling different backends:
353/// - `LocalBackend`: Default implementation using VfsRouter
354/// - `KaijutsuBackend`: CRDT-backed implementation for collaborative editing
355#[async_trait]
356pub trait KernelBackend: Send + Sync {
357    // ═══════════════════════════════════════════════════════════════════════════
358    // File Operations
359    // ═══════════════════════════════════════════════════════════════════════════
360
361    /// Read file contents, optionally with a range specification.
362    async fn read(&self, path: &Path, range: Option<ReadRange>) -> BackendResult<Vec<u8>>;
363
364    /// Write content to a file with the specified mode.
365    async fn write(&self, path: &Path, content: &[u8], mode: WriteMode) -> BackendResult<()>;
366
367    /// Append content to a file.
368    async fn append(&self, path: &Path, content: &[u8]) -> BackendResult<()>;
369
370    /// Apply patch operations to a file.
371    ///
372    /// Patch operations support compare-and-set (CAS) for conflict detection.
373    /// If an operation's `expected` field doesn't match the actual content,
374    /// returns `BackendError::Conflict`.
375    async fn patch(&self, path: &Path, ops: &[PatchOp]) -> BackendResult<()>;
376
377    // ═══════════════════════════════════════════════════════════════════════════
378    // Directory Operations
379    // ═══════════════════════════════════════════════════════════════════════════
380
381    /// List directory contents.
382    async fn list(&self, path: &Path) -> BackendResult<Vec<EntryInfo>>;
383
384    /// Get file or directory metadata.
385    async fn stat(&self, path: &Path) -> BackendResult<EntryInfo>;
386
387    /// Create a directory (and parent directories if needed).
388    async fn mkdir(&self, path: &Path) -> BackendResult<()>;
389
390    /// Remove a file or directory.
391    ///
392    /// If `recursive` is true, removes directories and their contents.
393    async fn remove(&self, path: &Path, recursive: bool) -> BackendResult<()>;
394
395    /// Rename (move) a file or directory.
396    ///
397    /// This is an atomic operation when source and destination are on the same
398    /// filesystem. Cross-mount renames are not supported.
399    async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()>;
400
401    /// Check if a path exists.
402    async fn exists(&self, path: &Path) -> bool;
403
404    // ═══════════════════════════════════════════════════════════════════════════
405    // Symlink Operations
406    // ═══════════════════════════════════════════════════════════════════════════
407
408    /// Read the target of a symbolic link.
409    ///
410    /// Returns the path the symlink points to without following it.
411    async fn read_link(&self, path: &Path) -> BackendResult<PathBuf>;
412
413    /// Create a symbolic link.
414    ///
415    /// Creates a symlink at `link` pointing to `target`.
416    async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()>;
417
418    // ═══════════════════════════════════════════════════════════════════════════
419    // Tool Dispatch
420    // ═══════════════════════════════════════════════════════════════════════════
421
422    /// Call a tool by name with the given arguments and execution context.
423    ///
424    /// For local backends, this executes the tool directly via ToolRegistry.
425    /// For remote backends (e.g., kaijutsu), this may serialize the call
426    /// and forward it to the parent process.
427    async fn call_tool(
428        &self,
429        name: &str,
430        args: ToolArgs,
431        ctx: &mut ExecContext,
432    ) -> BackendResult<ToolResult>;
433
434    /// List available external tools.
435    async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>>;
436
437    /// Get information about a specific tool.
438    async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>>;
439
440    // ═══════════════════════════════════════════════════════════════════════════
441    // Backend Information
442    // ═══════════════════════════════════════════════════════════════════════════
443
444    /// Returns true if this backend is read-only.
445    fn read_only(&self) -> bool;
446
447    /// Returns the backend type identifier (e.g., "local", "kaijutsu").
448    fn backend_type(&self) -> &str;
449
450    /// List all mount points.
451    fn mounts(&self) -> Vec<MountInfo>;
452
453    /// Resolve a VFS path to a real filesystem path.
454    ///
455    /// Returns `Some(path)` if the VFS path maps to a real filesystem (like LocalFs),
456    /// or `None` if the path is in a virtual filesystem (like MemoryFs).
457    ///
458    /// This is needed for tools like `git` that must use real paths with external libraries.
459    fn resolve_real_path(&self, path: &Path) -> Option<std::path::PathBuf>;
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use super::testing::MockBackend;
466    use std::sync::atomic::Ordering;
467    use std::sync::Arc;
468
469    #[test]
470    fn test_backend_error_from_io_error() {
471        let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
472        let backend_err: BackendError = not_found.into();
473        assert!(matches!(backend_err, BackendError::NotFound(_)));
474
475        let permission = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "no access");
476        let backend_err: BackendError = permission.into();
477        assert!(matches!(backend_err, BackendError::PermissionDenied(_)));
478    }
479
480    #[test]
481    fn test_entry_info_constructors() {
482        let dir = EntryInfo::directory("mydir");
483        assert!(dir.is_dir);
484        assert!(!dir.is_file);
485        assert_eq!(dir.name, "mydir");
486
487        let file = EntryInfo::file("myfile.txt", 1024);
488        assert!(!file.is_dir);
489        assert!(file.is_file);
490        assert_eq!(file.size, 1024);
491    }
492
493    #[test]
494    fn test_tool_result() {
495        let success = ToolResult::success("hello");
496        assert!(success.ok());
497        assert_eq!(success.stdout, "hello");
498
499        let failure = ToolResult::failure(1, "error");
500        assert!(!failure.ok());
501        assert_eq!(failure.code, 1);
502    }
503
504    #[test]
505    fn test_read_range() {
506        let lines = ReadRange::lines(10, 20);
507        assert_eq!(lines.start_line, Some(10));
508        assert_eq!(lines.end_line, Some(20));
509
510        let bytes = ReadRange::bytes(100, 50);
511        assert_eq!(bytes.offset, Some(100));
512        assert_eq!(bytes.limit, Some(50));
513    }
514
515    #[tokio::test]
516    async fn test_mock_backend_call_tool_routing() {
517        let (backend, call_count) = MockBackend::new();
518        let backend: Arc<dyn KernelBackend> = Arc::new(backend);
519        let mut ctx = ExecContext::with_backend(backend.clone());
520
521        // Verify initial count is 0
522        assert_eq!(call_count.load(Ordering::SeqCst), 0);
523
524        // Call tool through backend
525        let args = ToolArgs::new();
526        let result = backend.call_tool("test-tool", args, &mut ctx).await.unwrap();
527
528        // Verify call was routed through backend
529        assert_eq!(call_count.load(Ordering::SeqCst), 1);
530        assert!(result.ok());
531        assert!(result.stdout.contains("mock executed: test-tool"));
532
533        // Call again to verify count increments
534        let args = ToolArgs::new();
535        backend.call_tool("another-tool", args, &mut ctx).await.unwrap();
536        assert_eq!(call_count.load(Ordering::SeqCst), 2);
537    }
538}