Skip to main content

kaish_types/
backend.rs

1//! Backend data types — errors, results, and operations.
2//!
3//! These types define the data contract for `KernelBackend` implementations.
4//! The trait itself lives in kaish-kernel (it depends on async_trait and ExecContext).
5
6use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use serde_json::Value as JsonValue;
10use thiserror::Error;
11
12use crate::output::OutputData;
13use crate::result::{value_to_json, ExecResult};
14use crate::tool::ToolSchema;
15
16/// Result type for backend operations.
17pub type BackendResult<T> = Result<T, BackendError>;
18
19/// Information about a mount point.
20///
21/// Returned by `KernelBackend::mounts`. Pure data so it can live in the leaf
22/// types crate alongside the rest of the backend contract.
23#[derive(Debug, Clone)]
24pub struct MountInfo {
25    /// The mount path (e.g., "/mnt/project").
26    pub path: PathBuf,
27    /// Whether this mount is read-only.
28    pub read_only: bool,
29    /// Memory-resident content bytes held by this mount, if it tracks them.
30    ///
31    /// `Some(n)` for memory-backed mounts (`MemoryFs`, `OverlayFs`).
32    /// `None` for disk-backed mounts (`LocalFs`) — disk residency is the
33    /// host's concern (`df`), not this counter. Embedder-supplied `MountInfo`
34    /// values that do not track residency should use `None` so `kaish-mounts`
35    /// renders `-` rather than a misleading number.
36    pub resident_bytes: Option<u64>,
37}
38
39/// Backend operation errors.
40#[derive(Debug, Clone, Error)]
41#[non_exhaustive]
42pub enum BackendError {
43    #[error("not found: {0}")]
44    NotFound(String),
45    #[error("already exists: {0}")]
46    AlreadyExists(String),
47    #[error("permission denied: {0}")]
48    PermissionDenied(String),
49    #[error("is a directory: {0}")]
50    IsDirectory(String),
51    #[error("not a directory: {0}")]
52    NotDirectory(String),
53    #[error("read-only filesystem")]
54    ReadOnly,
55    #[error("conflict: {0}")]
56    Conflict(ConflictError),
57    #[error("tool not found: {0}")]
58    ToolNotFound(String),
59    #[error("io error: {0}")]
60    Io(String),
61    #[error("invalid operation: {0}")]
62    InvalidOperation(String),
63}
64
65impl From<std::io::Error> for BackendError {
66    fn from(err: std::io::Error) -> Self {
67        use std::io::ErrorKind;
68        match err.kind() {
69            ErrorKind::NotFound => BackendError::NotFound(err.to_string()),
70            ErrorKind::AlreadyExists => BackendError::AlreadyExists(err.to_string()),
71            ErrorKind::PermissionDenied => BackendError::PermissionDenied(err.to_string()),
72            ErrorKind::IsADirectory => BackendError::IsDirectory(err.to_string()),
73            ErrorKind::NotADirectory => BackendError::NotDirectory(err.to_string()),
74            ErrorKind::ReadOnlyFilesystem => BackendError::ReadOnly,
75            _ => BackendError::Io(err.to_string()),
76        }
77    }
78}
79
80/// Error when CAS (compare-and-set) check fails during patching.
81#[derive(Debug, Clone, Error)]
82#[error("conflict at {location}: expected {expected:?}, found {actual:?}")]
83pub struct ConflictError {
84    /// Location of the conflict (e.g., "offset 42" or "line 7")
85    pub location: String,
86    /// Expected content at that location
87    pub expected: String,
88    /// Actual content found at that location
89    pub actual: String,
90}
91
92/// Generic patch operation for file modifications.
93///
94/// Maps to POSIX operations, CRDTs, or REST APIs. All positional ops
95/// support compare-and-set (CAS) via optional `expected` field.
96/// If `expected` is Some, the operation fails with ConflictError if the
97/// current content at that position doesn't match.
98///
99/// # Line Ending Normalization
100///
101/// Line-based operations (`InsertLine`, `DeleteLine`, `ReplaceLine`) normalize
102/// line endings to Unix-style (`\n`). Files with `\r\n` (Windows) line endings
103/// will be converted to `\n` after a line-based patch. This is intentional for
104/// kaish's Unix-first design. Use byte-based operations (`Insert`, `Delete`,
105/// `Replace`) to preserve original line endings.
106#[derive(Debug, Clone)]
107pub enum PatchOp {
108    /// Insert content at byte offset.
109    Insert { offset: usize, content: String },
110
111    /// Delete bytes from offset to offset+len.
112    /// `expected`: if Some, must match content being deleted (CAS)
113    Delete {
114        offset: usize,
115        len: usize,
116        expected: Option<String>,
117    },
118
119    /// Replace content at offset.
120    /// `expected`: if Some, must match content being replaced (CAS)
121    Replace {
122        offset: usize,
123        len: usize,
124        content: String,
125        expected: Option<String>,
126    },
127
128    /// Insert a line at line number (1-indexed).
129    InsertLine { line: usize, content: String },
130
131    /// Delete a line at line number (1-indexed).
132    /// `expected`: if Some, must match line being deleted (CAS)
133    DeleteLine { line: usize, expected: Option<String> },
134
135    /// Replace a line at line number (1-indexed).
136    /// `expected`: if Some, must match line being replaced (CAS)
137    ReplaceLine {
138        line: usize,
139        content: String,
140        expected: Option<String>,
141    },
142
143    /// Append content to end of file (no CAS needed - always safe).
144    Append { content: String },
145}
146
147/// Range specification for partial file reads.
148#[derive(Debug, Clone, Default)]
149pub struct ReadRange {
150    /// Start line (1-indexed). If set, read from this line.
151    pub start_line: Option<usize>,
152    /// End line (1-indexed, inclusive). If set, read until this line.
153    pub end_line: Option<usize>,
154    /// Byte offset to start reading from.
155    pub offset: Option<u64>,
156    /// Maximum number of bytes to read.
157    pub limit: Option<u64>,
158}
159
160impl ReadRange {
161    /// Create a range for reading specific lines.
162    pub fn lines(start: usize, end: usize) -> Self {
163        Self {
164            start_line: Some(start),
165            end_line: Some(end),
166            ..Default::default()
167        }
168    }
169
170    /// Create a range for reading bytes at an offset.
171    pub fn bytes(offset: u64, limit: u64) -> Self {
172        Self {
173            offset: Some(offset),
174            limit: Some(limit),
175            ..Default::default()
176        }
177    }
178}
179
180/// Write mode for file operations.
181#[non_exhaustive]
182#[derive(Debug, Clone, Copy, Default)]
183pub enum WriteMode {
184    /// Fail if file already exists.
185    CreateNew,
186    /// Overwrite existing file (default, like `>`).
187    #[default]
188    Overwrite,
189    /// Fail if file does not exist.
190    UpdateOnly,
191    /// Explicitly truncate file before writing.
192    Truncate,
193}
194
195/// Result from tool execution via backend.
196#[derive(Debug, Clone)]
197pub struct ToolResult {
198    /// Exit code (0 = success).
199    pub code: i32,
200    /// Standard output.
201    pub stdout: String,
202    /// Standard error.
203    pub stderr: String,
204    /// Structured data (if any).
205    pub data: Option<JsonValue>,
206    /// Structured output data for rendering (preserved from ExecResult).
207    pub output: Option<OutputData>,
208    /// MIME content type hint (propagated from ExecResult).
209    pub content_type: Option<String>,
210    /// Opaque key-value context (propagated from ExecResult).
211    pub baggage: BTreeMap<String, String>,
212}
213
214impl ToolResult {
215    /// Create a successful result.
216    pub fn success(stdout: impl Into<String>) -> Self {
217        Self {
218            code: 0,
219            stdout: stdout.into(),
220            stderr: String::new(),
221            data: None,
222            output: None,
223            content_type: None,
224            baggage: BTreeMap::new(),
225        }
226    }
227
228    /// Create a failed result.
229    pub fn failure(code: i32, stderr: impl Into<String>) -> Self {
230        Self {
231            code,
232            stdout: String::new(),
233            stderr: stderr.into(),
234            data: None,
235            output: None,
236            content_type: None,
237            baggage: BTreeMap::new(),
238        }
239    }
240
241    /// Create a result with structured data.
242    pub fn with_data(stdout: impl Into<String>, data: JsonValue) -> Self {
243        Self {
244            code: 0,
245            stdout: stdout.into(),
246            stderr: String::new(),
247            data: Some(data),
248            output: None,
249            content_type: None,
250            baggage: BTreeMap::new(),
251        }
252    }
253
254    /// Check if the tool execution succeeded.
255    pub fn ok(&self) -> bool {
256        self.code == 0
257    }
258}
259
260impl From<ExecResult> for ToolResult {
261    fn from(mut exec: ExecResult) -> Self {
262        // Saturating cast: codes outside i32 range clamp to i32::MIN/MAX
263        let code = exec.code.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
264
265        // Materialize text before moving fields out
266        let stdout = exec.text_out().into_owned();
267        let output = exec.take_output();
268
269        // Convert ast::Value to serde_json::Value if present
270        let data = exec.data.map(|v| value_to_json(&v));
271
272        Self {
273            code,
274            stdout,
275            stderr: exec.err,
276            data,
277            output,
278            content_type: exec.content_type,
279            baggage: exec.baggage,
280        }
281    }
282}
283
284/// Information about an available tool.
285#[derive(Debug, Clone)]
286pub struct ToolInfo {
287    /// Tool name.
288    pub name: String,
289    /// Tool description.
290    pub description: String,
291    /// Full tool schema.
292    pub schema: ToolSchema,
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn tool_result_from_exec_result_preserves_content_type_and_baggage() {
301        let mut exec = ExecResult::success("hello");
302        exec.content_type = Some("text/markdown".to_string());
303        exec.baggage.insert("traceparent".to_string(), "00-abc-def-01".to_string());
304
305        let tool_result = ToolResult::from(exec);
306        assert_eq!(tool_result.content_type.as_deref(), Some("text/markdown"));
307        assert_eq!(
308            tool_result.baggage.get("traceparent").map(|s| s.as_str()),
309            Some("00-abc-def-01")
310        );
311    }
312
313    #[test]
314    fn tool_result_constructors_default_to_empty_baggage() {
315        let success = ToolResult::success("ok");
316        assert!(success.baggage.is_empty());
317        assert!(success.content_type.is_none());
318
319        let failure = ToolResult::failure(1, "err");
320        assert!(failure.baggage.is_empty());
321        assert!(failure.content_type.is_none());
322    }
323}