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