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