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 struct BgBuffer {
15    stdout_path: PathBuf,
16    stderr_path: PathBuf,
17    rotated: bool,
18}
19
20impl BgBuffer {
21    pub fn new(stdout_path: PathBuf, stderr_path: PathBuf) -> Self {
22        Self {
23            stdout_path,
24            stderr_path,
25            rotated: false,
26        }
27    }
28
29    pub fn stdout_path(&self) -> &Path {
30        &self.stdout_path
31    }
32
33    pub fn stderr_path(&self) -> &Path {
34        &self.stderr_path
35    }
36
37    pub fn read_tail(&self, max_bytes: usize) -> (String, bool) {
38        let stdout = read_file_tail(&self.stdout_path, max_bytes);
39        let stderr = read_file_tail(&self.stderr_path, max_bytes);
40        match (stdout, stderr) {
41            (Ok((stdout, stdout_truncated)), Ok((stderr, stderr_truncated))) => {
42                let mut output = Vec::with_capacity(stdout.len().saturating_add(stderr.len()));
43                output.extend_from_slice(&stdout);
44                output.extend_from_slice(&stderr);
45                if output.len() > max_bytes {
46                    let keep_from = output.len().saturating_sub(max_bytes);
47                    output.drain(..keep_from);
48                }
49                (
50                    String::from_utf8_lossy(&output).into_owned(),
51                    self.rotated
52                        || stdout_truncated
53                        || stderr_truncated
54                        || output.len() >= max_bytes && (stdout.len() + stderr.len()) > max_bytes,
55                )
56            }
57            (Ok((stdout, stdout_truncated)), Err(_)) => (
58                String::from_utf8_lossy(&stdout).into_owned(),
59                self.rotated || stdout_truncated,
60            ),
61            (Err(_), Ok((stderr, stderr_truncated))) => (
62                String::from_utf8_lossy(&stderr).into_owned(),
63                self.rotated || stderr_truncated,
64            ),
65            (Err(_), Err(_)) => (String::new(), self.rotated),
66        }
67    }
68
69    pub fn read_for_token_count(&self, max_bytes_per_stream: usize) -> TokenCountInput {
70        // Read up to `max_bytes_per_stream` bytes per stream rather than
71        // refusing to tokenize anything when the file exceeds the cap.
72        // `read_file_with_cap` returns `Ok(None)` for files over the cap,
73        // which would mask large outputs from compression accounting
74        // entirely — defeating the purpose of token tracking for the
75        // tasks that benefit most from compression (huge logs, test
76        // output, build noise). The tokenizer benchmark in
77        // `crates/aft-tokenizer` shows ~7ms at 128KiB and scales
78        // linearly, so reading the tail (most recent output) is safe
79        // even for very large spills.
80        let stdout = read_file_tail(&self.stdout_path, max_bytes_per_stream);
81        let stderr = read_file_tail(&self.stderr_path, max_bytes_per_stream);
82        match (stdout, stderr) {
83            (Ok((stdout, _)), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
84                String::from_utf8_lossy(&stdout).as_ref(),
85                String::from_utf8_lossy(&stderr).as_ref(),
86            )),
87            // If either file is missing/unreadable, fall back to whatever
88            // we could read. Truly missing both = skip (rare).
89            (Ok((stdout, _)), Err(_)) => TokenCountInput::Text(combine_streams(
90                String::from_utf8_lossy(&stdout).as_ref(),
91                "",
92            )),
93            (Err(_), Ok((stderr, _))) => TokenCountInput::Text(combine_streams(
94                "",
95                String::from_utf8_lossy(&stderr).as_ref(),
96            )),
97            (Err(_), Err(_)) => TokenCountInput::Skipped,
98        }
99    }
100
101    pub fn read_stream_tail(&self, stream: StreamKind, max_bytes: usize) -> (String, bool) {
102        let path = match stream {
103            StreamKind::Stdout => &self.stdout_path,
104            StreamKind::Stderr => &self.stderr_path,
105        };
106        match read_file_tail(path, max_bytes) {
107            Ok((bytes, truncated)) => (
108                String::from_utf8_lossy(&bytes).into_owned(),
109                self.rotated || truncated,
110            ),
111            Err(_) => (String::new(), self.rotated),
112        }
113    }
114
115    /// Path to the stdout spill file (alias of `stdout_path` for backward compat).
116    pub fn output_path(&self) -> Option<PathBuf> {
117        Some(self.stdout_path.clone())
118    }
119
120    // stderr_path() already exists above returning &Path — no duplicate needed.
121
122    pub fn enforce_terminal_cap(&mut self) {
123        if truncate_front(&self.stdout_path, DISK_LIMIT_BYTES).unwrap_or(false) {
124            self.rotated = true;
125        }
126        if truncate_front(&self.stderr_path, DISK_LIMIT_BYTES).unwrap_or(false) {
127            self.rotated = true;
128        }
129    }
130
131    pub fn cleanup(&self) {
132        let _ = fs::remove_file(&self.stdout_path);
133        let _ = fs::remove_file(&self.stderr_path);
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum TokenCountInput {
139    Text(String),
140    Skipped,
141}
142
143pub fn combine_streams(stdout: &str, stderr: &str) -> String {
144    match (stdout.is_empty(), stderr.is_empty()) {
145        (true, true) => String::new(),
146        (false, true) => stdout.to_string(),
147        (true, false) => stderr.to_string(),
148        (false, false) => format!("{stdout}\n{stderr}"),
149    }
150}
151
152fn read_file_tail(path: &Path, max_bytes: usize) -> io::Result<(Vec<u8>, bool)> {
153    if max_bytes == 0 {
154        return Ok((
155            Vec::new(),
156            path.metadata()
157                .map(|metadata| metadata.len() > 0)
158                .unwrap_or(false),
159        ));
160    }
161
162    let mut file = File::open(path)?;
163    let len = file.metadata()?.len();
164    let read_len = len.min(max_bytes as u64);
165    if read_len > 0 {
166        file.seek(SeekFrom::End(-(read_len as i64)))?;
167    }
168    let mut bytes = Vec::with_capacity(read_len as usize);
169    file.read_to_end(&mut bytes)?;
170    Ok((bytes, len > max_bytes as u64))
171}
172
173fn truncate_front(path: &Path, retain_bytes: u64) -> io::Result<bool> {
174    let len = match path.metadata() {
175        Ok(metadata) => metadata.len(),
176        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
177        Err(error) => return Err(error),
178    };
179    if len <= retain_bytes {
180        return Ok(false);
181    }
182
183    let mut file = File::open(path)?;
184    file.seek(SeekFrom::End(-(retain_bytes as i64)))?;
185    let mut tail = Vec::with_capacity(retain_bytes as usize);
186    file.read_to_end(&mut tail)?;
187    let tmp = path.with_extension(format!(
188        "{}.tmp",
189        path.extension()
190            .and_then(|extension| extension.to_str())
191            .unwrap_or("out")
192    ));
193    fs::write(&tmp, tail)?;
194    fs::rename(&tmp, path)?;
195    Ok(true)
196}