Skip to main content

aft/bash_background/
buffer.rs

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    // --- Regression tests for UTF-8 splitting at byte boundaries ---
562    // CORRECT behavior: read_file_tail should not split UTF-8 characters.
563    // These tests FAIL when the bug is present.
564
565    #[test]
566    fn read_file_tail_should_not_split_utf8_character() {
567        // "AAAA€" = 7 bytes (4 ASCII + 3-byte €).
568        // 2-byte tail reads bytes [5,6] = 0x82 0xAC - incomplete trailing
569        // bytes of €. from_utf8_lossy produces U+FFFD.
570        // CORRECT: no replacement character should appear.
571        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        // "AAAA😀" = 4 + 4 = 8 bytes. 2-byte tail reads bytes [6,7] = incomplete.
601        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        // "AAAA€" = 7 bytes. read_file_range(path, 0, 5) reads bytes [0..5].
616        // byte 4 = 0xE2 (lead of €), byte 5 = 0x82 (continuation) — not included.
617        // End at byte 5 splits after the lead byte. align_end_to_utf8 should trim it.
618        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        // "Hello€World" = 5 + 3 + 5 = 13 bytes.
644        // read_file_range(path, 5, 4) reads bytes [5..9]:
645        // bytes 5-7 = € (0xE2 0x82 0xAC), byte 8 = 'W'.
646        // Start at byte 5 = 0xE2 (lead byte) — aligned, no split.
647        // End at byte 9 = 'o' — aligned, no split.
648        // But read_file_range(path, 6, 2) reads bytes [6..8]:
649        // byte 6 = 0x82 (continuation), byte 7 = 0xAC (continuation).
650        // Start at byte 6 splits inside €. align_start_to_utf8 should skip.
651        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    // --- Regression test for stdout/stderr interleaving ---
664    // This test documents the limitation: stdout always comes before stderr
665    // in the combined output, regardless of temporal write order.
666    // It does not assert correct interleaving (that would require a redesign)
667    // but verifies the current behavior is what we expect.
668
669    #[test]
670    fn read_tail_puts_stdout_before_stderr() {
671        // Write stdout and stderr to separate files, then verify
672        // the combined output has stdout content before stderr content.
673        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}