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