1use std::fs::{self, File};
2use std::io::{self, Read, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5pub const DISK_LIMIT_BYTES: u64 = 100 * 1024 * 1024;
6
7#[derive(Debug, Clone, Copy)]
8pub enum StreamKind {
9 Stdout,
10 Stderr,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BoundedRead {
15 pub text: String,
16 pub truncated: bool,
17 pub total_bytes: u64,
18}
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct DiskTruncation {
22 pub stdout_prefix_bytes: u64,
23 pub stderr_prefix_bytes: u64,
24 pub combined_prefix_bytes: u64,
25}
26
27impl DiskTruncation {
28 pub fn total_prefix_bytes(self) -> u64 {
29 self.stdout_prefix_bytes
30 .saturating_add(self.stderr_prefix_bytes)
31 .saturating_add(self.combined_prefix_bytes)
32 }
33}
34
35#[derive(Debug, Clone)]
36pub enum BgBuffer {
37 Pipes {
38 stdout_path: PathBuf,
39 stderr_path: PathBuf,
40 },
41 Pty {
42 combined_path: PathBuf,
43 },
44}
45
46impl BgBuffer {
47 pub fn new(stdout_path: PathBuf, stderr_path: PathBuf) -> Self {
48 Self::Pipes {
49 stdout_path,
50 stderr_path,
51 }
52 }
53
54 pub fn pty(combined_path: PathBuf) -> Self {
55 Self::Pty { combined_path }
56 }
57
58 pub fn stdout_path(&self) -> Option<&Path> {
59 match self {
60 Self::Pipes { stdout_path, .. } => Some(stdout_path),
61 Self::Pty { .. } => None,
62 }
63 }
64
65 pub fn stderr_path(&self) -> Option<&Path> {
66 match self {
67 Self::Pipes { stderr_path, .. } => Some(stderr_path),
68 Self::Pty { .. } => None,
69 }
70 }
71
72 pub fn combined_path(&self) -> Option<&Path> {
73 match self {
74 Self::Pipes { .. } => None,
75 Self::Pty { combined_path } => Some(combined_path),
76 }
77 }
78
79 pub fn read_tail(&self, max_bytes: usize) -> (String, bool) {
80 match self {
81 Self::Pipes {
82 stdout_path,
83 stderr_path,
84 } => read_two_file_tails(stdout_path, stderr_path, max_bytes),
85 Self::Pty { combined_path } => match read_file_tail(combined_path, max_bytes) {
86 Ok((bytes, truncated)) => (String::from_utf8_lossy(&bytes).into_owned(), truncated),
87 Err(_) => (String::new(), false),
88 },
89 }
90 }
91
92 pub fn read_combined_head_tail(
93 &self,
94 max_bytes: usize,
95 head_bytes: usize,
96 tail_bytes: usize,
97 ) -> BoundedRead {
98 match self {
99 Self::Pipes {
100 stdout_path,
101 stderr_path,
102 } => {
103 read_two_file_head_tail(stdout_path, stderr_path, max_bytes, head_bytes, tail_bytes)
104 }
105 Self::Pty { combined_path } => {
106 read_single_file_head_tail(combined_path, max_bytes, head_bytes, tail_bytes)
107 .unwrap_or_else(|_| BoundedRead {
108 text: String::new(),
109 truncated: false,
110 total_bytes: 0,
111 })
112 }
113 }
114 }
115
116 pub fn read_stream_bounded(&self, stream: StreamKind, max_bytes: usize) -> BoundedRead {
117 let path = match (self, stream) {
118 (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
119 (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
120 (Self::Pty { combined_path }, _) => Some(combined_path),
121 };
122 path.and_then(|path| read_file_bounded(path, max_bytes).ok())
123 .unwrap_or_else(|| BoundedRead {
124 text: String::new(),
125 truncated: false,
126 total_bytes: 0,
127 })
128 }
129
130 pub fn stream_len(&self, stream: StreamKind) -> u64 {
131 let path = match (self, stream) {
132 (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
133 (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
134 (Self::Pty { combined_path }, _) => Some(combined_path),
135 };
136 path.and_then(|path| path.metadata().ok())
137 .map(|metadata| metadata.len())
138 .unwrap_or(0)
139 }
140
141 pub fn read_for_token_count(&self, max_bytes_per_stream: usize) -> TokenCountInput {
142 match self {
143 Self::Pipes {
144 stdout_path,
145 stderr_path,
146 } => {
147 let stdout = read_file_tail(stdout_path, max_bytes_per_stream);
150 let stderr = read_file_tail(stderr_path, max_bytes_per_stream);
151 match (stdout, stderr) {
152 (Ok((stdout, _)), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
153 String::from_utf8_lossy(&stdout).as_ref(),
154 String::from_utf8_lossy(&stderr).as_ref(),
155 )),
156 (Ok((stdout, _)), Err(_)) => TokenCountInput::Text(combine_streams(
157 String::from_utf8_lossy(&stdout).as_ref(),
158 "",
159 )),
160 (Err(_), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
161 "",
162 String::from_utf8_lossy(&stderr).as_ref(),
163 )),
164 (Err(_), Err(_)) => TokenCountInput::Skipped,
165 }
166 }
167 Self::Pty { .. } => TokenCountInput::Skipped,
171 }
172 }
173
174 pub fn read_stream_tail(&self, stream: StreamKind, max_bytes: usize) -> (String, bool) {
175 let path = match (self, stream) {
176 (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
177 (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
178 (Self::Pty { combined_path }, _) => Some(combined_path),
179 };
180 match path.and_then(|path| read_file_tail(path, max_bytes).ok()) {
181 Some((bytes, truncated)) => (String::from_utf8_lossy(&bytes).into_owned(), truncated),
182 None => (String::new(), false),
183 }
184 }
185
186 pub fn output_path(&self) -> Option<PathBuf> {
188 match self {
189 Self::Pipes { stdout_path, .. } => Some(stdout_path.clone()),
190 Self::Pty { combined_path } => Some(combined_path.clone()),
191 }
192 }
193
194 pub fn enforce_terminal_cap(&mut self) -> DiskTruncation {
195 match self {
196 Self::Pipes {
197 stdout_path,
198 stderr_path,
199 } => DiskTruncation {
200 stdout_prefix_bytes: truncate_front(stdout_path, DISK_LIMIT_BYTES).unwrap_or(0),
201 stderr_prefix_bytes: truncate_front(stderr_path, DISK_LIMIT_BYTES).unwrap_or(0),
202 combined_prefix_bytes: 0,
203 },
204 Self::Pty { combined_path } => DiskTruncation {
205 stdout_prefix_bytes: 0,
206 stderr_prefix_bytes: 0,
207 combined_prefix_bytes: truncate_front(combined_path, DISK_LIMIT_BYTES).unwrap_or(0),
208 },
209 }
210 }
211
212 pub fn cleanup(&self) {
213 match self {
214 Self::Pipes {
215 stdout_path,
216 stderr_path,
217 } => {
218 let _ = fs::remove_file(stdout_path);
219 let _ = fs::remove_file(stderr_path);
220 }
221 Self::Pty { combined_path } => {
222 let _ = fs::remove_file(combined_path);
223 }
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum TokenCountInput {
230 Text(String),
231 Skipped,
232}
233
234pub fn combine_streams(stdout: &str, stderr: &str) -> String {
235 match (stdout.is_empty(), stderr.is_empty()) {
236 (true, true) => String::new(),
237 (false, true) => stdout.to_string(),
238 (true, false) => stderr.to_string(),
239 (false, false) => format!("{stdout}\n{stderr}"),
240 }
241}
242
243pub(crate) fn read_file_tail(path: &Path, max_bytes: usize) -> io::Result<(Vec<u8>, bool)> {
244 if max_bytes == 0 {
245 return Ok((
246 Vec::new(),
247 path.metadata()
248 .map(|metadata| metadata.len() > 0)
249 .unwrap_or(false),
250 ));
251 }
252
253 let mut file = File::open(path)?;
254 let len = file.metadata()?.len();
255 let read_len = len.min(max_bytes as u64);
256 if read_len > 0 {
257 file.seek(SeekFrom::End(-(read_len as i64)))?;
258 }
259 let mut bytes = Vec::with_capacity(read_len as usize);
260 file.read_to_end(&mut bytes)?;
261 let truncated = len > max_bytes as u64;
262 if truncated {
263 bytes = align_start_to_utf8(bytes);
264 }
265 Ok((bytes, truncated))
266}
267
268fn read_file_bounded(path: &Path, max_bytes: usize) -> io::Result<BoundedRead> {
269 let metadata = path.metadata()?;
270 let total_bytes = metadata.len();
271 if total_bytes > max_bytes as u64 {
272 if max_bytes == 0 {
273 return Ok(BoundedRead {
274 text: String::new(),
275 truncated: true,
276 total_bytes,
277 });
278 }
279 return read_single_file_head_tail(
280 path,
281 max_bytes,
282 max_bytes / 2,
283 max_bytes - max_bytes / 2,
284 );
285 }
286 let bytes = fs::read(path)?;
287 Ok(BoundedRead {
288 text: String::from_utf8_lossy(&bytes).into_owned(),
289 truncated: false,
290 total_bytes,
291 })
292}
293
294fn read_single_file_head_tail(
295 path: &Path,
296 max_bytes: usize,
297 head_bytes: usize,
298 tail_bytes: usize,
299) -> io::Result<BoundedRead> {
300 let total_bytes = path.metadata()?.len();
301 if total_bytes <= max_bytes as u64 {
302 let bytes = fs::read(path)?;
303 return Ok(BoundedRead {
304 text: String::from_utf8_lossy(&bytes).into_owned(),
305 truncated: false,
306 total_bytes,
307 });
308 }
309
310 let head_len = head_bytes.min(max_bytes) as u64;
311 let tail_len = tail_bytes.min(max_bytes.saturating_sub(head_len as usize)) as u64;
312 let head = read_file_range(path, 0, head_len)?;
313 let tail_start = total_bytes.saturating_sub(tail_len);
314 let tail = read_file_range(path, tail_start, tail_len)?;
315 Ok(BoundedRead {
316 text: join_head_tail_bytes(head, tail, total_bytes.saturating_sub(head_len + tail_len)),
317 truncated: true,
318 total_bytes,
319 })
320}
321
322fn read_two_file_head_tail(
323 first: &Path,
324 second: &Path,
325 max_bytes: usize,
326 head_bytes: usize,
327 tail_bytes: usize,
328) -> BoundedRead {
329 let first_len = first.metadata().map(|metadata| metadata.len()).unwrap_or(0);
330 let second_len = second
331 .metadata()
332 .map(|metadata| metadata.len())
333 .unwrap_or(0);
334 let total_bytes = first_len.saturating_add(second_len);
335
336 if total_bytes <= max_bytes as u64 {
337 let mut bytes = Vec::with_capacity(total_bytes as usize);
338 if let Ok(first_bytes) = fs::read(first) {
339 bytes.extend_from_slice(&first_bytes);
340 }
341 if let Ok(second_bytes) = fs::read(second) {
342 bytes.extend_from_slice(&second_bytes);
343 }
344 return BoundedRead {
345 text: String::from_utf8_lossy(&bytes).into_owned(),
346 truncated: false,
347 total_bytes,
348 };
349 }
350
351 let head_budget = head_bytes.min(max_bytes);
352 let (first_head, second_head) = split_stream_budget(first_len, second_len, head_budget);
353 let tail_budget = tail_bytes.min(max_bytes.saturating_sub(first_head + second_head));
354 let first_remaining = first_len.saturating_sub(first_head as u64);
355 let second_remaining = second_len.saturating_sub(second_head as u64);
356 let (first_tail, second_tail) =
357 split_stream_budget(first_remaining, second_remaining, tail_budget);
358
359 let first_read =
360 read_single_file_head_tail(first, first_head + first_tail, first_head, first_tail)
361 .unwrap_or_else(|_| BoundedRead {
362 text: String::new(),
363 truncated: false,
364 total_bytes: first_len,
365 });
366 let second_read =
367 read_single_file_head_tail(second, second_head + second_tail, second_head, second_tail)
368 .unwrap_or_else(|_| BoundedRead {
369 text: String::new(),
370 truncated: false,
371 total_bytes: second_len,
372 });
373
374 BoundedRead {
375 text: combine_streams(&first_read.text, &second_read.text),
376 truncated: true,
377 total_bytes,
378 }
379}
380
381fn read_two_file_tails(first: &Path, second: &Path, max_bytes: usize) -> (String, bool) {
382 let first_len = first.metadata().map(|metadata| metadata.len()).unwrap_or(0);
383 let second_len = second
384 .metadata()
385 .map(|metadata| metadata.len())
386 .unwrap_or(0);
387 let total_bytes = first_len.saturating_add(second_len);
388 if total_bytes <= max_bytes as u64 {
389 let first_bytes = fs::read(first).unwrap_or_default();
390 let second_bytes = fs::read(second).unwrap_or_default();
391 return (
392 combine_streams(
393 String::from_utf8_lossy(&first_bytes).as_ref(),
394 String::from_utf8_lossy(&second_bytes).as_ref(),
395 ),
396 false,
397 );
398 }
399
400 let (first_budget, second_budget) = split_stream_budget(first_len, second_len, max_bytes);
401 let (first_bytes, first_truncated) = read_file_tail(first, first_budget)
402 .unwrap_or_else(|_| (Vec::new(), first_len > first_budget as u64));
403 let (second_bytes, second_truncated) = read_file_tail(second, second_budget)
404 .unwrap_or_else(|_| (Vec::new(), second_len > second_budget as u64));
405 (
406 combine_streams(
407 String::from_utf8_lossy(&first_bytes).as_ref(),
408 String::from_utf8_lossy(&second_bytes).as_ref(),
409 ),
410 first_truncated || second_truncated || total_bytes > max_bytes as u64,
411 )
412}
413
414fn split_stream_budget(first_len: u64, second_len: u64, total_budget: usize) -> (usize, usize) {
415 if total_budget == 0 {
416 return (0, 0);
417 }
418 match (first_len > 0, second_len > 0) {
419 (false, false) => (0, 0),
420 (true, false) => (total_budget, 0),
421 (false, true) => (0, total_budget),
422 (true, true) => {
423 let mut first_budget = total_budget / 2;
424 let mut second_budget = total_budget - first_budget;
425 redistribute_unused_budget(first_len, &mut first_budget, &mut second_budget);
426 redistribute_unused_budget(second_len, &mut second_budget, &mut first_budget);
427 (first_budget, second_budget)
428 }
429 }
430}
431
432fn redistribute_unused_budget(len: u64, own_budget: &mut usize, other_budget: &mut usize) {
433 let needed = len.min(usize::MAX as u64) as usize;
434 if needed < *own_budget {
435 let spare = own_budget.saturating_sub(needed);
436 *own_budget = needed;
437 *other_budget = other_budget.saturating_add(spare);
438 }
439}
440
441fn read_file_range(path: &Path, start: u64, len: u64) -> io::Result<Vec<u8>> {
442 if len == 0 {
443 return Ok(Vec::new());
444 }
445 let mut file = File::open(path)?;
446 file.seek(SeekFrom::Start(start))?;
447 let mut limited = file.take(len);
448 let mut bytes = Vec::with_capacity(len as usize);
449 limited.read_to_end(&mut bytes)?;
450 if start > 0 {
451 bytes = align_start_to_utf8(bytes);
452 }
453 bytes = align_end_to_utf8(bytes);
454 Ok(bytes)
455}
456
457fn join_head_tail_bytes(head: Vec<u8>, tail: Vec<u8>, truncated_bytes: u64) -> String {
458 let mut output = String::from_utf8_lossy(&head).into_owned();
459 if !output.ends_with('\n') {
460 output.push('\n');
461 }
462 output.push_str("...<truncated ");
463 output.push_str(&truncated_bytes.to_string());
464 output.push_str(" bytes>...\n");
465 output.push_str(&String::from_utf8_lossy(&tail));
466 output
467}
468
469fn truncate_front(path: &Path, retain_bytes: u64) -> io::Result<u64> {
470 let len = match path.metadata() {
471 Ok(metadata) => metadata.len(),
472 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
473 Err(error) => return Err(error),
474 };
475 if len <= retain_bytes {
476 return Ok(0);
477 }
478
479 let mut file = File::open(path)?;
480 file.seek(SeekFrom::End(-(retain_bytes as i64)))?;
481 let mut tail = Vec::with_capacity(retain_bytes as usize);
482 file.read_to_end(&mut tail)?;
483 let tail = align_start_to_utf8(tail);
484 let retained_bytes = tail.len() as u64;
485 let tmp = path.with_extension(format!(
486 "{}.tmp",
487 path.extension()
488 .and_then(|extension| extension.to_str())
489 .unwrap_or("out")
490 ));
491 fs::write(&tmp, tail)?;
492 fs::rename(&tmp, path)?;
493 Ok(len.saturating_sub(retained_bytes))
494}
495
496fn align_start_to_utf8(mut bytes: Vec<u8>) -> Vec<u8> {
497 let mut start = 0;
498 while start < bytes.len() && (bytes[start] & 0xC0) == 0x80 {
499 start += 1;
500 }
501 if start > 0 {
502 bytes.drain(..start);
503 }
504 bytes
505}
506
507fn align_end_to_utf8(mut bytes: Vec<u8>) -> Vec<u8> {
508 while !bytes.is_empty() {
509 let last = bytes.len() - 1;
510 if bytes[last] < 0x80 {
511 break;
512 }
513 let lead_pos = if (bytes[last] & 0xC0) == 0x80 {
514 let mut pos = last;
515 while pos > 0 && (bytes[pos] & 0xC0) == 0x80 {
516 pos -= 1;
517 }
518 if (bytes[pos] & 0xC0) == 0xC0 {
519 pos
520 } else {
521 bytes.pop();
522 continue;
523 }
524 } else {
525 last
526 };
527 let lead = bytes[lead_pos];
528 debug_assert!(lead >= 0xC0, "lead byte must be >= 0xC0, got {lead:#x}");
529 let expected = if lead < 0xE0 {
530 1
531 } else if lead < 0xF0 {
532 2
533 } else {
534 3
535 };
536 if last - lead_pos >= expected {
537 break;
538 }
539 bytes.truncate(lead_pos);
540 }
541 bytes
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
553 fn read_file_tail_should_not_split_utf8_character() {
554 let dir = tempfile::tempdir().unwrap();
559 let path = dir.path().join("stdout");
560 std::fs::write(&path, "AAAA€".as_bytes()).unwrap();
561 let (bytes, _truncated) = read_file_tail(&path, 2).unwrap();
562 let text = String::from_utf8_lossy(&bytes);
563 assert!(
564 !text.contains('\u{FFFD}'),
565 "read_file_tail should not produce replacement characters, got: {:?}",
566 text
567 );
568 }
569
570 #[test]
571 fn truncate_front_should_not_split_utf8_character() {
572 let dir = tempfile::tempdir().unwrap();
573 let path = dir.path().join("stdout");
574 std::fs::write(&path, "AAAA€".as_bytes()).unwrap();
575 truncate_front(&path, 2).unwrap();
576 let bytes = std::fs::read(&path).unwrap();
577 let text = String::from_utf8_lossy(&bytes);
578 assert!(
579 !text.contains('\u{FFFD}'),
580 "truncate_front should not produce replacement characters, got: {:?}",
581 text
582 );
583 }
584
585 #[test]
586 fn read_file_tail_should_not_split_4byte_utf8() {
587 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("stdout");
590 std::fs::write(&path, "AAAA😀".as_bytes()).unwrap();
591 let (bytes, _truncated) = read_file_tail(&path, 2).unwrap();
592 let text = String::from_utf8_lossy(&bytes);
593 assert!(
594 !text.contains('\u{FFFD}'),
595 "read_file_tail should not produce replacement characters for 4-byte chars, got: {:?}",
596 text
597 );
598 }
599
600 #[test]
601 fn read_file_range_end_boundary_should_not_split_utf8() {
602 let dir = tempfile::tempdir().unwrap();
606 let path = dir.path().join("stdout");
607 std::fs::write(&path, "AAAA€".as_bytes()).unwrap();
608 let bytes = read_file_range(&path, 0, 5).unwrap();
609 let text = String::from_utf8_lossy(&bytes);
610 assert!(
611 !text.contains('\u{FFFD}'),
612 "read_file_range should not produce replacement characters at end boundary, got: {:?}",
613 text
614 );
615 }
616
617 #[test]
618 fn ascii_content_unaffected_by_alignment() {
619 let dir = tempfile::tempdir().unwrap();
620 let path = dir.path().join("stdout");
621 let content = b"hello world\nline two\n";
622 std::fs::write(&path, content).unwrap();
623 let (bytes, truncated) = read_file_tail(&path, 10).unwrap();
624 assert!(truncated);
625 assert_eq!(bytes, b"\nline two\n");
626 }
627
628 #[test]
629 fn read_file_range_start_boundary_should_not_split_utf8() {
630 let dir = tempfile::tempdir().unwrap();
639 let path = dir.path().join("stdout");
640 std::fs::write(&path, b"Hello\xe2\x82\xacWorld").unwrap();
641 let bytes = read_file_range(&path, 6, 2).unwrap();
642 let text = String::from_utf8_lossy(&bytes);
643 assert!(
644 !text.contains('\u{FFFD}'),
645 "read_file_range with start>0 should not produce replacement characters, got: {:?}",
646 text
647 );
648 }
649
650 #[test]
657 fn read_tail_puts_stdout_before_stderr() {
658 let dir = tempfile::tempdir().unwrap();
661 let stdout_path = dir.path().join("stdout");
662 let stderr_path = dir.path().join("stderr");
663 std::fs::write(&stdout_path, b"stdout-line\n").unwrap();
664 std::fs::write(&stderr_path, b"stderr-line\n").unwrap();
665 let buffer = BgBuffer::new(stdout_path, stderr_path);
666 let (text, _) = buffer.read_tail(1024);
667 let stdout_pos = text.find("stdout-line").unwrap();
668 let stderr_pos = text.find("stderr-line").unwrap();
669 assert!(
670 stdout_pos < stderr_pos,
671 "stdout should come before stderr in combined output"
672 );
673 }
674
675 #[test]
676 fn read_tail_preserves_each_stream_tail_when_combined_cap_truncates() {
677 let dir = tempfile::tempdir().unwrap();
678 let stdout_path = dir.path().join("stdout");
679 let stderr_path = dir.path().join("stderr");
680 std::fs::write(
681 &stdout_path,
682 format!(
683 "{}
684error: stdout boom
685",
686 "stdout noise
687"
688 .repeat(20)
689 ),
690 )
691 .unwrap();
692 std::fs::write(
693 &stderr_path,
694 format!(
695 "{}
696stderr tail
697",
698 "stderr noise
699"
700 .repeat(200)
701 ),
702 )
703 .unwrap();
704 let buffer = BgBuffer::new(stdout_path, stderr_path);
705
706 let (text, truncated) = buffer.read_tail(160);
707
708 assert!(truncated);
709 assert!(text.contains("error: stdout boom"));
710 assert!(text.contains("stderr tail"));
711 }
712
713 #[test]
714 fn read_combined_head_tail_preserves_each_stream_tail() {
715 let dir = tempfile::tempdir().unwrap();
716 let stdout_path = dir.path().join("stdout");
717 let stderr_path = dir.path().join("stderr");
718 std::fs::write(
719 &stdout_path,
720 format!(
721 "stdout head
722{}
723ERROR: stdout final
724",
725 "x".repeat(512)
726 ),
727 )
728 .unwrap();
729 std::fs::write(
730 &stderr_path,
731 format!(
732 "stderr head
733{}
734stderr final
735",
736 "y".repeat(2048)
737 ),
738 )
739 .unwrap();
740 let buffer = BgBuffer::new(stdout_path, stderr_path);
741
742 let read = buffer.read_combined_head_tail(256, 64, 192);
743
744 assert!(read.truncated);
745 assert!(read.text.contains("ERROR: stdout final"));
746 assert!(read.text.contains("stderr final"));
747 }
748
749 #[test]
750 fn read_file_bounded_returns_head_and_tail_for_oversized_files() {
751 let dir = tempfile::tempdir().unwrap();
752 let path = dir.path().join("stdout");
753 std::fs::write(
754 &path,
755 format!(
756 "HEAD
757{}
758TAIL",
759 "x".repeat(256)
760 ),
761 )
762 .unwrap();
763
764 let read = read_file_bounded(&path, 64).unwrap();
765
766 assert!(read.truncated);
767 assert!(read.text.contains("HEAD"));
768 assert!(read.text.contains("TAIL"));
769 assert!(read.text.contains("...<truncated "));
770 }
771
772 #[test]
773 fn truncate_front_reports_prefix_bytes_removed() {
774 let dir = tempfile::tempdir().unwrap();
775 let path = dir.path().join("stdout");
776 std::fs::write(
777 &path,
778 b"early root cause
779late tail
780",
781 )
782 .unwrap();
783
784 let removed = truncate_front(&path, 10).unwrap();
785 let retained = std::fs::read_to_string(&path).unwrap();
786
787 assert!(removed > 0);
788 assert!(!retained.contains("early root cause"));
789 assert!(retained.contains("late tail"));
790 }
791}