Skip to main content

weavatrix_edit/
error.rs

1use core::fmt;
2
3use serde::{Deserialize, Serialize};
4
5/// Resource ceilings for diagnostics collected without applying edits.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub struct DiagnosticLimits {
8    /// Maximum number of diagnostics retained in one report.
9    pub max_items: usize,
10    /// Maximum UTF-8 bytes retained from each expected or actual text value.
11    pub max_preview_bytes: usize,
12}
13
14impl Default for DiagnosticLimits {
15    fn default() -> Self {
16        Self {
17            max_items: 32,
18            max_preview_bytes: 256,
19        }
20    }
21}
22
23/// A half-open UTF-8 byte range in the immutable source revision.
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct ByteSpan {
27    pub start: usize,
28    pub end: usize,
29}
30
31impl ByteSpan {
32    #[must_use]
33    pub const fn new(start: usize, end: usize) -> Self {
34        Self { start, end }
35    }
36}
37
38/// A bounded diagnostic rendering of source text.
39#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct TextPreview {
42    byte_len: usize,
43    text: String,
44    truncated: bool,
45}
46
47impl TextPreview {
48    fn new(text: &str, max_bytes: usize) -> Self {
49        let mut end = text.len().min(max_bytes);
50        while !text.is_char_boundary(end) {
51            end -= 1;
52        }
53        Self {
54            byte_len: text.len(),
55            text: text[..end].to_owned(),
56            truncated: end < text.len(),
57        }
58    }
59
60    #[must_use]
61    pub const fn byte_len(&self) -> usize {
62        self.byte_len
63    }
64
65    #[must_use]
66    pub fn text(&self) -> &str {
67        &self.text
68    }
69
70    #[must_use]
71    pub const fn is_truncated(&self) -> bool {
72        self.truncated
73    }
74}
75
76/// Structured evidence for an exact-before mismatch.
77#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct MismatchDetails {
80    source_range: ByteSpan,
81    expected: TextPreview,
82    actual: TextPreview,
83}
84
85/// Bounded result of checking a complete edit set without applying it.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct ValidationReport {
89    diagnostics: Vec<EditError>,
90    total_diagnostics: usize,
91    truncated: bool,
92}
93
94impl ValidationReport {
95    pub(crate) fn new(diagnostics: Vec<EditError>, total_diagnostics: usize) -> Self {
96        Self {
97            truncated: diagnostics.len() < total_diagnostics,
98            diagnostics,
99            total_diagnostics,
100        }
101    }
102
103    /// Returns true when the full checked set had no validation failures.
104    #[must_use]
105    pub const fn is_valid(&self) -> bool {
106        self.total_diagnostics == 0
107    }
108
109    /// Diagnostics retained under [`DiagnosticLimits::max_items`].
110    #[must_use]
111    pub fn diagnostics(&self) -> &[EditError] {
112        &self.diagnostics
113    }
114
115    /// Total failures observed, including omitted diagnostics.
116    #[must_use]
117    pub const fn total_diagnostics(&self) -> usize {
118        self.total_diagnostics
119    }
120
121    /// Returns true when some observed diagnostics were omitted.
122    #[must_use]
123    pub const fn is_truncated(&self) -> bool {
124        self.truncated
125    }
126}
127
128impl MismatchDetails {
129    #[must_use]
130    pub const fn source_range(&self) -> ByteSpan {
131        self.source_range
132    }
133
134    #[must_use]
135    pub const fn expected(&self) -> &TextPreview {
136        &self.expected
137    }
138
139    #[must_use]
140    pub const fn actual(&self) -> &TextPreview {
141        &self.actual
142    }
143}
144
145/// Stable machine-readable failure categories.
146#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
147#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
148pub enum ErrorCode {
149    SchemaMismatch,
150    InvalidPlan,
151    InvalidFile,
152    InvalidEdit,
153    InvalidPath,
154    UnprovenEdit,
155    PlanTooLarge,
156    PositionOutOfRange,
157    BeforeMismatch,
158    OverlappingEdits,
159    OutputTooLarge,
160    ValidationRejected,
161}
162
163impl ErrorCode {
164    /// Returns the wire-compatible status code.
165    #[must_use]
166    pub const fn as_str(self) -> &'static str {
167        match self {
168            Self::SchemaMismatch => "SCHEMA_MISMATCH",
169            Self::InvalidPlan => "INVALID_PLAN",
170            Self::InvalidFile => "INVALID_FILE",
171            Self::InvalidEdit => "INVALID_EDIT",
172            Self::InvalidPath => "INVALID_PATH",
173            Self::UnprovenEdit => "UNPROVEN_EDIT",
174            Self::PlanTooLarge => "PLAN_TOO_LARGE",
175            Self::PositionOutOfRange => "POSITION_OUT_OF_RANGE",
176            Self::BeforeMismatch => "BEFORE_MISMATCH",
177            Self::OverlappingEdits => "OVERLAPPING_EDITS",
178            Self::OutputTooLarge => "OUTPUT_TOO_LARGE",
179            Self::ValidationRejected => "VALIDATION_REJECTED",
180        }
181    }
182}
183
184impl fmt::Display for ErrorCode {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        formatter.write_str(self.as_str())
187    }
188}
189
190/// A fail-closed validation or application error.
191#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
192#[serde(rename_all = "camelCase")]
193pub struct EditError {
194    code: ErrorCode,
195    message: String,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    file_index: Option<usize>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    edit_index: Option<usize>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    related_edit_index: Option<usize>,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    mismatch: Option<Box<MismatchDetails>>,
204}
205
206impl EditError {
207    pub(crate) fn new(code: ErrorCode, message: impl Into<String>) -> Self {
208        Self {
209            code,
210            message: message.into(),
211            file_index: None,
212            edit_index: None,
213            related_edit_index: None,
214            mismatch: None,
215        }
216    }
217
218    pub(crate) fn before_mismatch(
219        source_range: ByteSpan,
220        expected: &str,
221        actual: &str,
222        limits: DiagnosticLimits,
223    ) -> Self {
224        Self {
225            code: ErrorCode::BeforeMismatch,
226            message: "source text does not match the exact before guard".to_owned(),
227            file_index: None,
228            edit_index: None,
229            related_edit_index: None,
230            mismatch: Some(Box::new(MismatchDetails {
231                source_range,
232                expected: TextPreview::new(expected, limits.max_preview_bytes),
233                actual: TextPreview::new(actual, limits.max_preview_bytes),
234            })),
235        }
236    }
237
238    pub(crate) const fn at_file(mut self, file_index: usize) -> Self {
239        self.file_index = Some(file_index);
240        self
241    }
242
243    pub(crate) const fn at_edit(mut self, edit_index: usize) -> Self {
244        self.edit_index = Some(edit_index);
245        self
246    }
247
248    pub(crate) const fn with_related_edit(mut self, edit_index: usize) -> Self {
249        self.related_edit_index = Some(edit_index);
250        self
251    }
252
253    /// Stable machine-readable code.
254    #[must_use]
255    pub const fn code(&self) -> ErrorCode {
256        self.code
257    }
258
259    /// Human-readable diagnostic. Consumers should branch on [`Self::code`].
260    #[must_use]
261    pub fn message(&self) -> &str {
262        &self.message
263    }
264
265    /// File index within the plan, when applicable.
266    #[must_use]
267    pub const fn file_index(&self) -> Option<usize> {
268        self.file_index
269    }
270
271    /// Edit index within the file or input slice, when applicable.
272    #[must_use]
273    pub const fn edit_index(&self) -> Option<usize> {
274        self.edit_index
275    }
276
277    /// The other edit participating in an overlap, when applicable.
278    #[must_use]
279    pub const fn related_edit_index(&self) -> Option<usize> {
280        self.related_edit_index
281    }
282
283    /// Bounded expected/actual evidence for [`ErrorCode::BeforeMismatch`].
284    #[must_use]
285    pub fn mismatch(&self) -> Option<&MismatchDetails> {
286        self.mismatch.as_deref()
287    }
288}
289
290impl fmt::Display for EditError {
291    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
292        write!(formatter, "{}: {}", self.code, self.message)
293    }
294}
295
296impl std::error::Error for EditError {}