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#[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                // Read up to `max_bytes_per_stream` bytes per stream rather than
155                // refusing to tokenize anything when the file exceeds the cap.
156                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            // PTY output skips token accounting because the raw terminal stream
175            // can contain control sequences produced by terminal emulation rather
176            // than the original command text.
177            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    /// Path to the primary output spill file.
194    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    // --- Regression tests for UTF-8 splitting at byte boundaries ---
594    // CORRECT behavior: read_file_tail should not split UTF-8 characters.
595    // These tests FAIL when the bug is present.
596
597    #[test]
598    fn read_file_tail_should_not_split_utf8_character() {
599        // "AAAA€" = 7 bytes (4 ASCII + 3-byte €).
600        // 2-byte tail reads bytes [5,6] = 0x82 0xAC - incomplete trailing
601        // bytes of €. from_utf8_lossy produces U+FFFD.
602        // CORRECT: no replacement character should appear.
603        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        // "AAAA😀" = 4 + 4 = 8 bytes. 2-byte tail reads bytes [6,7] = incomplete.
633        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        // "AAAA€" = 7 bytes. read_file_range(path, 0, 5) reads bytes [0..5].
648        // byte 4 = 0xE2 (lead of €), byte 5 = 0x82 (continuation) — not included.
649        // End at byte 5 splits after the lead byte. align_end_to_utf8 should trim it.
650        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        // "Hello€World" = 5 + 3 + 5 = 13 bytes.
676        // read_file_range(path, 5, 4) reads bytes [5..9]:
677        // bytes 5-7 = € (0xE2 0x82 0xAC), byte 8 = 'W'.
678        // Start at byte 5 = 0xE2 (lead byte) — aligned, no split.
679        // End at byte 9 = 'o' — aligned, no split.
680        // But read_file_range(path, 6, 2) reads bytes [6..8]:
681        // byte 6 = 0x82 (continuation), byte 7 = 0xAC (continuation).
682        // Start at byte 6 splits inside €. align_start_to_utf8 should skip.
683        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    // --- Regression test for stdout/stderr interleaving ---
696    // This test documents the limitation: stdout always comes before stderr
697    // in the combined output, regardless of temporal write order.
698    // It does not assert correct interleaving (that would require a redesign)
699    // but verifies the current behavior is what we expect.
700
701    #[test]
702    fn read_tail_puts_stdout_before_stderr() {
703        // Write stdout and stderr to separate files, then verify
704        // the combined output has stdout content before stderr content.
705        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}