1use serde::Serialize;
5
6pub const DEFAULT_MAX_LINES: usize = 2000;
8pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
10pub const GREP_MAX_LINE_LENGTH: usize = 500;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TruncatedBy {
17 Lines,
19 Bytes,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Truncation {
27 pub content: String,
29 pub truncated: bool,
31 pub truncated_by: Option<TruncatedBy>,
33 pub total_lines: usize,
35 pub total_bytes: usize,
37 pub output_lines: usize,
39 pub output_bytes: usize,
41 pub last_line_partial: bool,
43 pub first_line_exceeds_limit: bool,
45 pub max_lines: usize,
47 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#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct MiddleTruncation {
65 pub content: String,
67 pub truncated: bool,
69 pub total_bytes: usize,
71 pub total_lines: usize,
73}
74
75pub 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
110pub 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
137pub 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
188pub 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
237pub 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
249pub 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}