Skip to main content

newt_coder/
error.rs

1//! Error type for the newt-coder plugin.
2//!
3//! Mirrors the failure-mode taxonomy in
4//! `~/workspaces/knowledge/board/drake/2026-05-29_newt-coder-failure-mode-taxonomy.md`:
5//! workspace scan failure, prompt-too-large guard trip, malformed
6//! emission, file-write failure, and inference-backend errors are
7//! distinct variants so callers (and tests) can pattern-match.
8
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12pub enum CoderError {
13    #[error("workspace error: {0}")]
14    Workspace(String),
15    #[error("prompt too large: {actual} chars > cap {cap}")]
16    PromptTooLarge { actual: usize, cap: usize },
17    #[error("emission malformed: {0}")]
18    BadEmission(String),
19    #[error("file write failed: {0}")]
20    FileWrite(String),
21    /// The emitted body for `path` was empty or whitespace-only.
22    ///
23    /// A whole-file emission must contain something to write; an empty
24    /// body is never a legitimate rewrite. The `file write failed:`
25    /// prefix is load-bearing — the ACP worker and existing call sites
26    /// match on it.
27    #[error("file write failed: empty emission for '{path}'")]
28    EmptyEmission { path: String },
29    /// The emitted body for `path` looked like a unified diff rather
30    /// than the complete file contents (its first non-blank line begins
31    /// with `--- `, `+++ `, or `@@`).
32    #[error("file write failed: emission for '{path}' looks like a diff, not a whole file")]
33    LooksLikeDiff { path: String },
34    /// The emitted body for `path` still began with a leaked `FILE:`
35    /// marker as its first non-blank line (defense in depth in case the
36    /// parser did not strip it).
37    #[error("file write failed: emission for '{path}' still begins with a leaked FILE: marker")]
38    LeakedMarker { path: String },
39    #[error("inference: {0}")]
40    Inference(String),
41    /// The peer's signed [`Caveats`](newt_core::Caveats) deny the tool
42    /// call this dispatch would have made.
43    ///
44    /// `kind` names the axis that refused — one of `"fs_read"`,
45    /// `"fs_write"`, `"net"`, `"exec"`, or `"max_calls"` — and `target`
46    /// names the concrete item the dispatch tried to touch (the path, the
47    /// host, or `"<turn>"` for the `max_calls` budget). The pair is
48    /// load-bearing for arbiter scorecards: every `CapabilityDenied`
49    /// becomes a scrubbed-sortie reason, not a model failure.
50    #[error("capability denied: {kind} does not permit '{target}'")]
51    CapabilityDenied { kind: &'static str, target: String },
52    #[error("io: {0}")]
53    Io(#[from] std::io::Error),
54}
55
56pub type Result<T> = std::result::Result<T, CoderError>;
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn workspace_error_renders() {
64        let e = CoderError::Workspace("missing dir".to_string());
65        assert!(e.to_string().contains("missing dir"));
66    }
67
68    #[test]
69    fn prompt_too_large_renders() {
70        let e = CoderError::PromptTooLarge {
71            actual: 100,
72            cap: 50,
73        };
74        let s = e.to_string();
75        assert!(s.contains("100"));
76        assert!(s.contains("50"));
77    }
78
79    #[test]
80    fn bad_emission_renders() {
81        let e = CoderError::BadEmission("no FILE: header".to_string());
82        assert!(e.to_string().contains("no FILE: header"));
83    }
84
85    #[test]
86    fn file_write_renders() {
87        let e = CoderError::FileWrite("permission denied".to_string());
88        assert!(e.to_string().contains("permission denied"));
89    }
90
91    #[test]
92    fn empty_emission_renders_with_prefix() {
93        let e = CoderError::EmptyEmission {
94            path: "src/lib.rs".to_string(),
95        };
96        let s = e.to_string();
97        assert!(s.starts_with("file write failed:"), "got: {s}");
98        assert!(s.contains("src/lib.rs"));
99    }
100
101    #[test]
102    fn looks_like_diff_renders_with_prefix() {
103        let e = CoderError::LooksLikeDiff {
104            path: "src/lib.rs".to_string(),
105        };
106        let s = e.to_string();
107        assert!(s.starts_with("file write failed:"), "got: {s}");
108        assert!(s.contains("diff"));
109    }
110
111    #[test]
112    fn leaked_marker_renders_with_prefix() {
113        let e = CoderError::LeakedMarker {
114            path: "src/lib.rs".to_string(),
115        };
116        let s = e.to_string();
117        assert!(s.starts_with("file write failed:"), "got: {s}");
118        assert!(s.contains("FILE:"));
119    }
120
121    #[test]
122    fn inference_renders() {
123        let e = CoderError::Inference("backend offline".to_string());
124        assert!(e.to_string().contains("backend offline"));
125    }
126
127    #[test]
128    fn io_error_converts() {
129        let io: std::io::Error = std::io::Error::new(std::io::ErrorKind::NotFound, "x");
130        let e: CoderError = io.into();
131        assert!(matches!(e, CoderError::Io(_)));
132    }
133
134    #[test]
135    fn capability_denied_renders_kind_and_target() {
136        let e = CoderError::CapabilityDenied {
137            kind: "fs_write",
138            target: "forbidden.rs".to_string(),
139        };
140        let s = e.to_string();
141        assert!(s.contains("capability denied"));
142        assert!(s.contains("fs_write"));
143        assert!(s.contains("forbidden.rs"));
144    }
145
146    #[test]
147    fn capability_denied_kinds_match_dispatch_axes() {
148        for kind in ["fs_read", "fs_write", "net", "exec", "max_calls"] {
149            let e = CoderError::CapabilityDenied {
150                kind,
151                target: "x".to_string(),
152            };
153            assert!(e.to_string().contains(kind));
154        }
155    }
156}