1pub const DEFAULT_MAX_LINES: usize = 2000;
7pub const DEFAULT_MAX_BYTES: usize = 50 * 1024;
9pub const GREP_MAX_LINE_LENGTH: usize = 500;
11
12#[derive(Debug, Clone)]
14pub struct TruncationResult {
15 pub content: String,
17 pub truncated: bool,
19 pub truncated_by: TruncatedBy,
21 pub total_lines: usize,
23 pub total_bytes: usize,
25 pub output_lines: usize,
27 pub output_bytes: usize,
29 pub last_line_partial: bool,
31 pub first_line_exceeds_limit: bool,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TruncatedBy {
38 None,
40 Lines,
42 Bytes,
44}
45
46#[derive(Debug, Clone)]
48pub struct TruncationOptions {
49 pub max_lines: Option<usize>,
51 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
64pub 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 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 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 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 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; 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 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
157pub 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 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 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 let mut last_line_partial = false;
189 while output_bytes > max_bytes && !output_lines_vec.is_empty() {
190 #[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 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] = ""; 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
252pub 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
263fn 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
276fn 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 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
291pub 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 let s = "Hello 🌍🌎🌏";
363 let truncated = truncate_to_bytes(s, 11); 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}