Skip to main content

yapi_core/tools/
truncate.rs

1//! Output truncation shared by the tools: by lines and by bytes, from the head or
2//! the tail.
3
4use serde::Serialize;
5
6/// Lines kept by default.
7pub const DEFAULT_MAX_LINES: usize = 2000;
8/// Bytes kept by default: 50 KB.
9pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
10/// Characters kept per grep match line.
11pub const GREP_MAX_LINE_LENGTH: usize = 500;
12
13/// Which limit truncated the output.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TruncatedBy {
17    /// The line limit.
18    Lines,
19    /// The byte limit.
20    Bytes,
21}
22
23/// What was kept, as stored in tool result details.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Truncation {
27    /// The kept text.
28    pub content: String,
29    /// Whether anything was dropped.
30    pub truncated: bool,
31    /// The limit that dropped content.
32    pub truncated_by: Option<TruncatedBy>,
33    /// Lines in the input.
34    pub total_lines: usize,
35    /// Bytes in the input.
36    pub total_bytes: usize,
37    /// Lines kept.
38    pub output_lines: usize,
39    /// Bytes kept.
40    pub output_bytes: usize,
41    /// The only kept line was cut (tail truncation).
42    pub last_line_partial: bool,
43    /// The first line alone exceeds the byte limit (head truncation).
44    pub first_line_exceeds_limit: bool,
45    /// Line limit applied.
46    pub max_lines: usize,
47    /// Byte limit applied.
48    pub max_bytes: usize,
49}
50
51fn count_lines(content: &str) -> Vec<&str> {
52    if content.is_empty() {
53        return Vec::new();
54    }
55    let mut lines: Vec<&str> = content.split('\n').collect();
56    if content.ends_with('\n') {
57        lines.pop();
58    }
59    lines
60}
61
62/// What [`truncate_middle`] kept.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct MiddleTruncation {
65    /// The text, with the middle replaced when truncated.
66    pub content: String,
67    /// Whether the middle was cut.
68    pub truncated: bool,
69    /// Bytes of the original.
70    pub total_bytes: usize,
71    /// Lines of the original.
72    pub total_lines: usize,
73}
74
75/// pi's `truncateMiddle`: keeps the start and end of `content`, half of
76/// `max_bytes` each, with a `…N chars truncated…` marker between, cutting only
77/// at character boundaries.
78pub fn truncate_middle(content: &str, max_bytes: usize) -> MiddleTruncation {
79    let total_lines = count_lines(content).len();
80    let total_bytes = content.len();
81    if total_bytes <= max_bytes {
82        return MiddleTruncation {
83            content: content.to_owned(),
84            truncated: false,
85            total_bytes,
86            total_lines,
87        };
88    }
89    let mut head_end = max_bytes / 2;
90    while head_end > 0 && !content.is_char_boundary(head_end) {
91        head_end -= 1;
92    }
93    let mut tail_start = total_bytes - (max_bytes - max_bytes / 2);
94    while tail_start < total_bytes && !content.is_char_boundary(tail_start) {
95        tail_start += 1;
96    }
97    let removed = content[head_end..tail_start].chars().count();
98    MiddleTruncation {
99        content: format!(
100            "{}…{removed} chars truncated…{}",
101            &content[..head_end],
102            &content[tail_start..]
103        ),
104        truncated: true,
105        total_bytes,
106        total_lines,
107    }
108}
109
110/// pi's size format: `512B`, `1.5KB`, `2.0MB`.
111pub fn format_size(bytes: usize) -> String {
112    if bytes < 1024 {
113        format!("{bytes}B")
114    } else if bytes < 1024 * 1024 {
115        format!("{:.1}KB", bytes as f64 / 1024.0)
116    } else {
117        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
118    }
119}
120
121fn untruncated(content: &str, lines: usize, max_lines: usize, max_bytes: usize) -> Truncation {
122    Truncation {
123        content: content.to_owned(),
124        truncated: false,
125        truncated_by: None,
126        total_lines: lines,
127        total_bytes: content.len(),
128        output_lines: lines,
129        output_bytes: content.len(),
130        last_line_partial: false,
131        first_line_exceeds_limit: false,
132        max_lines,
133        max_bytes,
134    }
135}
136
137/// Keeps whole lines from the start.
138pub fn truncate_head(content: &str, max_lines: usize, max_bytes: usize) -> Truncation {
139    let lines = count_lines(content);
140    let total_bytes = content.len();
141    if lines.len() <= max_lines && total_bytes <= max_bytes {
142        return untruncated(content, lines.len(), max_lines, max_bytes);
143    }
144    let base = Truncation {
145        content: String::new(),
146        truncated: true,
147        truncated_by: Some(TruncatedBy::Bytes),
148        total_lines: lines.len(),
149        total_bytes,
150        output_lines: 0,
151        output_bytes: 0,
152        last_line_partial: false,
153        first_line_exceeds_limit: false,
154        max_lines,
155        max_bytes,
156    };
157    if lines.first().is_some_and(|line| line.len() > max_bytes) {
158        return Truncation {
159            first_line_exceeds_limit: true,
160            ..base
161        };
162    }
163    let mut kept: Vec<&str> = Vec::new();
164    let mut bytes = 0;
165    let mut by = TruncatedBy::Lines;
166    for (index, line) in lines.iter().enumerate().take(max_lines) {
167        let size = line.len() + usize::from(index > 0);
168        if bytes + size > max_bytes {
169            by = TruncatedBy::Bytes;
170            break;
171        }
172        kept.push(line);
173        bytes += size;
174    }
175    if kept.len() >= max_lines && bytes <= max_bytes {
176        by = TruncatedBy::Lines;
177    }
178    let content = kept.join("\n");
179    Truncation {
180        output_lines: kept.len(),
181        output_bytes: content.len(),
182        content,
183        truncated_by: Some(by),
184        ..base
185    }
186}
187
188/// Keeps whole lines from the end. A single over-long last line keeps its tail.
189pub fn truncate_tail(content: &str, max_lines: usize, max_bytes: usize) -> Truncation {
190    let lines = count_lines(content);
191    let total_bytes = content.len();
192    if lines.len() <= max_lines && total_bytes <= max_bytes {
193        return untruncated(content, lines.len(), max_lines, max_bytes);
194    }
195    let mut kept: Vec<String> = Vec::new();
196    let mut bytes = 0;
197    let mut by = TruncatedBy::Lines;
198    let mut partial = false;
199    for line in lines.iter().rev() {
200        if kept.len() >= max_lines {
201            break;
202        }
203        let size = line.len() + usize::from(!kept.is_empty());
204        if bytes + size > max_bytes {
205            by = TruncatedBy::Bytes;
206            if kept.is_empty() {
207                let tail = tail_bytes(line, max_bytes);
208                bytes = tail.len();
209                kept.push(tail.to_owned());
210                partial = true;
211            }
212            break;
213        }
214        kept.push((*line).to_owned());
215        bytes += size;
216    }
217    if kept.len() >= max_lines && bytes <= max_bytes {
218        by = TruncatedBy::Lines;
219    }
220    kept.reverse();
221    let content = kept.join("\n");
222    Truncation {
223        output_lines: kept.len(),
224        output_bytes: content.len(),
225        content,
226        truncated: true,
227        truncated_by: Some(by),
228        total_lines: lines.len(),
229        total_bytes,
230        last_line_partial: partial,
231        first_line_exceeds_limit: false,
232        max_lines,
233        max_bytes,
234    }
235}
236
237/// The last `max_bytes` bytes of `text`, starting at a character boundary.
238pub fn tail_bytes(text: &str, max_bytes: usize) -> &str {
239    if text.len() <= max_bytes {
240        return text;
241    }
242    let mut start = text.len() - max_bytes;
243    while !text.is_char_boundary(start) {
244        start += 1;
245    }
246    &text[start..]
247}
248
249/// Cuts a line to `max_chars` UTF-16 units, as pi counts them.
250pub fn truncate_line(line: &str, max_chars: usize) -> (String, bool) {
251    let units: Vec<u16> = line.encode_utf16().collect();
252    if units.len() <= max_chars {
253        return (line.to_owned(), false);
254    }
255    (
256        format!(
257            "{}... [truncated]",
258            String::from_utf16_lossy(&units[..max_chars])
259        ),
260        true,
261    )
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn head_keeps_whole_lines() {
270        let text = "a\nbb\nccc\n";
271        let result = truncate_head(text, 2, 100);
272        assert_eq!(result.content, "a\nbb");
273        assert_eq!(result.truncated_by, Some(TruncatedBy::Lines));
274        assert_eq!(result.total_lines, 3);
275        let result = truncate_head(text, 10, 4);
276        assert_eq!(result.content, "a\nbb");
277        assert_eq!(result.truncated_by, Some(TruncatedBy::Bytes));
278        assert!(truncate_head("toolong\nx", 10, 3).first_line_exceeds_limit);
279    }
280
281    #[test]
282    fn tail_keeps_end() {
283        let result = truncate_tail("1\n2\n3\n4", 2, 100);
284        assert_eq!(result.content, "3\n4");
285        let result = truncate_tail("short\nwayyyyyyyy too long", 10, 8);
286        assert_eq!(result.content, "too long");
287        assert!(result.last_line_partial);
288        assert_eq!(tail_bytes("aé", 1), "");
289    }
290
291    #[test]
292    fn formats_sizes_like_pi() {
293        assert_eq!(format_size(512), "512B");
294        assert_eq!(format_size(51200), "50.0KB");
295        assert_eq!(format_size(1536 * 1024), "1.5MB");
296    }
297}