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)]
21pub enum BgBuffer {
22    Pipes {
23        stdout_path: PathBuf,
24        stderr_path: PathBuf,
25    },
26    Pty {
27        combined_path: PathBuf,
28    },
29}
30
31impl BgBuffer {
32    pub fn new(stdout_path: PathBuf, stderr_path: PathBuf) -> Self {
33        Self::Pipes {
34            stdout_path,
35            stderr_path,
36        }
37    }
38
39    pub fn pty(combined_path: PathBuf) -> Self {
40        Self::Pty { combined_path }
41    }
42
43    pub fn stdout_path(&self) -> Option<&Path> {
44        match self {
45            Self::Pipes { stdout_path, .. } => Some(stdout_path),
46            Self::Pty { .. } => None,
47        }
48    }
49
50    pub fn stderr_path(&self) -> Option<&Path> {
51        match self {
52            Self::Pipes { stderr_path, .. } => Some(stderr_path),
53            Self::Pty { .. } => None,
54        }
55    }
56
57    pub fn combined_path(&self) -> Option<&Path> {
58        match self {
59            Self::Pipes { .. } => None,
60            Self::Pty { combined_path } => Some(combined_path),
61        }
62    }
63
64    pub fn read_tail(&self, max_bytes: usize) -> (String, bool) {
65        match self {
66            Self::Pipes {
67                stdout_path,
68                stderr_path,
69            } => {
70                let stdout = read_file_tail(stdout_path, max_bytes);
71                let stderr = read_file_tail(stderr_path, max_bytes);
72                match (stdout, stderr) {
73                    (Ok((stdout, stdout_truncated)), Ok((stderr, stderr_truncated))) => {
74                        let mut output =
75                            Vec::with_capacity(stdout.len().saturating_add(stderr.len()));
76                        output.extend_from_slice(&stdout);
77                        output.extend_from_slice(&stderr);
78                        let was_over_cap = output.len() > max_bytes;
79                        if was_over_cap {
80                            let keep_from = output.len().saturating_sub(max_bytes);
81                            output.drain(..keep_from);
82                        }
83                        (
84                            String::from_utf8_lossy(&output).into_owned(),
85                            stdout_truncated || stderr_truncated || was_over_cap,
86                        )
87                    }
88                    (Ok((stdout, stdout_truncated)), Err(_)) => (
89                        String::from_utf8_lossy(&stdout).into_owned(),
90                        stdout_truncated,
91                    ),
92                    (Err(_), Ok((stderr, stderr_truncated))) => (
93                        String::from_utf8_lossy(&stderr).into_owned(),
94                        stderr_truncated,
95                    ),
96                    (Err(_), Err(_)) => (String::new(), false),
97                }
98            }
99            Self::Pty { combined_path } => match read_file_tail(combined_path, max_bytes) {
100                Ok((bytes, truncated)) => (String::from_utf8_lossy(&bytes).into_owned(), truncated),
101                Err(_) => (String::new(), false),
102            },
103        }
104    }
105
106    pub fn read_combined_head_tail(
107        &self,
108        max_bytes: usize,
109        head_bytes: usize,
110        tail_bytes: usize,
111    ) -> BoundedRead {
112        match self {
113            Self::Pipes {
114                stdout_path,
115                stderr_path,
116            } => {
117                read_two_file_head_tail(stdout_path, stderr_path, max_bytes, head_bytes, tail_bytes)
118            }
119            Self::Pty { combined_path } => {
120                read_single_file_head_tail(combined_path, max_bytes, head_bytes, tail_bytes)
121                    .unwrap_or_else(|_| BoundedRead {
122                        text: String::new(),
123                        truncated: false,
124                        total_bytes: 0,
125                    })
126            }
127        }
128    }
129
130    pub fn read_stream_bounded(&self, stream: StreamKind, max_bytes: usize) -> BoundedRead {
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| read_file_bounded(path, max_bytes).ok())
137            .unwrap_or_else(|| BoundedRead {
138                text: String::new(),
139                truncated: false,
140                total_bytes: 0,
141            })
142    }
143
144    pub fn stream_len(&self, stream: StreamKind) -> u64 {
145        let path = match (self, stream) {
146            (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
147            (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
148            (Self::Pty { combined_path }, _) => Some(combined_path),
149        };
150        path.and_then(|path| path.metadata().ok())
151            .map(|metadata| metadata.len())
152            .unwrap_or(0)
153    }
154
155    pub fn read_for_token_count(&self, max_bytes_per_stream: usize) -> TokenCountInput {
156        match self {
157            Self::Pipes {
158                stdout_path,
159                stderr_path,
160            } => {
161                // Read up to `max_bytes_per_stream` bytes per stream rather than
162                // refusing to tokenize anything when the file exceeds the cap.
163                let stdout = read_file_tail(stdout_path, max_bytes_per_stream);
164                let stderr = read_file_tail(stderr_path, max_bytes_per_stream);
165                match (stdout, stderr) {
166                    (Ok((stdout, _)), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
167                        String::from_utf8_lossy(&stdout).as_ref(),
168                        String::from_utf8_lossy(&stderr).as_ref(),
169                    )),
170                    (Ok((stdout, _)), Err(_)) => TokenCountInput::Text(combine_streams(
171                        String::from_utf8_lossy(&stdout).as_ref(),
172                        "",
173                    )),
174                    (Err(_), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
175                        "",
176                        String::from_utf8_lossy(&stderr).as_ref(),
177                    )),
178                    (Err(_), Err(_)) => TokenCountInput::Skipped,
179                }
180            }
181            // PTY completions intentionally skip token accounting. The combined
182            // stream can include terminal control sequences that Phase 2 renders
183            // via xterm-headless instead of the text compressor.
184            Self::Pty { .. } => TokenCountInput::Skipped,
185        }
186    }
187
188    pub fn read_stream_tail(&self, stream: StreamKind, max_bytes: usize) -> (String, bool) {
189        let path = match (self, stream) {
190            (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
191            (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
192            (Self::Pty { combined_path }, _) => Some(combined_path),
193        };
194        match path.and_then(|path| read_file_tail(path, max_bytes).ok()) {
195            Some((bytes, truncated)) => (String::from_utf8_lossy(&bytes).into_owned(), truncated),
196            None => (String::new(), false),
197        }
198    }
199
200    /// Path to the primary output spill file.
201    pub fn output_path(&self) -> Option<PathBuf> {
202        match self {
203            Self::Pipes { stdout_path, .. } => Some(stdout_path.clone()),
204            Self::Pty { combined_path } => Some(combined_path.clone()),
205        }
206    }
207
208    pub fn enforce_terminal_cap(&mut self) {
209        match self {
210            Self::Pipes {
211                stdout_path,
212                stderr_path,
213            } => {
214                let _ = truncate_front(stdout_path, DISK_LIMIT_BYTES);
215                let _ = truncate_front(stderr_path, DISK_LIMIT_BYTES);
216            }
217            Self::Pty { combined_path } => {
218                let _ = truncate_front(combined_path, DISK_LIMIT_BYTES);
219            }
220        }
221    }
222
223    pub fn cleanup(&self) {
224        match self {
225            Self::Pipes {
226                stdout_path,
227                stderr_path,
228            } => {
229                let _ = fs::remove_file(stdout_path);
230                let _ = fs::remove_file(stderr_path);
231            }
232            Self::Pty { combined_path } => {
233                let _ = fs::remove_file(combined_path);
234            }
235        }
236    }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum TokenCountInput {
241    Text(String),
242    Skipped,
243}
244
245pub fn combine_streams(stdout: &str, stderr: &str) -> String {
246    match (stdout.is_empty(), stderr.is_empty()) {
247        (true, true) => String::new(),
248        (false, true) => stdout.to_string(),
249        (true, false) => stderr.to_string(),
250        (false, false) => format!("{stdout}\n{stderr}"),
251    }
252}
253
254pub(crate) fn read_file_tail(path: &Path, max_bytes: usize) -> io::Result<(Vec<u8>, bool)> {
255    if max_bytes == 0 {
256        return Ok((
257            Vec::new(),
258            path.metadata()
259                .map(|metadata| metadata.len() > 0)
260                .unwrap_or(false),
261        ));
262    }
263
264    let mut file = File::open(path)?;
265    let len = file.metadata()?.len();
266    let read_len = len.min(max_bytes as u64);
267    if read_len > 0 {
268        file.seek(SeekFrom::End(-(read_len as i64)))?;
269    }
270    let mut bytes = Vec::with_capacity(read_len as usize);
271    file.read_to_end(&mut bytes)?;
272    Ok((bytes, len > max_bytes as u64))
273}
274
275fn read_file_bounded(path: &Path, max_bytes: usize) -> io::Result<BoundedRead> {
276    let metadata = path.metadata()?;
277    let total_bytes = metadata.len();
278    if total_bytes > max_bytes as u64 {
279        return Ok(BoundedRead {
280            text: String::new(),
281            truncated: true,
282            total_bytes,
283        });
284    }
285    let bytes = fs::read(path)?;
286    Ok(BoundedRead {
287        text: String::from_utf8_lossy(&bytes).into_owned(),
288        truncated: false,
289        total_bytes,
290    })
291}
292
293fn read_single_file_head_tail(
294    path: &Path,
295    max_bytes: usize,
296    head_bytes: usize,
297    tail_bytes: usize,
298) -> io::Result<BoundedRead> {
299    let total_bytes = path.metadata()?.len();
300    if total_bytes <= max_bytes as u64 {
301        let bytes = fs::read(path)?;
302        return Ok(BoundedRead {
303            text: String::from_utf8_lossy(&bytes).into_owned(),
304            truncated: false,
305            total_bytes,
306        });
307    }
308
309    let head_len = head_bytes.min(max_bytes) as u64;
310    let tail_len = tail_bytes.min(max_bytes.saturating_sub(head_len as usize)) as u64;
311    let head = read_file_range(path, 0, head_len)?;
312    let tail_start = total_bytes.saturating_sub(tail_len);
313    let tail = read_file_range(path, tail_start, tail_len)?;
314    Ok(BoundedRead {
315        text: join_head_tail_bytes(head, tail, total_bytes.saturating_sub(head_len + tail_len)),
316        truncated: true,
317        total_bytes,
318    })
319}
320
321fn read_two_file_head_tail(
322    first: &Path,
323    second: &Path,
324    max_bytes: usize,
325    head_bytes: usize,
326    tail_bytes: usize,
327) -> BoundedRead {
328    let first_len = first.metadata().map(|metadata| metadata.len()).unwrap_or(0);
329    let second_len = second
330        .metadata()
331        .map(|metadata| metadata.len())
332        .unwrap_or(0);
333    let total_bytes = first_len.saturating_add(second_len);
334
335    if total_bytes <= max_bytes as u64 {
336        let mut bytes = Vec::with_capacity(total_bytes as usize);
337        if let Ok(first_bytes) = fs::read(first) {
338            bytes.extend_from_slice(&first_bytes);
339        }
340        if let Ok(second_bytes) = fs::read(second) {
341            bytes.extend_from_slice(&second_bytes);
342        }
343        return BoundedRead {
344            text: String::from_utf8_lossy(&bytes).into_owned(),
345            truncated: false,
346            total_bytes,
347        };
348    }
349
350    let head_len = head_bytes.min(max_bytes) as u64;
351    let tail_len = tail_bytes.min(max_bytes.saturating_sub(head_len as usize)) as u64;
352    let head = read_virtual_range(first, first_len, second, 0, head_len).unwrap_or_default();
353    let tail_start = total_bytes.saturating_sub(tail_len);
354    let tail =
355        read_virtual_range(first, first_len, second, tail_start, tail_len).unwrap_or_default();
356    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_virtual_range(
364    first: &Path,
365    first_len: u64,
366    second: &Path,
367    start: u64,
368    len: u64,
369) -> io::Result<Vec<u8>> {
370    let mut output = Vec::with_capacity(len as usize);
371    if len == 0 {
372        return Ok(output);
373    }
374    let end = start.saturating_add(len);
375
376    if start < first_len {
377        let first_read_start = start;
378        let first_read_end = end.min(first_len);
379        output.extend(read_file_range(
380            first,
381            first_read_start,
382            first_read_end.saturating_sub(first_read_start),
383        )?);
384    }
385
386    if end > first_len {
387        let second_read_start = start.saturating_sub(first_len);
388        let second_read_end = end.saturating_sub(first_len);
389        output.extend(read_file_range(
390            second,
391            second_read_start,
392            second_read_end.saturating_sub(second_read_start),
393        )?);
394    }
395
396    Ok(output)
397}
398
399fn read_file_range(path: &Path, start: u64, len: u64) -> io::Result<Vec<u8>> {
400    if len == 0 {
401        return Ok(Vec::new());
402    }
403    let mut file = File::open(path)?;
404    file.seek(SeekFrom::Start(start))?;
405    let mut limited = file.take(len);
406    let mut bytes = Vec::with_capacity(len as usize);
407    limited.read_to_end(&mut bytes)?;
408    Ok(bytes)
409}
410
411fn join_head_tail_bytes(head: Vec<u8>, tail: Vec<u8>, truncated_bytes: u64) -> String {
412    let mut output = String::from_utf8_lossy(&head).into_owned();
413    if !output.ends_with('\n') {
414        output.push('\n');
415    }
416    output.push_str("...<truncated ");
417    output.push_str(&truncated_bytes.to_string());
418    output.push_str(" bytes>...\n");
419    output.push_str(&String::from_utf8_lossy(&tail));
420    output
421}
422
423fn truncate_front(path: &Path, retain_bytes: u64) -> io::Result<bool> {
424    let len = match path.metadata() {
425        Ok(metadata) => metadata.len(),
426        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
427        Err(error) => return Err(error),
428    };
429    if len <= retain_bytes {
430        return Ok(false);
431    }
432
433    let mut file = File::open(path)?;
434    file.seek(SeekFrom::End(-(retain_bytes as i64)))?;
435    let mut tail = Vec::with_capacity(retain_bytes as usize);
436    file.read_to_end(&mut tail)?;
437    let tmp = path.with_extension(format!(
438        "{}.tmp",
439        path.extension()
440            .and_then(|extension| extension.to_str())
441            .unwrap_or("out")
442    ));
443    fs::write(&tmp, tail)?;
444    fs::rename(&tmp, path)?;
445    Ok(true)
446}