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)]
14pub enum BgBuffer {
15    Pipes {
16        stdout_path: PathBuf,
17        stderr_path: PathBuf,
18    },
19    Pty {
20        combined_path: PathBuf,
21    },
22}
23
24impl BgBuffer {
25    pub fn new(stdout_path: PathBuf, stderr_path: PathBuf) -> Self {
26        Self::Pipes {
27            stdout_path,
28            stderr_path,
29        }
30    }
31
32    pub fn pty(combined_path: PathBuf) -> Self {
33        Self::Pty { combined_path }
34    }
35
36    pub fn stdout_path(&self) -> Option<&Path> {
37        match self {
38            Self::Pipes { stdout_path, .. } => Some(stdout_path),
39            Self::Pty { .. } => None,
40        }
41    }
42
43    pub fn stderr_path(&self) -> Option<&Path> {
44        match self {
45            Self::Pipes { stderr_path, .. } => Some(stderr_path),
46            Self::Pty { .. } => None,
47        }
48    }
49
50    pub fn combined_path(&self) -> Option<&Path> {
51        match self {
52            Self::Pipes { .. } => None,
53            Self::Pty { combined_path } => Some(combined_path),
54        }
55    }
56
57    pub fn read_tail(&self, max_bytes: usize) -> (String, bool) {
58        match self {
59            Self::Pipes {
60                stdout_path,
61                stderr_path,
62            } => {
63                let stdout = read_file_tail(stdout_path, max_bytes);
64                let stderr = read_file_tail(stderr_path, max_bytes);
65                match (stdout, stderr) {
66                    (Ok((stdout, stdout_truncated)), Ok((stderr, stderr_truncated))) => {
67                        let mut output =
68                            Vec::with_capacity(stdout.len().saturating_add(stderr.len()));
69                        output.extend_from_slice(&stdout);
70                        output.extend_from_slice(&stderr);
71                        let was_over_cap = output.len() > max_bytes;
72                        if was_over_cap {
73                            let keep_from = output.len().saturating_sub(max_bytes);
74                            output.drain(..keep_from);
75                        }
76                        (
77                            String::from_utf8_lossy(&output).into_owned(),
78                            stdout_truncated || stderr_truncated || was_over_cap,
79                        )
80                    }
81                    (Ok((stdout, stdout_truncated)), Err(_)) => (
82                        String::from_utf8_lossy(&stdout).into_owned(),
83                        stdout_truncated,
84                    ),
85                    (Err(_), Ok((stderr, stderr_truncated))) => (
86                        String::from_utf8_lossy(&stderr).into_owned(),
87                        stderr_truncated,
88                    ),
89                    (Err(_), Err(_)) => (String::new(), false),
90                }
91            }
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_for_token_count(&self, max_bytes_per_stream: usize) -> TokenCountInput {
100        match self {
101            Self::Pipes {
102                stdout_path,
103                stderr_path,
104            } => {
105                // Read up to `max_bytes_per_stream` bytes per stream rather than
106                // refusing to tokenize anything when the file exceeds the cap.
107                let stdout = read_file_tail(stdout_path, max_bytes_per_stream);
108                let stderr = read_file_tail(stderr_path, max_bytes_per_stream);
109                match (stdout, stderr) {
110                    (Ok((stdout, _)), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
111                        String::from_utf8_lossy(&stdout).as_ref(),
112                        String::from_utf8_lossy(&stderr).as_ref(),
113                    )),
114                    (Ok((stdout, _)), Err(_)) => TokenCountInput::Text(combine_streams(
115                        String::from_utf8_lossy(&stdout).as_ref(),
116                        "",
117                    )),
118                    (Err(_), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
119                        "",
120                        String::from_utf8_lossy(&stderr).as_ref(),
121                    )),
122                    (Err(_), Err(_)) => TokenCountInput::Skipped,
123                }
124            }
125            // PTY completions intentionally skip token accounting. The combined
126            // stream can include terminal control sequences that Phase 2 renders
127            // via xterm-headless instead of the text compressor.
128            Self::Pty { .. } => TokenCountInput::Skipped,
129        }
130    }
131
132    pub fn read_stream_tail(&self, stream: StreamKind, max_bytes: usize) -> (String, bool) {
133        let path = match (self, stream) {
134            (Self::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
135            (Self::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
136            (Self::Pty { combined_path }, _) => Some(combined_path),
137        };
138        match path.and_then(|path| read_file_tail(path, max_bytes).ok()) {
139            Some((bytes, truncated)) => (String::from_utf8_lossy(&bytes).into_owned(), truncated),
140            None => (String::new(), false),
141        }
142    }
143
144    /// Path to the primary output spill file.
145    pub fn output_path(&self) -> Option<PathBuf> {
146        match self {
147            Self::Pipes { stdout_path, .. } => Some(stdout_path.clone()),
148            Self::Pty { combined_path } => Some(combined_path.clone()),
149        }
150    }
151
152    pub fn enforce_terminal_cap(&mut self) {
153        match self {
154            Self::Pipes {
155                stdout_path,
156                stderr_path,
157            } => {
158                let _ = truncate_front(stdout_path, DISK_LIMIT_BYTES);
159                let _ = truncate_front(stderr_path, DISK_LIMIT_BYTES);
160            }
161            Self::Pty { combined_path } => {
162                let _ = truncate_front(combined_path, DISK_LIMIT_BYTES);
163            }
164        }
165    }
166
167    pub fn cleanup(&self) {
168        match self {
169            Self::Pipes {
170                stdout_path,
171                stderr_path,
172            } => {
173                let _ = fs::remove_file(stdout_path);
174                let _ = fs::remove_file(stderr_path);
175            }
176            Self::Pty { combined_path } => {
177                let _ = fs::remove_file(combined_path);
178            }
179        }
180    }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum TokenCountInput {
185    Text(String),
186    Skipped,
187}
188
189pub fn combine_streams(stdout: &str, stderr: &str) -> String {
190    match (stdout.is_empty(), stderr.is_empty()) {
191        (true, true) => String::new(),
192        (false, true) => stdout.to_string(),
193        (true, false) => stderr.to_string(),
194        (false, false) => format!("{stdout}\n{stderr}"),
195    }
196}
197
198pub(crate) fn read_file_tail(path: &Path, max_bytes: usize) -> io::Result<(Vec<u8>, bool)> {
199    if max_bytes == 0 {
200        return Ok((
201            Vec::new(),
202            path.metadata()
203                .map(|metadata| metadata.len() > 0)
204                .unwrap_or(false),
205        ));
206    }
207
208    let mut file = File::open(path)?;
209    let len = file.metadata()?.len();
210    let read_len = len.min(max_bytes as u64);
211    if read_len > 0 {
212        file.seek(SeekFrom::End(-(read_len as i64)))?;
213    }
214    let mut bytes = Vec::with_capacity(read_len as usize);
215    file.read_to_end(&mut bytes)?;
216    Ok((bytes, len > max_bytes as u64))
217}
218
219fn truncate_front(path: &Path, retain_bytes: u64) -> io::Result<bool> {
220    let len = match path.metadata() {
221        Ok(metadata) => metadata.len(),
222        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
223        Err(error) => return Err(error),
224    };
225    if len <= retain_bytes {
226        return Ok(false);
227    }
228
229    let mut file = File::open(path)?;
230    file.seek(SeekFrom::End(-(retain_bytes as i64)))?;
231    let mut tail = Vec::with_capacity(retain_bytes as usize);
232    file.read_to_end(&mut tail)?;
233    let tmp = path.with_extension(format!(
234        "{}.tmp",
235        path.extension()
236            .and_then(|extension| extension.to_str())
237            .unwrap_or("out")
238    ));
239    fs::write(&tmp, tail)?;
240    fs::rename(&tmp, path)?;
241    Ok(true)
242}