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;
8pub const COMPLETION_OUTPUT_PREVIEW_BYTES: usize = 4 * 1024;
9pub const COMPLETION_OUTPUT_HEAD_BYTES: usize = 2 * 1024;
10pub const COMPLETION_OUTPUT_TAIL_BYTES: usize = 2 * 1024;
11
12pub const RAW_PASSTHROUGH_CAP_BYTES: usize = 50 * 1024;
13pub const RAW_PASSTHROUGH_HEAD_BYTES: usize = 20 * 1024;
14pub const RAW_PASSTHROUGH_TAIL_BYTES: usize = 30 * 1024;
15
16pub const STRUCTURED_OUTPUT_CAP_BYTES: usize = 50 * 1024;
17
18pub const COMPRESS_INPUT_CAP_BYTES: usize = 10 * 1024 * 1024;
19pub const COMPRESS_INPUT_HEAD_BYTES: usize = 4 * 1024 * 1024;
20pub const COMPRESS_INPUT_TAIL_BYTES: usize = 6 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CappedText {
24 pub text: String,
25 pub truncated: bool,
26}
27
28pub fn cap_final_output(input: &str) -> CappedText {
29 cap_head_tail(
30 input,
31 FINAL_OUTPUT_CAP_BYTES,
32 FINAL_OUTPUT_HEAD_BYTES,
33 FINAL_OUTPUT_TAIL_BYTES,
34 )
35}
36
37pub fn cap_final_output_with_marker(input: &str, marker: &str) -> CappedText {
38 cap_head_tail_with_marker(
39 input,
40 FINAL_OUTPUT_CAP_BYTES,
41 FINAL_OUTPUT_HEAD_BYTES,
42 FINAL_OUTPUT_TAIL_BYTES,
43 marker,
44 )
45}
46
47pub fn cap_completion_output(input: &str) -> CappedText {
48 cap_head_tail(
49 input,
50 COMPLETION_OUTPUT_PREVIEW_BYTES,
51 COMPLETION_OUTPUT_HEAD_BYTES,
52 COMPLETION_OUTPUT_TAIL_BYTES,
53 )
54}
55
56pub fn cap_completion_output_with_marker(input: &str, marker: &str) -> CappedText {
57 cap_head_tail_with_marker(
58 input,
59 COMPLETION_OUTPUT_PREVIEW_BYTES,
60 COMPLETION_OUTPUT_HEAD_BYTES,
61 COMPLETION_OUTPUT_TAIL_BYTES,
62 marker,
63 )
64}
65
66pub fn cap_head_tail(
67 input: &str,
68 threshold_bytes: usize,
69 keep_head: usize,
70 keep_tail: usize,
71) -> CappedText {
72 if input.len() <= threshold_bytes {
73 return CappedText {
74 text: input.to_string(),
75 truncated: false,
76 };
77 }
78
79 let head_end = floor_char_boundary(input, keep_head.min(input.len()));
80 let mut tail_start = ceil_char_boundary(input, input.len().saturating_sub(keep_tail));
81
82 if head_end >= tail_start {
83 return CappedText {
84 text: input.to_string(),
85 truncated: false,
86 };
87 }
88
89 let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
90 0
91 } else {
92 1
93 };
94 loop {
95 let truncated_bytes = tail_start - head_end;
96 let marker_len = marker_prefix_len
97 + "...<truncated ".len()
98 + truncated_bytes.to_string().len()
99 + " bytes>...\n".len();
100 let max_tail = threshold_bytes.saturating_sub(head_end + marker_len);
101 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(max_tail));
102 if adjusted_tail_start <= tail_start {
103 break;
104 }
105 tail_start = adjusted_tail_start;
106 if head_end >= tail_start {
107 return CappedText {
108 text: input.to_string(),
109 truncated: false,
110 };
111 }
112 }
113
114 let truncated_bytes = tail_start - head_end;
115 let mut output = String::with_capacity(threshold_bytes.min(input.len()));
116 output.push_str(&input[..head_end]);
117 if !output.ends_with('\n') {
118 output.push('\n');
119 }
120 output.push_str("...<truncated ");
121 output.push_str(&truncated_bytes.to_string());
122 output.push_str(" bytes>...\n");
123 output.push_str(&input[tail_start..]);
124
125 CappedText {
126 text: output,
127 truncated: true,
128 }
129}
130
131pub fn cap_head_tail_with_marker(
132 input: &str,
133 threshold_bytes: usize,
134 keep_head: usize,
135 keep_tail: usize,
136 marker: &str,
137) -> CappedText {
138 if marker.is_empty() {
139 return cap_head_tail(input, threshold_bytes, keep_head, keep_tail);
140 }
141 if input.len() <= threshold_bytes {
142 let with_marker = append_marker_line(input, marker);
143 if with_marker.len() <= threshold_bytes {
144 return CappedText {
145 text: with_marker,
146 truncated: true,
147 };
148 }
149 }
150
151 let mut head_budget = keep_head.min(input.len());
152 let mut tail_budget = keep_tail.min(input.len());
153 let mut seen_budgets = Vec::new();
154
155 for _ in 0..8 {
156 let head_end = floor_char_boundary(input, head_budget);
157 let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
158 0
159 } else {
160 1
161 };
162 let marker_len = marker_prefix_len + marker.len() + 1;
163 let available = threshold_bytes.saturating_sub(marker_len);
164 let next_head = keep_head.min(available).min(input.len());
165 let next_tail = keep_tail
166 .min(available.saturating_sub(next_head))
167 .min(input.len().saturating_sub(next_head));
168
169 if next_head == head_budget && next_tail == tail_budget {
170 break;
171 }
172 if seen_budgets.contains(&(next_head, next_tail)) {
173 if next_head.saturating_add(next_tail) < head_budget.saturating_add(tail_budget) {
174 head_budget = next_head;
175 tail_budget = next_tail;
176 }
177 break;
178 }
179 seen_budgets.push((head_budget, tail_budget));
180 head_budget = next_head;
181 tail_budget = next_tail;
182 }
183
184 let head_end = floor_char_boundary(input, head_budget);
185 let tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
186 let tail_start = if head_end >= tail_start {
187 input.len()
188 } else {
189 tail_start
190 };
191
192 CappedText {
193 text: marker_capped_output(input, head_end, tail_start, marker, threshold_bytes),
194 truncated: true,
195 }
196}
197
198fn append_marker_line(input: &str, marker: &str) -> String {
199 if input.is_empty() {
200 return marker.to_string();
201 }
202 let mut output = input.trim_end().to_string();
203 output.push('\n');
204 output.push_str(marker);
205 output
206}
207
208fn marker_capped_output(
209 input: &str,
210 head_end: usize,
211 tail_start: usize,
212 marker: &str,
213 threshold_bytes: usize,
214) -> String {
215 let output = marker_output(input, head_end, tail_start, marker);
216 if output.len() <= threshold_bytes {
217 return output;
218 }
219
220 let separator_len = usize::from(head_end > 0 && !input[..head_end].ends_with('\n'));
221 let marker_line_len = marker.len().saturating_add(1);
222 let tail_budget = threshold_bytes.saturating_sub(head_end + separator_len + marker_line_len);
223 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
224 let output = marker_output(input, head_end, tail_start.max(adjusted_tail_start), marker);
225 if output.len() <= threshold_bytes {
226 return output;
227 }
228
229 let tail_budget = threshold_bytes.saturating_sub(marker_line_len);
232 let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
233 let output = marker_output(input, 0, adjusted_tail_start, marker);
234 if output.len() <= threshold_bytes {
235 return output;
236 }
237
238 marker[..floor_char_boundary(marker, threshold_bytes.min(marker.len()))].to_string()
239}
240
241fn marker_output(input: &str, head_end: usize, tail_start: usize, marker: &str) -> String {
242 let mut output = String::new();
243 output.push_str(&input[..head_end]);
244 if head_end > 0 && !output.ends_with('\n') {
245 output.push('\n');
246 }
247 output.push_str(marker);
248 if tail_start < input.len() {
249 output.push('\n');
250 output.push_str(&input[tail_start..]);
251 }
252 output
253}
254
255pub fn quote_path(path: &str) -> String {
256 let escaped = path.replace('"', "\\\"");
264 format!("\"{escaped}\"")
265}
266
267pub fn json_output_pointer(total_bytes: u64, path: &str) -> String {
268 let kb = total_bytes.div_ceil(1024);
269 format!(
270 "[JSON output {kb} KB; full output: read {}]",
271 quote_path(path)
272 )
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn quote_path_preserves_windows_backslashes() {
281 assert_eq!(
286 quote_path(r"C:\Users\foo\stdout"),
287 r#""C:\Users\foo\stdout""#
288 );
289 assert_eq!(quote_path(r"C:\a\b"), r#""C:\a\b""#);
290 assert_eq!(quote_path("/tmp/out"), r#""/tmp/out""#);
291 assert_eq!(quote_path(r#"a"b"#), r#""a\"b""#);
293 }
294
295 #[test]
296 fn head_tail_cap_respects_utf8_boundaries() {
297 let input = format!("{}{}", "🦀".repeat(4_000), "tail");
298 let capped = cap_head_tail(&input, 128, 64, 64);
299 assert!(capped.truncated);
300 assert!(capped.text.is_char_boundary(capped.text.len()));
301 assert!(capped.text.contains("...<truncated "));
302 assert!(capped.text.len() <= 128);
303 assert!(capped.text.ends_with("tail"));
304 }
305
306 #[test]
307 fn marker_cap_respects_newline_boundary_oscillation() {
308 let capped = cap_head_tail_with_marker("01234567\nabcdef", 20, 15, 0, "123456789");
309
310 assert!(capped.truncated);
311 assert!(capped.text.len() <= 20, "{}", capped.text.len());
312 assert!(capped.text.is_char_boundary(capped.text.len()));
313 assert!(!capped.text.starts_with('\n'));
314 }
315
316 #[test]
317 fn marker_cap_respects_tiny_threshold_and_long_marker() {
318 let capped = cap_head_tail_with_marker("abcdef", 5, 0, 0, "[very long recovery marker]");
319
320 assert!(capped.truncated);
321 assert!(capped.text.len() <= 5, "{}", capped.text.len());
322 assert!(capped.text.is_char_boundary(capped.text.len()));
323 assert!(!capped.text.starts_with('\n'));
324 }
325
326 #[test]
327 fn marker_cap_accounts_for_marker_when_body_is_under_threshold() {
328 let capped = cap_head_tail_with_marker("0123456789", 10, 10, 0, "[x]");
329
330 assert!(capped.truncated);
331 assert!(capped.text.len() <= 10, "{}", capped.text.len());
332 assert!(capped.text.contains("[x]"));
333 }
334}