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::{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#[non_exhaustive]
234#[derive(Debug, Clone)]
235pub struct ToolResult {
236    /// Exit code (0 = success).
237    pub code: i32,
238    /// Standard output.
239    pub stdout: String,
240    /// Standard error.
241    pub stderr: String,
242    /// Structured data (if any).
243    pub data: Option<JsonValue>,
244    /// Structured output data for rendering (preserved from ExecResult).
245    pub output: Option<OutputData>,
246    /// True if the output limiter capped this result (propagated from
247    /// `ExecResult`). See `ExecResult.did_spill` for the full semantics (disk
248    /// spill vs. in-memory truncation, exit code remap to 3).
249    pub did_spill: bool,
250    /// The command's original exit code before spill logic overwrote it
251    /// (propagated from `ExecResult`). Present only when `did_spill` is true
252    /// and `code` was changed. See `ExecResult.original_code`.
253    pub original_code: Option<i64>,
254    /// MIME content type hint (propagated from ExecResult).
255    pub content_type: Option<String>,
256    /// Opaque key-value context (propagated from ExecResult).
257    pub baggage: BTreeMap<String, String>,
258    /// A pending confirmation-latch request (propagated from ExecResult), so a
259    /// backend-tool latch survives the ExecResult↔ToolResult roundtrip. Its own
260    /// typed field — never folded into `data`. Boxed to match `ExecResult.latch`
261    /// (keeps the roundtrip a direct move; see that field for why).
262    pub latch: Option<Box<LatchRequest>>,
263}
264
265impl ToolResult {
266    /// Create a successful result.
267    pub fn success(stdout: impl Into<String>) -> Self {
268        Self {
269            code: 0,
270            stdout: stdout.into(),
271            stderr: String::new(),
272            data: None,
273            output: None,
274            did_spill: false,
275            original_code: None,
276            content_type: None,
277            baggage: BTreeMap::new(),
278            latch: None,
279        }
280    }
281
282    /// Create a failed result.
283    pub fn failure(code: i32, stderr: impl Into<String>) -> Self {
284        Self {
285            code,
286            stdout: String::new(),
287            stderr: stderr.into(),
288            data: None,
289            output: None,
290            did_spill: false,
291            original_code: None,
292            content_type: None,
293            baggage: BTreeMap::new(),
294            latch: None,
295        }
296    }
297
298    /// Create a result with structured data.
299    pub fn with_data(stdout: impl Into<String>, data: JsonValue) -> Self {
300        Self {
301            code: 0,
302            stdout: stdout.into(),
303            stderr: String::new(),
304            data: Some(data),
305            output: None,
306            did_spill: false,
307            original_code: None,
308            content_type: None,
309            baggage: BTreeMap::new(),
310            latch: None,
311        }
312    }
313
314    /// Check if the tool execution succeeded.
315    pub fn ok(&self) -> bool {
316        self.code == 0
317    }
318
319    /// Set the structured output-data payload, returning self for chaining.
320    pub fn with_output(mut self, output: Option<OutputData>) -> Self {
321        self.output = output;
322        self
323    }
324
325    /// Set the content-type hint, returning self for chaining.
326    pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
327        self.content_type = Some(ct.into());
328        self
329    }
330
331    /// Replace the baggage map, returning self for chaining.
332    pub fn with_baggage(mut self, baggage: BTreeMap<String, String>) -> Self {
333        self.baggage = baggage;
334        self
335    }
336
337    /// Set the pending confirmation-latch request, returning self for chaining.
338    pub fn with_latch(mut self, latch: Option<LatchRequest>) -> Self {
339        self.latch = latch.map(Box::new);
340        self
341    }
342
343    /// Set the spill flag, returning self for chaining.
344    pub fn with_did_spill(mut self, did_spill: bool) -> Self {
345        self.did_spill = did_spill;
346        self
347    }
348
349    /// Set the pre-spill original exit code, returning self for chaining.
350    pub fn with_original_code(mut self, original_code: Option<i64>) -> Self {
351        self.original_code = original_code;
352        self
353    }
354}
355
356impl From<ExecResult> for ToolResult {
357    fn from(mut exec: ExecResult) -> Self {
358        // Saturating cast: codes outside i32 range clamp to i32::MIN/MAX
359        let code = exec.code.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
360
361        // HAZARD: `text_out()` is the infallible/lossy decoder — a binary
362        // `OutputPayload::Bytes` (already produced by `cat`/`head`/`tail`/
363        // `base64 -d`/`xxd -r`/`dd`/`tee`/external commands, so this is real
364        // today, not merely a future risk) gets its invalid-UTF-8 bytes
365        // replaced with U+FFFD here. This `From` impl is infallible by
366        // signature, so it cannot fail loud the way `try_text_out()` does —
367        // this is an accepted, deliberate exception, not an oversight. The
368        // binary survives losslessly in the preserved `output: Option<OutputData>`
369        // field below; a structured/binary-aware consumer MUST read `output`,
370        // never `stdout`, to avoid the lossy decode. If a future embedder seam
371        // needs a fallible conversion here, add a `TryFrom` (or a bytes-carrying
372        // field) rather than changing this `From`'s behavior. See
373        // `docs/binary-data.md`.
374        let stdout = exec.text_out().into_owned();
375        let output = exec.take_output();
376
377        // Convert ast::Value to serde_json::Value if present
378        let data = exec.data.map(|v| value_to_json(&v));
379
380        Self {
381            code,
382            stdout,
383            stderr: exec.err,
384            data,
385            output,
386            did_spill: exec.did_spill,
387            original_code: exec.original_code,
388            content_type: exec.content_type,
389            baggage: exec.baggage,
390            latch: exec.latch,
391        }
392    }
393}
394
395impl From<ToolResult> for ExecResult {
396    /// The symmetric peer of `From<ExecResult> for ToolResult` above — every
397    /// field that direction preserves, this direction must preserve too, or a
398    /// backend-registered tool's structured `data`/`content_type`/`baggage`
399    /// silently vanishes crossing back into the kernel (the embedder seam:
400    /// `x=$(embedder_tool)` and `for r in $(embedder_tool)` need `.data` to
401    /// see typed results, not just stdout text).
402    ///
403    /// `data` uses [`json_to_value_no_envelope`] rather than the internal
404    /// round-trip conversion: a backend tool's JSON is external input, so an
405    /// object shaped like the byte envelope must stay a plain record, never
406    /// silently auto-decode to `Value::Bytes`.
407    fn from(result: ToolResult) -> Self {
408        let mut exec = ExecResult::from_output(result.code as i64, result.stdout, result.stderr);
409        exec.set_output(result.output);
410        exec.data = result.data.map(json_to_value_no_envelope);
411        exec.did_spill = result.did_spill;
412        exec.original_code = result.original_code;
413        exec.content_type = result.content_type;
414        exec.baggage = result.baggage;
415        exec.latch = result.latch;
416        exec
417    }
418}
419
420/// Information about an available tool.
421#[derive(Debug, Clone)]
422pub struct ToolInfo {
423    /// Tool name.
424    pub name: String,
425    /// Tool description.
426    pub description: String,
427    /// Full tool schema.
428    pub schema: ToolSchema,
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn tool_result_from_exec_result_preserves_content_type_and_baggage() {
437        let mut exec = ExecResult::success("hello");
438        exec.content_type = Some("text/markdown".to_string());
439        exec.baggage.insert("traceparent".to_string(), "00-abc-def-01".to_string());
440
441        let tool_result = ToolResult::from(exec);
442        assert_eq!(tool_result.content_type.as_deref(), Some("text/markdown"));
443        assert_eq!(
444            tool_result.baggage.get("traceparent").map(|s| s.as_str()),
445            Some("00-abc-def-01")
446        );
447    }
448
449    #[test]
450    fn tool_result_constructors_default_to_empty_baggage() {
451        let success = ToolResult::success("ok");
452        assert!(success.baggage.is_empty());
453        assert!(success.content_type.is_none());
454
455        let failure = ToolResult::failure(1, "err");
456        assert!(failure.baggage.is_empty());
457        assert!(failure.content_type.is_none());
458    }
459
460    #[test]
461    fn tool_result_constructors_default_did_spill_and_original_code() {
462        // GH #93 item 3 baseline: the new fields must not silently drift the
463        // existing constructors' defaults.
464        assert!(!ToolResult::success("ok").did_spill);
465        assert!(ToolResult::success("ok").original_code.is_none());
466        assert!(!ToolResult::failure(1, "err").did_spill);
467        assert!(!ToolResult::with_data("ok", serde_json::json!(1)).did_spill);
468    }
469
470    #[test]
471    fn tool_result_from_exec_result_preserves_did_spill_and_original_code() {
472        // GH #93 item 3: ExecResult -> ToolResult must not drop the spill
473        // metadata — an embedder reading a ToolResult off this seam needs to
474        // know the output was capped and what the code was before the remap.
475        let mut exec = ExecResult::success("hello");
476        exec.did_spill = true;
477        exec.original_code = Some(0);
478
479        let tool_result = ToolResult::from(exec);
480        assert!(tool_result.did_spill);
481        assert_eq!(tool_result.original_code, Some(0));
482    }
483
484    #[test]
485    fn exec_result_from_tool_result_preserves_did_spill_and_original_code() {
486        // The reverse direction: a backend tool that reports a capped result
487        // (e.g. an embedder fronting its own output limiter) must have that
488        // survive back into the kernel's ExecResult.
489        let tool_result = ToolResult::success("hello")
490            .with_did_spill(true)
491            .with_original_code(Some(5));
492
493        let exec = ExecResult::from(tool_result);
494        assert!(exec.did_spill);
495        assert_eq!(exec.original_code, Some(5));
496    }
497
498    #[test]
499    fn tool_result_builder_setters_chain() {
500        // Exercises the ergonomic construction surface added for
501        // `#[non_exhaustive]`: every field not covered by success/failure/
502        // with_data gets a with_* setter, matching the ExecResult style.
503        let mut baggage = BTreeMap::new();
504        baggage.insert("k".to_string(), "v".to_string());
505
506        let latch = LatchRequest {
507            nonce: "n".to_string(),
508            command: "rm".to_string(),
509            paths: vec!["f".to_string()],
510            hint: "rm --confirm=n f".to_string(),
511            tool: "rm".to_string(),
512            argv: vec!["f".to_string()],
513            ttl: 60,
514            job_id: None,
515        };
516
517        let result = ToolResult::success("hi")
518            .with_output(Some(OutputData::text("hi")))
519            .with_content_type("text/plain")
520            .with_baggage(baggage.clone())
521            .with_latch(Some(latch.clone()))
522            .with_did_spill(true)
523            .with_original_code(Some(2));
524
525        assert!(result.output.is_some());
526        assert_eq!(result.content_type.as_deref(), Some("text/plain"));
527        assert_eq!(result.baggage, baggage);
528        assert_eq!(result.latch.as_deref(), Some(&latch));
529        assert!(result.did_spill);
530        assert_eq!(result.original_code, Some(2));
531    }
532}