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 {
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 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
135pub 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}