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