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
7pub 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 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
181pub 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}