Skip to main content

tea_protocol/
change.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use thiserror::Error;
3
4use crate::WebFetchPresentation;
5
6/// Maximum UTF-8 byte length of a workspace-relative changed-file path.
7pub const MAX_CODE_CHANGE_PATH_BYTES: usize = 4_096;
8/// Maximum hunks retained for one code-change presentation.
9pub const MAX_CODE_CHANGE_HUNKS: usize = 32;
10/// Maximum lines retained in one hunk.
11pub const MAX_CODE_CHANGE_LINES_PER_HUNK: usize = 128;
12/// Maximum lines retained across one code-change presentation.
13pub const MAX_CODE_CHANGE_LINES: usize = 1_024;
14/// Maximum UTF-8 byte length of one retained source line.
15pub const MAX_CODE_CHANGE_LINE_BYTES: usize = 1_024;
16/// Maximum UTF-8 byte length of an optional unified patch.
17pub const MAX_CODE_CHANGE_PATCH_BYTES: usize = 64 * 1024;
18
19/// UI-only presentation attached to a tool result or preview.
20///
21/// Successful results persist this data with the tool execution record. Preview
22/// events remain ephemeral. Both forms stay separate from model-visible
23/// tool-result content.
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(tag = "type", content = "value", rename_all = "snake_case")]
26pub enum ToolPresentation {
27    /// A bounded file change with structured diff hunks.
28    CodeChange(CodeChange),
29    /// A bounded normalized client web-fetch result.
30    WebFetch(Box<WebFetchPresentation>),
31}
32
33impl ToolPresentation {
34    /// Returns the structured code change when this is a code-change presentation.
35    #[must_use]
36    pub const fn code_change(&self) -> Option<&CodeChange> {
37        match self {
38            Self::CodeChange(change) => Some(change),
39            Self::WebFetch(_) => None,
40        }
41    }
42
43    /// Returns the normalized fetch result when this is a web-fetch presentation.
44    #[must_use]
45    pub fn web_fetch(&self) -> Option<&WebFetchPresentation> {
46        match self {
47            Self::CodeChange(_) => None,
48            Self::WebFetch(fetch) => Some(fetch.as_ref()),
49        }
50    }
51}
52
53/// Kind of file change represented by a code-change presentation.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum CodeChangeKind {
57    /// A file was created.
58    Create,
59    /// An existing file was updated.
60    Update,
61    /// A file was deleted.
62    Delete,
63}
64
65/// Reason a code-change presentation was deterministically truncated.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum CodeChangeTruncation {
69    /// The configured hunk bound was reached.
70    Hunks,
71    /// The configured line bound was reached.
72    Lines,
73    /// A retained source line exceeded its byte bound.
74    LineBytes,
75    /// The optional unified patch exceeded its byte bound.
76    PatchBytes,
77}
78
79/// Kind of one line in a code-change hunk.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum CodeChangeLineKind {
83    /// Unchanged contextual source.
84    Context,
85    /// A line introduced by the new file content.
86    Addition,
87    /// A line removed from the old file content.
88    Deletion,
89}
90
91/// One bounded, line-numbered source line in a diff hunk.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct CodeChangeLine {
95    kind: CodeChangeLineKind,
96    old_line: Option<u32>,
97    new_line: Option<u32>,
98    text: String,
99}
100
101impl CodeChangeLine {
102    /// Creates a validated diff line.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error when the line kind, numbers, or text exceed the
107    /// stable presentation bounds.
108    pub fn new(
109        kind: CodeChangeLineKind,
110        old_line: Option<u32>,
111        new_line: Option<u32>,
112        text: impl Into<String>,
113    ) -> Result<Self, CodeChangeValidationError> {
114        let line = Self {
115            kind,
116            old_line,
117            new_line,
118            text: text.into(),
119        };
120        line.validate()?;
121        Ok(line)
122    }
123
124    /// Returns the line kind.
125    #[must_use]
126    pub const fn kind(&self) -> CodeChangeLineKind {
127        self.kind
128    }
129
130    /// Returns the one-based old-file line number when applicable.
131    #[must_use]
132    pub const fn old_line(&self) -> Option<u32> {
133        self.old_line
134    }
135
136    /// Returns the one-based new-file line number when applicable.
137    #[must_use]
138    pub const fn new_line(&self) -> Option<u32> {
139        self.new_line
140    }
141
142    /// Returns source text without its line ending.
143    #[must_use]
144    pub fn text(&self) -> &str {
145        &self.text
146    }
147
148    fn validate(&self) -> Result<(), CodeChangeValidationError> {
149        if self.text.len() > MAX_CODE_CHANGE_LINE_BYTES {
150            return Err(CodeChangeValidationError::LineTooLong);
151        }
152        let positive = |line: Option<u32>| line.is_some_and(|line| line > 0);
153        let valid_numbers = match self.kind {
154            CodeChangeLineKind::Context => positive(self.old_line) && positive(self.new_line),
155            CodeChangeLineKind::Addition => self.old_line.is_none() && positive(self.new_line),
156            CodeChangeLineKind::Deletion => positive(self.old_line) && self.new_line.is_none(),
157        };
158        if valid_numbers {
159            Ok(())
160        } else {
161            Err(CodeChangeValidationError::InvalidLineNumbers)
162        }
163    }
164}
165
166/// One grouped hunk of a code-change presentation.
167#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct CodeChangeHunk {
170    old_start: u32,
171    old_lines: u32,
172    new_start: u32,
173    new_lines: u32,
174    lines: Vec<CodeChangeLine>,
175}
176
177impl CodeChangeHunk {
178    /// Creates a validated diff hunk.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error when line counts or contained lines violate the
183    /// durable presentation contract.
184    pub fn new(
185        old_start: u32,
186        old_lines: u32,
187        new_start: u32,
188        new_lines: u32,
189        lines: Vec<CodeChangeLine>,
190    ) -> Result<Self, CodeChangeValidationError> {
191        let hunk = Self {
192            old_start,
193            old_lines,
194            new_start,
195            new_lines,
196            lines,
197        };
198        hunk.validate()?;
199        Ok(hunk)
200    }
201
202    /// Returns the old-file hunk start, with zero permitted for an empty file.
203    #[must_use]
204    pub const fn old_start(&self) -> u32 {
205        self.old_start
206    }
207
208    /// Returns the number of old-file lines covered by this hunk.
209    #[must_use]
210    pub const fn old_lines(&self) -> u32 {
211        self.old_lines
212    }
213
214    /// Returns the new-file hunk start, with zero permitted for an empty file.
215    #[must_use]
216    pub const fn new_start(&self) -> u32 {
217        self.new_start
218    }
219
220    /// Returns the number of new-file lines covered by this hunk.
221    #[must_use]
222    pub const fn new_lines(&self) -> u32 {
223        self.new_lines
224    }
225
226    /// Returns ordered, line-numbered hunk lines.
227    #[must_use]
228    pub fn lines(&self) -> &[CodeChangeLine] {
229        &self.lines
230    }
231
232    fn validate(&self) -> Result<(), CodeChangeValidationError> {
233        if self.lines.is_empty() || self.lines.len() > MAX_CODE_CHANGE_LINES_PER_HUNK {
234            return Err(CodeChangeValidationError::InvalidHunkLines);
235        }
236        for line in &self.lines {
237            line.validate()?;
238        }
239        let old_start = self
240            .lines
241            .iter()
242            .find_map(CodeChangeLine::old_line)
243            .unwrap_or(0);
244        let new_start = self
245            .lines
246            .iter()
247            .find_map(CodeChangeLine::new_line)
248            .unwrap_or(0);
249        let old_lines = u32::try_from(
250            self.lines
251                .iter()
252                .filter(|line| line.old_line().is_some())
253                .count(),
254        )
255        .unwrap_or(u32::MAX);
256        let new_lines = u32::try_from(
257            self.lines
258                .iter()
259                .filter(|line| line.new_line().is_some())
260                .count(),
261        )
262        .unwrap_or(u32::MAX);
263        if (
264            self.old_start,
265            self.old_lines,
266            self.new_start,
267            self.new_lines,
268        ) != (old_start, old_lines, new_start, new_lines)
269        {
270            return Err(CodeChangeValidationError::InconsistentHunkRange);
271        }
272        Ok(())
273    }
274}
275
276/// Bounded, structured presentation of one changed file.
277#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
278pub struct CodeChange {
279    path: String,
280    kind: CodeChangeKind,
281    hunks: Vec<CodeChangeHunk>,
282    truncated: bool,
283    truncation: Option<CodeChangeTruncation>,
284    patch: Option<String>,
285    first_changed_line: Option<u32>,
286}
287
288impl CodeChange {
289    /// Creates a validated structured file change.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error when presentation data exceeds its stable storage
294    /// bounds or its truncation marker is inconsistent.
295    #[allow(clippy::too_many_arguments)]
296    pub fn new(
297        path: impl Into<String>,
298        kind: CodeChangeKind,
299        hunks: Vec<CodeChangeHunk>,
300        truncated: bool,
301        truncation: Option<CodeChangeTruncation>,
302        unified_patch: Option<String>,
303        first_changed_line: Option<u32>,
304    ) -> Result<Self, CodeChangeValidationError> {
305        let change = Self {
306            path: path.into(),
307            kind,
308            hunks,
309            truncated,
310            truncation,
311            patch: unified_patch,
312            first_changed_line,
313        };
314        change.validate()?;
315        Ok(change)
316    }
317
318    /// Returns the workspace-relative path.
319    #[must_use]
320    pub fn path(&self) -> &str {
321        &self.path
322    }
323
324    /// Returns the file-change kind.
325    #[must_use]
326    pub const fn kind(&self) -> CodeChangeKind {
327        self.kind
328    }
329
330    /// Returns ordered structured hunks.
331    #[must_use]
332    pub fn hunks(&self) -> &[CodeChangeHunk] {
333        &self.hunks
334    }
335
336    /// Returns whether any presentation bound caused truncation.
337    #[must_use]
338    pub const fn truncated(&self) -> bool {
339        self.truncated
340    }
341
342    /// Returns the deterministic truncation reason when the change was truncated.
343    #[must_use]
344    pub const fn truncation(&self) -> Option<CodeChangeTruncation> {
345        self.truncation
346    }
347
348    /// Returns the optional bounded unified patch.
349    #[must_use]
350    pub fn patch(&self) -> Option<&str> {
351        self.patch.as_deref()
352    }
353
354    /// Returns the first changed one-based new-file line when available.
355    #[must_use]
356    pub const fn first_changed_line(&self) -> Option<u32> {
357        self.first_changed_line
358    }
359
360    fn validate(&self) -> Result<(), CodeChangeValidationError> {
361        if self.path.is_empty()
362            || self.path.len() > MAX_CODE_CHANGE_PATH_BYTES
363            || self.path.contains('\0')
364        {
365            return Err(CodeChangeValidationError::InvalidPath);
366        }
367        if self.hunks.len() > MAX_CODE_CHANGE_HUNKS {
368            return Err(CodeChangeValidationError::TooManyHunks);
369        }
370        let mut total_lines = 0usize;
371        for hunk in &self.hunks {
372            hunk.validate()?;
373            total_lines = total_lines.saturating_add(hunk.lines.len());
374        }
375        if total_lines > MAX_CODE_CHANGE_LINES {
376            return Err(CodeChangeValidationError::TooManyLines);
377        }
378        if self
379            .patch
380            .as_ref()
381            .is_some_and(|patch| patch.len() > MAX_CODE_CHANGE_PATCH_BYTES)
382        {
383            return Err(CodeChangeValidationError::PatchTooLong);
384        }
385        if self.first_changed_line.is_some_and(|line| line == 0) {
386            return Err(CodeChangeValidationError::InvalidFirstChangedLine);
387        }
388        if self.truncated == self.truncation.is_none() {
389            return Err(CodeChangeValidationError::InconsistentTruncation);
390        }
391        Ok(())
392    }
393}
394
395#[derive(Serialize)]
396#[serde(rename_all = "camelCase")]
397struct CodeChangeDef<'a> {
398    path: &'a str,
399    kind: CodeChangeKind,
400    hunks: &'a [CodeChangeHunk],
401    truncated: bool,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    truncation: Option<CodeChangeTruncation>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    patch: Option<&'a str>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    first_changed_line: Option<u32>,
408}
409
410impl Serialize for CodeChange {
411    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
412    where
413        S: Serializer,
414    {
415        self.validate().map_err(serde::ser::Error::custom)?;
416        CodeChangeDef {
417            path: &self.path,
418            kind: self.kind,
419            hunks: &self.hunks,
420            truncated: self.truncated,
421            truncation: self.truncation,
422            patch: self.patch.as_deref(),
423            first_changed_line: self.first_changed_line,
424        }
425        .serialize(serializer)
426    }
427}
428
429#[derive(Deserialize)]
430#[serde(rename_all = "camelCase", deny_unknown_fields)]
431struct RawCodeChange {
432    path: String,
433    kind: CodeChangeKind,
434    hunks: Vec<CodeChangeHunk>,
435    truncated: bool,
436    #[serde(default)]
437    truncation: Option<CodeChangeTruncation>,
438    #[serde(default)]
439    patch: Option<String>,
440    #[serde(default)]
441    first_changed_line: Option<u32>,
442}
443
444impl<'de> Deserialize<'de> for CodeChange {
445    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
446    where
447        D: Deserializer<'de>,
448    {
449        let raw = RawCodeChange::deserialize(deserializer)?;
450        Self::new(
451            raw.path,
452            raw.kind,
453            raw.hunks,
454            raw.truncated,
455            raw.truncation,
456            raw.patch,
457            raw.first_changed_line,
458        )
459        .map_err(serde::de::Error::custom)
460    }
461}
462
463/// Validation failure for a structured code-change presentation.
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
465pub enum CodeChangeValidationError {
466    /// File path is empty, contains a NUL, or exceeds its byte bound.
467    #[error("code-change path is invalid")]
468    InvalidPath,
469    /// The hunk count exceeds its bound.
470    #[error("code-change has too many hunks")]
471    TooManyHunks,
472    /// The total line count exceeds its bound.
473    #[error("code-change has too many lines")]
474    TooManyLines,
475    /// One hunk has no lines or exceeds its line bound.
476    #[error("code-change hunk line count is invalid")]
477    InvalidHunkLines,
478    /// One line exceeds its byte bound.
479    #[error("code-change line is too long")]
480    LineTooLong,
481    /// One line's kind and old/new line numbers are inconsistent.
482    #[error("code-change line numbers are invalid")]
483    InvalidLineNumbers,
484    /// Hunk start/count fields do not match their retained lines.
485    #[error("code-change hunk range is inconsistent")]
486    InconsistentHunkRange,
487    /// The optional unified patch exceeds its byte bound.
488    #[error("code-change patch is too long")]
489    PatchTooLong,
490    /// The optional first changed line must be one-based.
491    #[error("code-change first changed line is invalid")]
492    InvalidFirstChangedLine,
493    /// Truncation must have exactly one reason when it is set.
494    #[error("code-change truncation state is inconsistent")]
495    InconsistentTruncation,
496}