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::{json_to_value_no_envelope, value_to_json, ExecResult, LatchRequest};
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 /// Apply this range to already-read file content.
180 ///
181 /// Byte ranges win over line ranges when both are set. A line range on
182 /// non-UTF-8 content returns the content untouched (there are no lines to
183 /// slice). This is the single source of truth for range slicing, shared by
184 /// the `Filesystem::read_range` default and the kernel backends.
185 pub fn apply(&self, content: &[u8]) -> Vec<u8> {
186 // Byte-based range
187 if self.offset.is_some() || self.limit.is_some() {
188 let offset = self.offset.unwrap_or(0) as usize;
189 let limit = self.limit.map(|l| l as usize).unwrap_or(content.len());
190 let end = offset.saturating_add(limit).min(content.len());
191 return content.get(offset..end).unwrap_or(&[]).to_vec();
192 }
193
194 // Line-based range
195 if self.start_line.is_some() || self.end_line.is_some() {
196 let content_str = match std::str::from_utf8(content) {
197 Ok(s) => s,
198 Err(_) => return content.to_vec(),
199 };
200 let lines: Vec<&str> = content_str.lines().collect();
201 let start = self.start_line.unwrap_or(1).saturating_sub(1);
202 let end = self.end_line.unwrap_or(lines.len()).min(lines.len());
203 let selected: Vec<&str> = lines.get(start..end).unwrap_or(&[]).to_vec();
204 let mut result = selected.join("\n");
205 // Preserve a trailing newline only when reading to the implicit end
206 // and the original content had one.
207 if self.end_line.is_none() && content_str.ends_with('\n') && !result.is_empty() {
208 result.push('\n');
209 }
210 return result.into_bytes();
211 }
212
213 content.to_vec()
214 }
215}
216
217/// Write mode for file operations.
218#[non_exhaustive]
219#[derive(Debug, Clone, Copy, Default)]
220pub enum WriteMode {
221 /// Fail if file already exists.
222 CreateNew,
223 /// Overwrite existing file (default, like `>`).
224 #[default]
225 Overwrite,
226 /// Fail if file does not exist.
227 UpdateOnly,
228 /// Explicitly truncate file before writing.
229 Truncate,
230}
231
232/// Result from tool execution via backend.
233#[derive(Debug, Clone)]
234pub struct ToolResult {
235 /// Exit code (0 = success).
236 pub code: i32,
237 /// Standard output.
238 pub stdout: String,
239 /// Standard error.
240 pub stderr: String,
241 /// Structured data (if any).
242 pub data: Option<JsonValue>,
243 /// Structured output data for rendering (preserved from ExecResult).
244 pub output: Option<OutputData>,
245 /// MIME content type hint (propagated from ExecResult).
246 pub content_type: Option<String>,
247 /// Opaque key-value context (propagated from ExecResult).
248 pub baggage: BTreeMap<String, String>,
249 /// A pending confirmation-latch request (propagated from ExecResult), so a
250 /// backend-tool latch survives the ExecResult↔ToolResult roundtrip. Its own
251 /// typed field — never folded into `data`. Boxed to match `ExecResult.latch`
252 /// (keeps the roundtrip a direct move; see that field for why).
253 pub latch: Option<Box<LatchRequest>>,
254}
255
256impl ToolResult {
257 /// Create a successful result.
258 pub fn success(stdout: impl Into<String>) -> Self {
259 Self {
260 code: 0,
261 stdout: stdout.into(),
262 stderr: String::new(),
263 data: None,
264 output: None,
265 content_type: None,
266 baggage: BTreeMap::new(),
267 latch: None,
268 }
269 }
270
271 /// Create a failed result.
272 pub fn failure(code: i32, stderr: impl Into<String>) -> Self {
273 Self {
274 code,
275 stdout: String::new(),
276 stderr: stderr.into(),
277 data: None,
278 output: None,
279 content_type: None,
280 baggage: BTreeMap::new(),
281 latch: None,
282 }
283 }
284
285 /// Create a result with structured data.
286 pub fn with_data(stdout: impl Into<String>, data: JsonValue) -> Self {
287 Self {
288 code: 0,
289 stdout: stdout.into(),
290 stderr: String::new(),
291 data: Some(data),
292 output: None,
293 content_type: None,
294 baggage: BTreeMap::new(),
295 latch: None,
296 }
297 }
298
299 /// Check if the tool execution succeeded.
300 pub fn ok(&self) -> bool {
301 self.code == 0
302 }
303}
304
305impl From<ExecResult> for ToolResult {
306 fn from(mut exec: ExecResult) -> Self {
307 // Saturating cast: codes outside i32 range clamp to i32::MIN/MAX
308 let code = exec.code.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
309
310 // HAZARD: `text_out()` is the infallible/lossy decoder — a binary
311 // `OutputPayload::Bytes` (already produced by `cat`/`head`/`tail`/
312 // `base64 -d`/`xxd -r`/`dd`/`tee`/external commands, so this is real
313 // today, not merely a future risk) gets its invalid-UTF-8 bytes
314 // replaced with U+FFFD here. This `From` impl is infallible by
315 // signature, so it cannot fail loud the way `try_text_out()` does —
316 // this is an accepted, deliberate exception, not an oversight. The
317 // binary survives losslessly in the preserved `output: Option<OutputData>`
318 // field below; a structured/binary-aware consumer MUST read `output`,
319 // never `stdout`, to avoid the lossy decode. If a future embedder seam
320 // needs a fallible conversion here, add a `TryFrom` (or a bytes-carrying
321 // field) rather than changing this `From`'s behavior. See
322 // `docs/binary-data.md`.
323 let stdout = exec.text_out().into_owned();
324 let output = exec.take_output();
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,
332 stderr: exec.err,
333 data,
334 output,
335 content_type: exec.content_type,
336 baggage: exec.baggage,
337 latch: exec.latch,
338 }
339 }
340}
341
342impl From<ToolResult> for ExecResult {
343 /// The symmetric peer of `From<ExecResult> for ToolResult` above — every
344 /// field that direction preserves, this direction must preserve too, or a
345 /// backend-registered tool's structured `data`/`content_type`/`baggage`
346 /// silently vanishes crossing back into the kernel (the embedder seam:
347 /// `x=$(embedder_tool)` and `for r in $(embedder_tool)` need `.data` to
348 /// see typed results, not just stdout text).
349 ///
350 /// `data` uses [`json_to_value_no_envelope`] rather than the internal
351 /// round-trip conversion: a backend tool's JSON is external input, so an
352 /// object shaped like the byte envelope must stay a plain record, never
353 /// silently auto-decode to `Value::Bytes`.
354 fn from(result: ToolResult) -> Self {
355 let mut exec = ExecResult::from_output(result.code as i64, result.stdout, result.stderr);
356 exec.set_output(result.output);
357 exec.data = result.data.map(json_to_value_no_envelope);
358 exec.content_type = result.content_type;
359 exec.baggage = result.baggage;
360 exec.latch = result.latch;
361 exec
362 }
363}
364
365/// Information about an available tool.
366#[derive(Debug, Clone)]
367pub struct ToolInfo {
368 /// Tool name.
369 pub name: String,
370 /// Tool description.
371 pub description: String,
372 /// Full tool schema.
373 pub schema: ToolSchema,
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn tool_result_from_exec_result_preserves_content_type_and_baggage() {
382 let mut exec = ExecResult::success("hello");
383 exec.content_type = Some("text/markdown".to_string());
384 exec.baggage.insert("traceparent".to_string(), "00-abc-def-01".to_string());
385
386 let tool_result = ToolResult::from(exec);
387 assert_eq!(tool_result.content_type.as_deref(), Some("text/markdown"));
388 assert_eq!(
389 tool_result.baggage.get("traceparent").map(|s| s.as_str()),
390 Some("00-abc-def-01")
391 );
392 }
393
394 #[test]
395 fn tool_result_constructors_default_to_empty_baggage() {
396 let success = ToolResult::success("ok");
397 assert!(success.baggage.is_empty());
398 assert!(success.content_type.is_none());
399
400 let failure = ToolResult::failure(1, "err");
401 assert!(failure.baggage.is_empty());
402 assert!(failure.content_type.is_none());
403 }
404}