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 head in memory, spills the rest to a
8/// temp file when overflow triggers. Matches the TS HeadTailBuffer
9/// simplification (head-only inline; full file recoverable via Read).
10pub struct HeadTailBuffer {
11    max_inline: usize,
12    max_file: usize,
13    kind: &'static str,
14    spill_dir: PathBuf,
15    chunks: Vec<Vec<u8>>,
16    total_bytes: usize,
17    spilled: bool,
18    spill_path: Option<PathBuf>,
19    spill_file: Option<File>,
20    file_bytes_written: usize,
21}
22
23impl HeadTailBuffer {
24    pub fn new(max_inline: usize, max_file: usize, kind: &'static str, spill_dir: PathBuf) -> Self {
25        Self {
26            max_inline,
27            max_file,
28            kind,
29            spill_dir,
30            chunks: Vec::new(),
31            total_bytes: 0,
32            spilled: false,
33            spill_path: None,
34            spill_file: None,
35            file_bytes_written: 0,
36        }
37    }
38
39    pub fn with_defaults(kind: &'static str, spill_dir: PathBuf) -> Self {
40        Self::new(
41            MAX_OUTPUT_BYTES_INLINE,
42            MAX_OUTPUT_BYTES_FILE,
43            kind,
44            spill_dir,
45        )
46    }
47
48    pub fn write(&mut self, chunk: &[u8]) {
49        self.total_bytes += chunk.len();
50        if self.total_bytes <= self.max_inline {
51            self.chunks.push(chunk.to_vec());
52            return;
53        }
54        if !self.spilled {
55            self.spilled = true;
56            let _ = fs::create_dir_all(&self.spill_dir);
57            let path = self.spill_dir.join(format!(
58                "{}-{}.{}",
59                std::process::id(),
60                std::time::SystemTime::now()
61                    .duration_since(std::time::UNIX_EPOCH)
62                    .map(|d| d.as_nanos())
63                    .unwrap_or(0),
64                self.kind
65            ));
66            self.spill_path = Some(path.clone());
67            if let Ok(mut f) = File::create(&path) {
68                // Dump whatever's already buffered so the spill file has
69                // the full stream from the start.
70                for c in &self.chunks {
71                    let _ = f.write_all(c);
72                    self.file_bytes_written += c.len();
73                }
74                self.spill_file = OpenOptions::new().append(true).open(&path).ok();
75            }
76        }
77        if self.file_bytes_written + chunk.len() > self.max_file {
78            return;
79        }
80        if let Some(f) = self.spill_file.as_mut() {
81            let _ = f.write_all(chunk);
82            self.file_bytes_written += chunk.len();
83        }
84    }
85
86    pub fn render(&self) -> HeadTailRender {
87        if !self.spilled {
88            let joined: Vec<u8> = self.chunks.iter().flatten().copied().collect();
89            return HeadTailRender {
90                text: String::from_utf8_lossy(&joined).into_owned(),
91                byte_cap: false,
92                log_path: None,
93            };
94        }
95        let mut head_bytes: Vec<u8> = Vec::with_capacity(self.max_inline);
96        for c in &self.chunks {
97            for &b in c {
98                if head_bytes.len() >= self.max_inline {
99                    break;
100                }
101                head_bytes.push(b);
102            }
103            if head_bytes.len() >= self.max_inline {
104                break;
105            }
106        }
107        let head_str = String::from_utf8_lossy(&head_bytes).into_owned();
108        let log_path = self
109            .spill_path
110            .as_ref()
111            .map(|p| p.to_string_lossy().into_owned());
112        let marker = format!(
113            "\n... (stream exceeded {} bytes; full log at {}) ...",
114            self.max_inline,
115            log_path.as_deref().unwrap_or("?")
116        );
117        HeadTailRender {
118            text: format!("{}{}", head_str, marker),
119            byte_cap: true,
120            log_path,
121        }
122    }
123
124    pub fn bytes_total(&self) -> usize {
125        self.total_bytes
126    }
127}
128
129pub struct HeadTailRender {
130    pub text: String,
131    pub byte_cap: bool,
132    pub log_path: Option<String>,
133}
134
135// ---- result text formatters ----
136
137pub struct FormatResultArgs<'a> {
138    pub command: &'a str,
139    pub exit_code: i32,
140    pub stdout: &'a str,
141    pub stderr: &'a str,
142    pub duration_ms: u64,
143    pub byte_cap: bool,
144    pub log_path: Option<&'a str>,
145    pub kind_ok: bool,
146}
147
148pub fn format_result_text(args: FormatResultArgs<'_>) -> String {
149    let header = format!("<command>{}</command>", args.command);
150    let exit_line = format!("<exit_code>{}</exit_code>", args.exit_code);
151    let stdout_block = format!("<stdout>\n{}\n</stdout>", args.stdout);
152    let stderr_block = format!("<stderr>\n{}\n</stderr>", args.stderr);
153    let hint = if args.byte_cap {
154        format!(
155            "(Output capped. Full log: {}. Read it with pagination if you need the middle.)",
156            args.log_path.unwrap_or("?")
157        )
158    } else if args.kind_ok {
159        format!(
160            "(Command completed in {}ms. exit=0.)",
161            args.duration_ms
162        )
163    } else {
164        format!(
165            "(Command exited nonzero in {}ms. Exit code: {}.)",
166            args.duration_ms, args.exit_code
167        )
168    };
169    format!(
170        "{}\n{}\n{}\n{}\n{}",
171        header, exit_line, stdout_block, stderr_block, hint
172    )
173}
174
175pub struct FormatTimeoutArgs<'a> {
176    pub command: &'a str,
177    pub stdout: &'a str,
178    pub stderr: &'a str,
179    pub reason: TimeoutReason,
180    pub duration_ms: u64,
181    pub partial_bytes: usize,
182    pub log_path: Option<&'a str>,
183}
184
185pub fn format_timeout_text(args: FormatTimeoutArgs<'_>) -> String {
186    let header = format!("<command>{}</command>", args.command);
187    let stdout_block = format!("<stdout>\n{}\n</stdout>", args.stdout);
188    let stderr_block = format!("<stderr>\n{}\n</stderr>", args.stderr);
189    let log_hint = args
190        .log_path
191        .map(|p| format!(" Full log: {}.", p))
192        .unwrap_or_default();
193    let hint = format!(
194        "(Command hit {} after {}ms. {} bytes captured. Kill signal: SIGTERM then SIGKILL.{} If the command is long-running, retry with background: true.)",
195        args.reason.as_str(),
196        args.duration_ms,
197        args.partial_bytes,
198        log_hint
199    );
200    format!("{}\n{}\n{}\n{}", header, stdout_block, stderr_block, hint)
201}
202
203pub fn format_background_started_text(command: &str, job_id: &str) -> String {
204    format!(
205        "<command>{}</command>\n<job_id>{}</job_id>\n(Background job started. Poll output with bash_output(job_id). Kill with bash_kill(job_id).)",
206        command, job_id
207    )
208}
209
210pub struct FormatBashOutputArgs<'a> {
211    pub job_id: &'a str,
212    pub running: bool,
213    pub exit_code: Option<i32>,
214    pub stdout: &'a str,
215    pub stderr: &'a str,
216    pub since_byte: u64,
217    pub returned_bytes: u64,
218    pub total_bytes: u64,
219}
220
221pub fn format_bash_output_text(args: FormatBashOutputArgs<'_>) -> String {
222    let next = args.since_byte + args.returned_bytes;
223    format!(
224        "<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: {}.)",
225        args.job_id,
226        args.running,
227        args.exit_code.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string()),
228        args.stdout,
229        args.stderr,
230        args.since_byte,
231        next,
232        args.total_bytes,
233        next,
234        args.running,
235    )
236}
237
238pub fn format_bash_kill_text(job_id: &str, signal: &str) -> String {
239    format!(
240        "<job_id>{}</job_id>\n({} sent. Poll bash_output to confirm termination.)",
241        job_id, signal
242    )
243}