1use crate::compress::generic::{ceil_char_boundary, floor_char_boundary};
2
3pub const FINAL_OUTPUT_CAP_BYTES: usize = 16 * 1024;
4pub const FINAL_OUTPUT_HEAD_BYTES: usize = 6 * 1024;
5pub const FINAL_OUTPUT_TAIL_BYTES: usize = 10 * 1024;
6
7pub const RUNNING_OUTPUT_PREVIEW_BYTES: usize = 8 * 1024;
8
9pub const COMPLETION_SUCCESS_PREVIEW_BYTES: usize = 600;
20pub const COMPLETION_SUCCESS_HEAD_BYTES: usize = 0;
21pub const COMPLETION_SUCCESS_TAIL_BYTES: usize = 600;
22pub const COMPLETION_FAILURE_PREVIEW_BYTES: usize = 2304;
23pub const COMPLETION_FAILURE_HEAD_BYTES: usize = 512;
24pub const COMPLETION_FAILURE_TAIL_BYTES: usize = 1792;
25
26pub const RAW_PASSTHROUGH_CAP_BYTES: usize = 50 * 1024;
27pub const RAW_PASSTHROUGH_HEAD_BYTES: usize = 20 * 1024;
28pub const RAW_PASSTHROUGH_TAIL_BYTES: usize = 30 * 1024;
29
30pub const STRUCTURED_OUTPUT_CAP_BYTES: usize = 50 * 1024;
31
32pub const COMPRESS_INPUT_CAP_BYTES: usize = 10 * 1024 * 1024;
33pub const COMPRESS_INPUT_HEAD_BYTES: usize = 4 * 1024 * 1024;
34pub const COMPRESS_INPUT_TAIL_BYTES: usize = 6 * 1024 * 1024;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CappedText {
38 pub text: String,
39 pub truncated: bool,
40}
41
42pub fn cap_final_output(input: &str) -> CappedText {
43 cap_head_tail(
44 input,
45 FINAL_OUTPUT_CAP_BYTES,
46 FINAL_OUTPUT_HEAD_BYTES,
47 FINAL_OUTPUT_TAIL_BYTES,
48 )
49}
50
51pub fn cap_final_output_with_marker(input: &str, marker: &str) -> CappedText {
52 cap_head_tail_with_marker(
53 input,
54 FINAL_OUTPUT_CAP_BYTES,
55 FINAL_OUTPUT_HEAD_BYTES,
56 FINAL_OUTPUT_TAIL_BYTES,
57 marker,
58 )
59}
60
61pub fn completion_preview_threshold(exit_ok: bool) -> usize {
63 completion_caps(exit_ok).0
64}
65
66fn completion_caps(exit_ok: bool) -> (usize, usize, usize) {
67 if exit_ok {
68 (
69 COMPLETION_SUCCESS_PREVIEW_BYTES,
70 COMPLETION_SUCCESS_HEAD_BYTES,
71 COMPLETION_SUCCESS_TAIL_BYTES,
72 )
73 } else {
74 (
75 COMPLETION_FAILURE_PREVIEW_BYTES,
76 COMPLETION_FAILURE_HEAD_BYTES,
77 COMPLETION_FAILURE_TAIL_BYTES,
78 )
79 }
80}
81
82pub fn cap_completion_output(input: &str, exit_ok: bool) -> CappedText {
85 let (threshold, head, tail) = completion_caps(exit_ok);
86 cap_head_tail(input, threshold, head, tail)
87}
88
89pub fn cap_completion_output_with_marker(input: &str, marker: &str, exit_ok: bool) -> CappedText {
90 let (threshold, head, tail) = completion_caps(exit_ok);
91 cap_head_tail_with_marker(input, threshold, head, tail, marker)
92}
93
94pub fn cap_head_tail(
95 input: &str,
96 threshold_bytes: usize,
97 keep_head: usize,
98 keep_tail: usize,
99) -> CappedText {
100 if input.len() <= threshold_bytes {
101 return CappedText {
102 text: input.to_string(),
103 truncated: false,
104 };
105 }
106
107 let head_end = floor_char_boundary(input, keep_head.min(input.len()));
108 let mut tail_start = ceil_char_boundary(input, input.len().saturating_sub(keep_tail));
109
110 if head_end >= tail_start {
111 return CappedText {
112 text: input.to_string(),
113 truncated: false,
114 };
115 }
116
117 let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
118 0
119 } else {
120 1
121 };
122 loop {
123 let truncated_bytes = tail_start - head_end;
124 let marker_len = marker_prefix_len
125 + "...<truncated ".len()
126 + truncated_bytes.to_string().len()
127 + " bytes>...\n".len();
128 let max_tail = threshold_bytes.saturating_sub(head_end + marker_len);
129 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(max_tail));
130 if adjusted_tail_start <= tail_start {
131 break;
132 }
133 tail_start = adjusted_tail_start;
134 if head_end >= tail_start {
135 return CappedText {
136 text: input.to_string(),
137 truncated: false,
138 };
139 }
140 }
141
142 let truncated_bytes = tail_start - head_end;
143 let mut output = String::with_capacity(threshold_bytes.min(input.len()));
144 output.push_str(&input[..head_end]);
145 if head_end > 0 && !output.ends_with('\n') {
151 output.push('\n');
152 }
153 output.push_str("...<truncated ");
154 output.push_str(&truncated_bytes.to_string());
155 output.push_str(" bytes>...\n");
156 output.push_str(&input[tail_start..]);
157
158 CappedText {
159 text: output,
160 truncated: true,
161 }
162}
163
164pub fn cap_head_tail_with_marker(
165 input: &str,
166 threshold_bytes: usize,
167 keep_head: usize,
168 keep_tail: usize,
169 marker: &str,
170) -> CappedText {
171 if marker.is_empty() {
172 return cap_head_tail(input, threshold_bytes, keep_head, keep_tail);
173 }
174 if input.len() <= threshold_bytes {
175 let with_marker = append_marker_line(input, marker);
176 if with_marker.len() <= threshold_bytes {
177 return CappedText {
178 text: with_marker,
179 truncated: true,
180 };
181 }
182 }
183
184 let mut head_budget = keep_head.min(input.len());
185 let mut tail_budget = keep_tail.min(input.len());
186 let mut seen_budgets = Vec::new();
187
188 for _ in 0..8 {
189 let head_end = floor_char_boundary(input, head_budget);
190 let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
191 0
192 } else {
193 1
194 };
195 let marker_len = marker_prefix_len + marker.len() + 1;
196 let available = threshold_bytes.saturating_sub(marker_len);
197 let next_head = keep_head.min(available).min(input.len());
198 let next_tail = keep_tail
199 .min(available.saturating_sub(next_head))
200 .min(input.len().saturating_sub(next_head));
201
202 if next_head == head_budget && next_tail == tail_budget {
203 break;
204 }
205 if seen_budgets.contains(&(next_head, next_tail)) {
206 if next_head.saturating_add(next_tail) < head_budget.saturating_add(tail_budget) {
207 head_budget = next_head;
208 tail_budget = next_tail;
209 }
210 break;
211 }
212 seen_budgets.push((head_budget, tail_budget));
213 head_budget = next_head;
214 tail_budget = next_tail;
215 }
216
217 let head_end = floor_char_boundary(input, head_budget);
218 let tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
219 let tail_start = if head_end >= tail_start {
220 input.len()
221 } else {
222 tail_start
223 };
224
225 CappedText {
226 text: marker_capped_output(input, head_end, tail_start, marker, threshold_bytes),
227 truncated: true,
228 }
229}
230
231fn append_marker_line(input: &str, marker: &str) -> String {
232 if input.is_empty() {
233 return marker.to_string();
234 }
235 let mut output = input.trim_end().to_string();
236 output.push('\n');
237 output.push_str(marker);
238 output
239}
240
241fn marker_capped_output(
242 input: &str,
243 head_end: usize,
244 tail_start: usize,
245 marker: &str,
246 threshold_bytes: usize,
247) -> String {
248 let output = marker_output(input, head_end, tail_start, marker);
249 if output.len() <= threshold_bytes {
250 return output;
251 }
252
253 let separator_len = usize::from(head_end > 0 && !input[..head_end].ends_with('\n'));
254 let marker_line_len = marker.len().saturating_add(1);
255 let tail_budget = threshold_bytes.saturating_sub(head_end + separator_len + marker_line_len);
256 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
257 let output = marker_output(input, head_end, tail_start.max(adjusted_tail_start), marker);
258 if output.len() <= threshold_bytes {
259 return output;
260 }
261
262 let tail_budget = threshold_bytes.saturating_sub(marker_line_len);
265 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
266 let output = marker_output(input, 0, adjusted_tail_start, marker);
267 if output.len() <= threshold_bytes {
268 return output;
269 }
270
271 marker[..floor_char_boundary(marker, threshold_bytes.min(marker.len()))].to_string()
272}
273
274fn marker_output(input: &str, head_end: usize, tail_start: usize, marker: &str) -> String {
275 let mut output = String::new();
276 output.push_str(&input[..head_end]);
277 if head_end > 0 && !output.ends_with('\n') {
278 output.push('\n');
279 }
280 output.push_str(marker);
281 if tail_start < input.len() {
282 output.push('\n');
283 output.push_str(&input[tail_start..]);
284 }
285 output
286}
287
288pub fn quote_path(path: &str) -> String {
289 let escaped = path.replace('"', "\\\"");
297 format!("\"{escaped}\"")
298}
299
300pub fn json_output_pointer(total_bytes: u64, path: &str) -> String {
301 let kb = total_bytes.div_ceil(1024);
302 format!(
303 "[JSON output {kb} KB; full output: read {}]",
304 quote_path(path)
305 )
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn quote_path_preserves_windows_backslashes() {
314 assert_eq!(
319 quote_path(r"C:\Users\foo\stdout"),
320 r#""C:\Users\foo\stdout""#
321 );
322 assert_eq!(quote_path(r"C:\a\b"), r#""C:\a\b""#);
323 assert_eq!(quote_path("/tmp/out"), r#""/tmp/out""#);
324 assert_eq!(quote_path(r#"a"b"#), r#""a\"b""#);
326 }
327
328 #[test]
329 fn head_tail_cap_respects_utf8_boundaries() {
330 let input = format!("{}{}", "🦀".repeat(4_000), "tail");
331 let capped = cap_head_tail(&input, 128, 64, 64);
332 assert!(capped.truncated);
333 assert!(capped.text.is_char_boundary(capped.text.len()));
334 assert!(capped.text.contains("...<truncated "));
335 assert!(capped.text.len() <= 128);
336 assert!(capped.text.ends_with("tail"));
337 }
338
339 #[test]
340 fn marker_cap_respects_newline_boundary_oscillation() {
341 let capped = cap_head_tail_with_marker("01234567\nabcdef", 20, 15, 0, "123456789");
342
343 assert!(capped.truncated);
344 assert!(capped.text.len() <= 20, "{}", capped.text.len());
345 assert!(capped.text.is_char_boundary(capped.text.len()));
346 assert!(!capped.text.starts_with('\n'));
347 }
348
349 #[test]
350 fn marker_cap_respects_tiny_threshold_and_long_marker() {
351 let capped = cap_head_tail_with_marker("abcdef", 5, 0, 0, "[very long recovery marker]");
352
353 assert!(capped.truncated);
354 assert!(capped.text.len() <= 5, "{}", capped.text.len());
355 assert!(capped.text.is_char_boundary(capped.text.len()));
356 assert!(!capped.text.starts_with('\n'));
357 }
358
359 #[test]
360 fn marker_cap_accounts_for_marker_when_body_is_under_threshold() {
361 let capped = cap_head_tail_with_marker("0123456789", 10, 10, 0, "[x]");
362
363 assert!(capped.truncated);
364 assert!(capped.text.len() <= 10, "{}", capped.text.len());
365 assert!(capped.text.contains("[x]"));
366 }
367
368 #[test]
369 fn completion_cap_success_is_tail_only_and_small() {
370 let input = format!("HEAD-NOISE\n{}\nFINAL SUMMARY LINE", "x".repeat(8_000));
374 let capped = cap_completion_output(&input, true);
375 assert!(capped.truncated);
376 assert!(
377 capped.text.len() <= COMPLETION_SUCCESS_PREVIEW_BYTES,
378 "{}",
379 capped.text.len()
380 );
381 assert!(capped.text.ends_with("FINAL SUMMARY LINE"));
382 assert!(!capped.text.contains("HEAD-NOISE"));
383 }
384
385 #[test]
386 fn completion_cap_failure_keeps_head_and_larger_tail() {
387 let input = format!(
390 "FIRST-ERROR-CONTEXT\n{}\nTraceback: ModuleNotFoundError",
391 "y".repeat(8_000)
392 );
393 let capped = cap_completion_output(&input, false);
394 assert!(capped.truncated);
395 assert!(
396 capped.text.len() <= COMPLETION_FAILURE_PREVIEW_BYTES,
397 "{}",
398 capped.text.len()
399 );
400 assert!(capped.text.starts_with("FIRST-ERROR-CONTEXT"));
401 assert!(capped.text.ends_with("Traceback: ModuleNotFoundError"));
402 const {
404 assert!(COMPLETION_FAILURE_PREVIEW_BYTES > COMPLETION_SUCCESS_PREVIEW_BYTES);
405 assert!(COMPLETION_FAILURE_PREVIEW_BYTES < 4 * 1024);
406 }
407 }
408
409 #[test]
410 fn completion_cap_passes_short_output_through_untouched() {
411 let input = "ok: 12 tests passed";
412 for exit_ok in [true, false] {
413 let capped = cap_completion_output(input, exit_ok);
414 assert!(!capped.truncated);
415 assert_eq!(capped.text, input);
416 }
417 }
418}