Skip to main content

aft/bash_background/
buffer.rs

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                // Read up to `max_bytes_per_stream` bytes per stream rather than
148                // refusing to tokenize anything when the file exceeds the cap.
149                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            // PTY completions intentionally skip token accounting. The combined
168            // stream can include terminal control sequences that the plugin
169            // renders via xterm-headless instead of the text compressor.
170            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    /// Path to the primary output spill file.
187    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    // --- Regression tests for UTF-8 splitting at byte boundaries ---
549    // CORRECT behavior: read_file_tail should not split UTF-8 characters.
550    // These tests FAIL when the bug is present.
551
552    #[test]
553    fn read_file_tail_should_not_split_utf8_character() {
554        // "AAAA€" = 7 bytes (4 ASCII + 3-byte €).
555        // 2-byte tail reads bytes [5,6] = 0x82 0xAC - incomplete trailing
556        // bytes of €. from_utf8_lossy produces U+FFFD.
557        // CORRECT: no replacement character should appear.
558        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        // "AAAA😀" = 4 + 4 = 8 bytes. 2-byte tail reads bytes [6,7] = incomplete.
588        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        // "AAAA€" = 7 bytes. read_file_range(path, 0, 5) reads bytes [0..5].
603        // byte 4 = 0xE2 (lead of €), byte 5 = 0x82 (continuation) — not included.
604        // End at byte 5 splits after the lead byte. align_end_to_utf8 should trim it.
605        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        // "Hello€World" = 5 + 3 + 5 = 13 bytes.
631        // read_file_range(path, 5, 4) reads bytes [5..9]:
632        // bytes 5-7 = € (0xE2 0x82 0xAC), byte 8 = 'W'.
633        // Start at byte 5 = 0xE2 (lead byte) — aligned, no split.
634        // End at byte 9 = 'o' — aligned, no split.
635        // But read_file_range(path, 6, 2) reads bytes [6..8]:
636        // byte 6 = 0x82 (continuation), byte 7 = 0xAC (continuation).
637        // Start at byte 6 splits inside €. align_start_to_utf8 should skip.
638        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    // --- Regression test for stdout/stderr interleaving ---
651    // This test documents the limitation: stdout always comes before stderr
652    // in the combined output, regardless of temporal write order.
653    // It does not assert correct interleaving (that would require a redesign)
654    // but verifies the current behavior is what we expect.
655
656    #[test]
657    fn read_tail_puts_stdout_before_stderr() {
658        // Write stdout and stderr to separate files, then verify
659        // the combined output has stdout content before stderr content.
660        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}