Skip to main content

harness_bash/
format.rs

1use crate::constants::{MAX_OUTPUT_BYTES_FILE, MAX_OUTPUT_BYTES_INLINE};
2use crate::types::TimeoutReason;
3use std::fs::{self, File, OpenOptions};
4use std::io::Write;
5use std::path::PathBuf;
6
7/// Per-stream output buffer. Keeps a bounded head+tail preview in memory,
8/// spills the full stream to a temp file when overflow triggers.
9pub struct HeadTailBuffer {
10    max_inline: usize,
11    max_file: usize,
12    kind: &'static str,
13    spill_dir: PathBuf,
14    chunks: Vec<Vec<u8>>,
15    head_chunks: Vec<Vec<u8>>,
16    head_bytes: usize,
17    tail: Vec<u8>,
18    total_bytes: usize,
19    spilled: bool,
20    spill_path: Option<PathBuf>,
21    spill_file: Option<File>,
22    file_bytes_written: usize,
23}
24
25impl HeadTailBuffer {
26    pub fn new(max_inline: usize, max_file: usize, kind: &'static str, spill_dir: PathBuf) -> Self {
27        Self {
28            max_inline,
29            max_file,
30            kind,
31            spill_dir,
32            chunks: Vec::new(),
33            head_chunks: Vec::new(),
34            head_bytes: 0,
35            tail: Vec::new(),
36            total_bytes: 0,
37            spilled: false,
38            spill_path: None,
39            spill_file: None,
40            file_bytes_written: 0,
41        }
42    }
43
44    pub fn with_defaults(kind: &'static str, spill_dir: PathBuf) -> Self {
45        Self::new(
46            MAX_OUTPUT_BYTES_INLINE,
47            MAX_OUTPUT_BYTES_FILE,
48            kind,
49            spill_dir,
50        )
51    }
52
53    pub fn write(&mut self, chunk: &[u8]) {
54        self.total_bytes += chunk.len();
55        self.remember_head(chunk);
56        self.remember_tail(chunk);
57        if self.total_bytes <= self.max_inline {
58            self.chunks.push(chunk.to_vec());
59            return;
60        }
61        if !self.spilled {
62            self.spilled = true;
63            let _ = fs::create_dir_all(&self.spill_dir);
64            let path = self.spill_dir.join(format!(
65                "{}-{}.{}",
66                std::process::id(),
67                std::time::SystemTime::now()
68                    .duration_since(std::time::UNIX_EPOCH)
69                    .map(|d| d.as_nanos())
70                    .unwrap_or(0),
71                self.kind
72            ));
73            self.spill_path = Some(path.clone());
74            if let Ok(mut f) = File::create(&path) {
75                // Dump whatever's already buffered so the spill file has
76                // the full stream from the start.
77                for c in &self.chunks {
78                    let _ = f.write_all(c);
79                    self.file_bytes_written += c.len();
80                }
81                self.spill_file = OpenOptions::new().append(true).open(&path).ok();
82            }
83        }
84        if self.file_bytes_written + chunk.len() > self.max_file {
85            return;
86        }
87        if let Some(f) = self.spill_file.as_mut() {
88            let _ = f.write_all(chunk);
89            self.file_bytes_written += chunk.len();
90        }
91    }
92
93    fn head_limit(&self) -> usize {
94        self.max_inline.div_ceil(2)
95    }
96
97    fn tail_limit(&self) -> usize {
98        self.max_inline / 2
99    }
100
101    fn remember_head(&mut self, chunk: &[u8]) {
102        let remaining = self.head_limit().saturating_sub(self.head_bytes);
103        if remaining == 0 {
104            return;
105        }
106        let take = remaining.min(chunk.len());
107        self.head_chunks.push(chunk[..take].to_vec());
108        self.head_bytes += take;
109    }
110
111    fn remember_tail(&mut self, chunk: &[u8]) {
112        let limit = self.tail_limit();
113        if limit == 0 {
114            self.tail.clear();
115            return;
116        }
117        let mut combined = Vec::with_capacity(self.tail.len() + chunk.len());
118        combined.extend_from_slice(&self.tail);
119        combined.extend_from_slice(chunk);
120        if combined.len() > limit {
121            self.tail = combined[combined.len() - limit..].to_vec();
122        } else {
123            self.tail = combined;
124        }
125    }
126
127    pub fn render(&self) -> HeadTailRender {
128        if !self.spilled {
129            let joined: Vec<u8> = self.chunks.iter().flatten().copied().collect();
130            return HeadTailRender {
131                text: String::from_utf8_lossy(&joined).into_owned(),
132                byte_cap: false,
133                log_path: None,
134            };
135        }
136        let mut head_bytes: Vec<u8> = Vec::with_capacity(self.head_bytes);
137        for c in &self.head_chunks {
138            for &b in c {
139                if head_bytes.len() >= self.head_bytes {
140                    break;
141                }
142                head_bytes.push(b);
143            }
144            if head_bytes.len() >= self.head_bytes {
145                break;
146            }
147        }
148        let head_str = String::from_utf8_lossy(&head_bytes).into_owned();
149        let tail_str = String::from_utf8_lossy(&self.tail).into_owned();
150        let log_path = self
151            .spill_path
152            .as_ref()
153            .map(|p| p.to_string_lossy().into_owned());
154        let elided = self
155            .total_bytes
156            .saturating_sub(self.head_bytes + self.tail.len());
157        let marker = format!(
158            "\n... ({} bytes elided; stream exceeded {} bytes; full log at {}) ...\n",
159            elided,
160            self.max_inline,
161            log_path.as_deref().unwrap_or("?")
162        );
163        HeadTailRender {
164            text: format!("{}{}{}", head_str, marker, tail_str),
165            byte_cap: true,
166            log_path,
167        }
168    }
169
170    pub fn bytes_total(&self) -> usize {
171        self.total_bytes
172    }
173}
174
175pub struct HeadTailRender {
176    pub text: String,
177    pub byte_cap: bool,
178    pub log_path: Option<String>,
179}
180
181// ---- result text formatters ----
182
183pub struct FormatResultArgs<'a> {
184    pub command: &'a str,
185    pub exit_code: i32,
186    pub stdout: &'a str,
187    pub stderr: &'a str,
188    pub duration_ms: u64,
189    pub byte_cap: bool,
190    pub log_path: Option<&'a str>,
191    pub kind_ok: bool,
192}
193
194pub fn format_result_text(args: FormatResultArgs<'_>) -> String {
195    let header = format!("<command>{}</command>", args.command);
196    let exit_line = format!("<exit_code>{}</exit_code>", args.exit_code);
197    let stdout_block = format!("<stdout>\n{}\n</stdout>", args.stdout);
198    let stderr_block = format!("<stderr>\n{}\n</stderr>", args.stderr);
199    let curl_hint = if args.byte_cap && looks_like_url_fetch_command(args.command) {
200        " This looks like curl/wget output; use webfetch for cleaned HTML/page content, and reserve bash for raw source or downloads."
201    } else {
202        ""
203    };
204    let hint = if args.byte_cap {
205        format!(
206            "(Output capped; showing head+tail preview. Full log: {}. Read it with pagination if you need the middle.{})",
207            args.log_path.unwrap_or("?"),
208            curl_hint,
209        )
210    } else if args.kind_ok {
211        format!("(Command completed in {}ms. exit=0.)", args.duration_ms)
212    } else {
213        format!(
214            "(Command exited nonzero in {}ms. Exit code: {}.)",
215            args.duration_ms, args.exit_code
216        )
217    };
218    format!(
219        "{}\n{}\n{}\n{}\n{}",
220        header, exit_line, stdout_block, stderr_block, hint
221    )
222}
223
224fn looks_like_url_fetch_command(command: &str) -> bool {
225    let mut token = String::new();
226    for ch in command.chars().chain(std::iter::once(' ')) {
227        if ch.is_whitespace() || matches!(ch, ';' | '&' | '|' | '(' | ')') {
228            let stripped = token.strip_prefix("./").unwrap_or(&token);
229            if stripped == "curl" || stripped == "wget" {
230                return true;
231            }
232            token.clear();
233        } else {
234            token.push(ch);
235        }
236    }
237    false
238}
239
240pub struct FormatTimeoutArgs<'a> {
241    pub command: &'a str,
242    pub stdout: &'a str,
243    pub stderr: &'a str,
244    pub reason: TimeoutReason,
245    pub duration_ms: u64,
246    pub partial_bytes: usize,
247    pub log_path: Option<&'a str>,
248}
249
250pub fn format_timeout_text(args: FormatTimeoutArgs<'_>) -> String {
251    let header = format!("<command>{}</command>", args.command);
252    let stdout_block = format!("<stdout>\n{}\n</stdout>", args.stdout);
253    let stderr_block = format!("<stderr>\n{}\n</stderr>", args.stderr);
254    let log_hint = args
255        .log_path
256        .map(|p| format!(" Full log: {}.", p))
257        .unwrap_or_default();
258    let hint = format!(
259        "(Command hit {} after {}ms. {} bytes captured. Kill signal: SIGTERM then SIGKILL.{} If the command is long-running, retry with background: true.)",
260        args.reason.as_str(),
261        args.duration_ms,
262        args.partial_bytes,
263        log_hint
264    );
265    format!("{}\n{}\n{}\n{}", header, stdout_block, stderr_block, hint)
266}
267
268pub fn format_background_started_text(command: &str, job_id: &str) -> String {
269    format!(
270        "<command>{}</command>\n<job_id>{}</job_id>\n(Background job started. Poll output with bash_output(job_id). Kill with bash_kill(job_id).)",
271        command, job_id
272    )
273}
274
275pub struct FormatBashOutputArgs<'a> {
276    pub job_id: &'a str,
277    pub running: bool,
278    pub exit_code: Option<i32>,
279    pub stdout: &'a str,
280    pub stderr: &'a str,
281    pub since_byte: u64,
282    pub returned_bytes: u64,
283    pub total_bytes: u64,
284}
285
286pub fn format_bash_output_text(args: FormatBashOutputArgs<'_>) -> String {
287    let next = args.since_byte + args.returned_bytes;
288    format!(
289        "<job_id>{}</job_id>\n<running>{}</running>\n<exit_code>{}</exit_code>\n<stdout>\n{}\n</stdout>\n<stderr>\n{}\n</stderr>\n(Showing bytes {}-{} of {}. Next since_byte: {}. Job running: {}.)",
290        args.job_id,
291        args.running,
292        args.exit_code.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string()),
293        args.stdout,
294        args.stderr,
295        args.since_byte,
296        next,
297        args.total_bytes,
298        next,
299        args.running,
300    )
301}
302
303pub fn format_bash_kill_text(job_id: &str, signal: &str) -> String {
304    format!(
305        "<job_id>{}</job_id>\n({} sent. Poll bash_output to confirm termination.)",
306        job_id, signal
307    )
308}