Skip to main content

oxicode_agent/tools/
truncate.rs

1//! Output truncation utilities for tool results
2//! Limits tool output by two independent limits (lines + bytes), whichever hits first.
3//! UTF-8 safe: truncates at valid character boundaries.
4
5/// Default max output lines
6pub const DEFAULT_MAX_LINES: usize = 2000;
7/// Default max output bytes (50KB)
8pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
9/// Max line length for grep output
10pub const GREP_MAX_LINE_LENGTH: usize = 500;
11
12/// Result of a truncation operation
13#[derive(Debug, Clone)]
14pub struct TruncationResult {
15    /// The truncated content
16    pub content: String,
17    /// Whether truncation occurred
18    pub truncated: bool,
19    /// How truncation was applied
20    pub truncated_by: TruncatedBy,
21    /// Total lines in original content
22    pub total_lines: usize,
23    /// Total bytes in original content
24    pub total_bytes: usize,
25    /// Lines in output
26    pub output_lines: usize,
27    /// Bytes in output
28    pub output_bytes: usize,
29    /// Whether the first line of output is a partial line
30    pub last_line_partial: bool,
31    /// Whether the first line alone exceeds the byte limit
32    pub first_line_exceeds_limit: bool,
33}
34
35/// How truncation was applied
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TruncatedBy {
38    /// No truncation needed
39    None,
40    /// Truncated by line count
41    Lines,
42    /// Truncated by byte count
43    Bytes,
44}
45
46/// Options for truncation
47#[derive(Debug, Clone)]
48pub struct TruncationOptions {
49    /// Maximum number of lines to keep
50    pub max_lines: Option<usize>,
51    /// Maximum number of bytes to keep
52    pub max_bytes: Option<usize>,
53}
54
55impl Default for TruncationOptions {
56    fn default() -> Self {
57        Self {
58            max_lines: Some(DEFAULT_MAX_LINES),
59            max_bytes: Some(DEFAULT_MAX_BYTES),
60        }
61    }
62}
63
64/// Truncate content from the head (keep first N lines/bytes).
65/// Never produces partial lines unless the first line itself exceeds byte limit.
66pub fn truncate_head(content: &str, options: &TruncationOptions) -> TruncationResult {
67    let max_lines = options.max_lines.unwrap_or(usize::MAX);
68    let max_bytes = options.max_bytes.unwrap_or(usize::MAX);
69
70    let total_bytes = content.len();
71    let lines: Vec<&str> = content.lines().collect();
72    let total_lines = lines.len();
73
74    // Check if already within limits
75    if total_lines <= max_lines && total_bytes <= max_bytes {
76        return TruncationResult {
77            content: content.to_string(),
78            truncated: false,
79            truncated_by: TruncatedBy::None,
80            total_lines,
81            total_bytes,
82            output_lines: total_lines,
83            output_bytes: total_bytes,
84            last_line_partial: false,
85            first_line_exceeds_limit: false,
86        };
87    }
88
89    // Check if first line exceeds byte limit
90    let first_line_bytes = lines.first().map(|l| l.len()).unwrap_or(0);
91    let first_line_exceeds_limit = first_line_bytes > max_bytes;
92
93    if first_line_exceeds_limit {
94        // Can only include a partial first line
95        let truncated_line = truncate_to_bytes(lines[0], max_bytes);
96        let output_bytes = truncated_line.len();
97        return TruncationResult {
98            content: format!(
99                "{}\n\n... [truncated: first line exceeds byte limit]",
100                truncated_line
101            ),
102            truncated: true,
103            truncated_by: TruncatedBy::Bytes,
104            total_lines,
105            total_bytes,
106            output_lines: 1,
107            output_bytes,
108            last_line_partial: false,
109            first_line_exceeds_limit: true,
110        };
111    }
112
113    // Accumulate lines until we hit either limit
114    let mut output_lines_vec: Vec<&str> = Vec::new();
115    let mut output_bytes = 0;
116    let mut truncated_by = TruncatedBy::None;
117
118    for line in &lines {
119        let line_bytes = line.len() + 1; // +1 for newline
120        if output_lines_vec.len() >= max_lines {
121            truncated_by = TruncatedBy::Lines;
122            break;
123        }
124        if output_bytes + line_bytes > max_bytes {
125            truncated_by = TruncatedBy::Bytes;
126            break;
127        }
128        output_lines_vec.push(line);
129        output_bytes += line_bytes;
130    }
131
132    let output_lines_count = output_lines_vec.len();
133    let mut content = output_lines_vec.join("\n");
134
135    // Add truncation notice
136    let remaining_lines = total_lines.saturating_sub(output_lines_count);
137    let remaining_bytes = total_bytes.saturating_sub(output_bytes);
138    content.push_str(&format!(
139        "\n\n... [truncated: {} lines, {} bytes remaining]",
140        remaining_lines,
141        format_bytes(remaining_bytes)
142    ));
143
144    TruncationResult {
145        content,
146        truncated: true,
147        truncated_by,
148        total_lines,
149        total_bytes,
150        output_lines: output_lines_count,
151        output_bytes,
152        last_line_partial: false,
153        first_line_exceeds_limit,
154    }
155}
156
157/// Truncate content from the tail (keep last N lines/bytes).
158/// May produce a partial first line if the last line of original exceeds byte limit.
159pub fn truncate_tail(content: &str, options: &TruncationOptions) -> TruncationResult {
160    let max_lines = options.max_lines.unwrap_or(usize::MAX);
161    let max_bytes = options.max_bytes.unwrap_or(usize::MAX);
162
163    let total_bytes = content.len();
164    let lines: Vec<&str> = content.lines().collect();
165    let total_lines = lines.len();
166
167    // Check if already within limits
168    if total_lines <= max_lines && total_bytes <= max_bytes {
169        return TruncationResult {
170            content: content.to_string(),
171            truncated: false,
172            truncated_by: TruncatedBy::None,
173            total_lines,
174            total_bytes,
175            output_lines: total_lines,
176            output_bytes: total_bytes,
177            last_line_partial: false,
178            first_line_exceeds_limit: false,
179        };
180    }
181
182    // Take lines from the end
183    let start = total_lines.saturating_sub(max_lines);
184    let mut output_lines_vec: Vec<&str> = lines[start..].to_vec();
185    let mut output_bytes: usize = output_lines_vec.iter().map(|l| l.len() + 1).sum();
186
187    // If still over byte limit, trim from the front
188    let mut last_line_partial = false;
189    while output_bytes > max_bytes && !output_lines_vec.is_empty() {
190        // SAFETY: guarded by the `is_empty()` check immediately above.
191        #[allow(clippy::expect_used)]
192        let first = output_lines_vec
193            .first()
194            .expect("output_lines_vec non-empty after is_empty check");
195        let first_bytes = first.len() + 1;
196
197        if output_bytes - first_bytes <= max_bytes {
198            // Partial first line - truncate it
199            let keep_bytes = max_bytes.saturating_sub(output_bytes - first_bytes);
200            if keep_bytes > 0 {
201                let truncated = truncate_string_to_bytes_from_end(first, keep_bytes);
202                output_lines_vec[0] = ""; // Will be replaced
203                let content = format!(
204                    "... [truncated]\n{}\n{}",
205                    truncated,
206                    output_lines_vec[1..].join("\n")
207                );
208                let output_lines = output_lines_vec.len();
209                return TruncationResult {
210                    content,
211                    truncated: true,
212                    truncated_by: TruncatedBy::Bytes,
213                    total_lines,
214                    total_bytes,
215                    output_lines,
216                    output_bytes: output_bytes.saturating_sub(first_bytes) + truncated.len(),
217                    last_line_partial: true,
218                    first_line_exceeds_limit: false,
219                };
220            }
221        }
222
223        output_bytes -= first_bytes;
224        output_lines_vec.remove(0);
225        last_line_partial = true;
226    }
227
228    let output_lines_count = output_lines_vec.len();
229    let mut result = output_lines_vec.join("\n");
230
231    if last_line_partial || start > 0 {
232        result = format!("... [truncated]\n{}", result);
233    }
234
235    TruncationResult {
236        content: result,
237        truncated: true,
238        truncated_by: if output_bytes >= max_bytes {
239            TruncatedBy::Bytes
240        } else {
241            TruncatedBy::Lines
242        },
243        total_lines,
244        total_bytes,
245        output_lines: output_lines_count,
246        output_bytes,
247        last_line_partial,
248        first_line_exceeds_limit: false,
249    }
250}
251
252/// Truncate a single line to a maximum character length.
253/// Appends "... \[truncated\]" if truncated.
254pub fn truncate_line(line: &str, max_chars: usize) -> String {
255    if line.chars().count() <= max_chars {
256        return line.to_string();
257    }
258
259    let truncated: String = line.chars().take(max_chars).collect();
260    format!("{}... [truncated]", truncated)
261}
262
263/// Truncate a string to a maximum number of bytes, respecting UTF-8 boundaries.
264fn truncate_to_bytes(s: &str, max_bytes: usize) -> &str {
265    if s.len() <= max_bytes {
266        return s;
267    }
268
269    let mut end = max_bytes;
270    while end > 0 && !s.is_char_boundary(end) {
271        end -= 1;
272    }
273    &s[..end]
274}
275
276/// Truncate string to bytes from the end (keeping the tail portion).
277fn truncate_string_to_bytes_from_end(s: &str, max_bytes: usize) -> String {
278    if s.len() <= max_bytes {
279        return s.to_string();
280    }
281
282    let start = s.len() - max_bytes;
283    // Find valid UTF-8 boundary
284    let mut start = start;
285    while start < s.len() && !s.is_char_boundary(start) {
286        start += 1;
287    }
288    s[start..].to_string()
289}
290
291/// Format bytes for human-readable display
292pub fn format_bytes(bytes: usize) -> String {
293    if bytes < 1024 {
294        format!("{}B", bytes)
295    } else if bytes < 1024 * 1024 {
296        format!("{:.1}KB", bytes as f64 / 1024.0)
297    } else {
298        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_truncate_head_within_limits() {
308        let content = "line1\nline2\nline3";
309        let result = truncate_head(
310            content,
311            &TruncationOptions {
312                max_lines: Some(10),
313                max_bytes: Some(1000),
314            },
315        );
316        assert!(!result.truncated);
317        assert_eq!(result.total_lines, 3);
318    }
319
320    #[test]
321    fn test_truncate_head_by_lines() {
322        let content = "line1\nline2\nline3\nline4\nline5";
323        let result = truncate_head(
324            content,
325            &TruncationOptions {
326                max_lines: Some(2),
327                max_bytes: Some(1000),
328            },
329        );
330        assert!(result.truncated);
331        assert_eq!(result.output_lines, 2);
332        assert!(result.content.contains("truncated"));
333    }
334
335    #[test]
336    fn test_truncate_head_by_bytes() {
337        let content = "short\nthis is a much longer line that should push us over the byte limit";
338        let result = truncate_head(
339            content,
340            &TruncationOptions {
341                max_lines: Some(100),
342                max_bytes: Some(20),
343            },
344        );
345        assert!(result.truncated);
346    }
347
348    #[test]
349    fn test_truncate_line_short() {
350        assert_eq!(truncate_line("hello", 10), "hello");
351    }
352
353    #[test]
354    fn test_truncate_line_long() {
355        let result = truncate_line("hello world this is a very long line", 11);
356        assert_eq!(result, "hello world... [truncated]");
357    }
358
359    #[test]
360    fn test_truncate_to_bytes_utf8() {
361        // Multi-byte characters (emoji)
362        let s = "Hello 🌍🌎🌏";
363        let truncated = truncate_to_bytes(s, 11); // "Hello " = 6 + "🌍" = 4 → 10 bytes
364        assert_eq!(truncated, "Hello 🌍");
365    }
366
367    #[test]
368    fn test_format_bytes() {
369        assert_eq!(format_bytes(500), "500B");
370        assert_eq!(format_bytes(1024), "1.0KB");
371        assert_eq!(format_bytes(1024 * 1024), "1.0MB");
372    }
373
374    #[test]
375    fn test_truncate_tail_within_limits() {
376        let content = "line1\nline2\nline3";
377        let result = truncate_tail(
378            content,
379            &TruncationOptions {
380                max_lines: Some(10),
381                max_bytes: Some(1000),
382            },
383        );
384        assert!(!result.truncated);
385    }
386
387    #[test]
388    fn test_truncate_tail_by_lines() {
389        let content = "line1\nline2\nline3\nline4\nline5";
390        let result = truncate_tail(
391            content,
392            &TruncationOptions {
393                max_lines: Some(2),
394                max_bytes: Some(1000),
395            },
396        );
397        assert!(result.truncated);
398        assert!(result.content.contains("line4"));
399        assert!(result.content.contains("line5"));
400    }
401}