1use alloc::{
4 borrow::Cow,
5 format,
6 string::{String, ToString},
7 sync::Arc,
8};
9
10use super::{
11 alignment::{MAX_LINE_SCAN_WINDOW, TruncationAlignment},
12 outcome::TruncationOutcome,
13 placeholder::MarkerPlaceholder,
14 ratio::HeadRatio,
15 sink::{OutputSpillSink, SpillContext, SpillDisposition, SpillError, SpillRef},
16};
17use crate::types::{
18 ToolOutput,
19 quarantine::{
20 CLOSE_FRAME, FRAME_OVERHEAD, OPEN_FRAME, TOOL_OUTPUT_QUARANTINE_TAG,
21 unquarantine_tool_output,
22 },
23};
24
25pub const SPILL_FAILURE_NOTICE_PREFIX: &str = " WARNING: full output could NOT be retained (";
27
28pub const SPILL_FAILURE_NOTICE_SUFFIX: &str = "); the omitted bytes are unrecoverable.";
30
31pub const MINIMAL_TRUNCATION_MARKER: &str = "\n[TRUNCATED: bytes omitted]\n";
36
37pub const MINIMAL_TRUNCATION_MARKER_SPILL_FAILED: &str =
39 "\n[TRUNCATED: bytes omitted; NOT retained]\n";
40
41const COMPACT_TRUNCATION_MARKER: &str = "[TRUNCATED: bytes omitted]";
44
45const COMPACT_TRUNCATION_MARKER_SPILL_FAILED: &str = "[TRUNCATED: bytes omitted; NOT retained]";
47
48fn spill_failure_notice(error: &SpillError) -> String {
50 format!("{SPILL_FAILURE_NOTICE_PREFIX}{error}{SPILL_FAILURE_NOTICE_SUFFIX}")
51}
52
53#[derive(Debug, Clone)]
55pub struct TruncationPolicy {
56 pub max_bytes: usize,
58 pub head_ratio: HeadRatio,
61 pub alignment: TruncationAlignment,
63 pub marker_template: Option<String>,
65 pub spill_sink: Option<Arc<dyn OutputSpillSink>>,
67}
68
69impl PartialEq for TruncationPolicy {
70 fn eq(&self, other: &Self) -> bool {
71 self.max_bytes == other.max_bytes
72 && self.head_ratio == other.head_ratio
73 && self.alignment == other.alignment
74 && self.marker_template == other.marker_template
75 && match (&self.spill_sink, &other.spill_sink) {
76 (None, None) => true,
77 (Some(a), Some(b)) => Arc::ptr_eq(a, b),
78 _ => false,
79 }
80 }
81}
82
83impl Eq for TruncationPolicy {}
84
85impl TruncationPolicy {
86 #[must_use]
89 pub const fn new(max_bytes: usize) -> Self {
90 Self {
91 max_bytes,
92 head_ratio: HeadRatio::BALANCED,
93 alignment: TruncationAlignment::CharBoundary,
94 marker_template: None,
95 spill_sink: None,
96 }
97 }
98
99 #[must_use]
101 pub fn with_head_ratio(mut self, ratio: impl Into<HeadRatio>) -> Self {
102 self.head_ratio = ratio.into();
103 self
104 }
105
106 #[must_use]
108 pub const fn with_alignment(mut self, alignment: TruncationAlignment) -> Self {
109 self.alignment = alignment;
110 self
111 }
112
113 #[must_use]
119 pub fn with_marker(mut self, marker: impl Into<String>) -> Self {
120 self.marker_template = Some(marker.into());
121 self
122 }
123
124 #[must_use]
126 pub fn with_spill_sink(mut self, sink: Arc<dyn OutputSpillSink>) -> Self {
127 self.spill_sink = Some(sink);
128 self
129 }
130
131 #[must_use]
135 pub fn format_marker(&self, omitted_bytes: usize) -> String {
136 self.format_marker_detailed(
137 omitted_bytes,
138 omitted_bytes,
139 0,
140 omitted_bytes,
141 &SpillDisposition::NotConfigured,
142 )
143 }
144
145 #[must_use]
152 pub fn format_marker_detailed(
153 &self,
154 omitted_bytes: usize,
155 original_bytes: usize,
156 offset_start: usize,
157 offset_end: usize,
158 spill: &SpillDisposition,
159 ) -> String {
160 let omitted_str = omitted_bytes.to_string();
161 let mut marker = self.marker_template.as_ref().map_or_else(
162 || match spill {
163 SpillDisposition::Spilled(handle) => format!(
164 "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}). Full output retained as ref=\"{handle}\" — inspect with read_observation(ref, pattern, offset, length)]\n"
165 ),
166 SpillDisposition::Failed(error) => format!(
167 "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}).{notice}]\n",
168 notice = spill_failure_notice(error)
169 ),
170 SpillDisposition::NotConfigured => {
171 format!("\n[TRUNCATED: {omitted_str} bytes omitted]\n")
172 }
173 },
174 |template| {
175 let orig_str = original_bytes.to_string();
176 let start_str = offset_start.to_string();
177 let end_str = offset_end.to_string();
178 let ref_str = spill.spill_ref().map_or("", SpillRef::as_str);
179
180 template
181 .replace(MarkerPlaceholder::TOKEN_OMITTED_BYTES, &omitted_str)
182 .replace(MarkerPlaceholder::TOKEN_ORIGINAL_BYTES, &orig_str)
183 .replace(MarkerPlaceholder::TOKEN_OFFSET_START, &start_str)
184 .replace(MarkerPlaceholder::TOKEN_OFFSET_END, &end_str)
185 .replace(MarkerPlaceholder::TOKEN_SPILL_REF, ref_str)
186 .replace(MarkerPlaceholder::DEPRECATED_COUNT, &omitted_str)
187 .replace(MarkerPlaceholder::DEPRECATED_BYTES, &omitted_str)
188 .replace(MarkerPlaceholder::DEPRECATED_N, &omitted_str)
189 },
190 );
191
192 if let Some(error) = spill.error()
194 && self.marker_template.is_some()
195 {
196 marker.push_str(&spill_failure_notice(error));
197 }
198
199 marker
200 }
201
202 #[must_use]
216 pub fn truncate<'a>(&self, input: &'a str) -> Cow<'a, str> {
217 self.truncate_lossy_with_context(input, &SpillContext::ANONYMOUS)
218 .text
219 }
220
221 #[must_use]
226 pub fn truncate_sanitized<'a>(&self, input: &'a str) -> Cow<'a, str> {
227 self.truncate_sanitized_lossy_with_context(input, &SpillContext::ANONYMOUS)
228 .text
229 }
230
231 #[must_use]
235 pub fn truncate_quarantined(&self, input: &str) -> String {
236 self.truncate_quarantined_lossy_with_context(input, &SpillContext::ANONYMOUS)
237 .text
238 .into_owned()
239 }
240
241 #[must_use]
244 pub fn truncate_lossy_with_context<'a>(
245 &self,
246 input: &'a str,
247 ctx: &SpillContext<'_>,
248 ) -> TruncationOutcome<'a> {
249 if let Some(outcome) = Self::passthrough(input, self.max_bytes) {
250 return outcome;
251 }
252
253 let spill = self.resolve_spill_logged(ctx, input);
254 self.truncate_oversized_auto(input, &spill)
255 }
256
257 #[must_use]
259 pub fn truncate_sanitized_lossy_with_context<'a>(
260 &self,
261 input: &'a str,
262 ctx: &SpillContext<'_>,
263 ) -> TruncationOutcome<'a> {
264 if unquarantine_tool_output(input).is_some() {
265 return self.truncate_lossy_with_context(input, ctx);
266 }
267
268 if input.len() <= self.max_bytes {
269 let sanitized = md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
270 if sanitized.len() <= self.max_bytes {
271 return TruncationOutcome {
272 text: sanitized,
273 original_len: input.len(),
274 omitted: None,
275 spill_ref: None,
276 };
277 }
278 }
279
280 let spill = self.resolve_spill_logged(ctx, input);
281 self.truncate_oversized_sanitized(input, &spill)
282 }
283
284 #[must_use]
286 pub fn truncate_quarantined_lossy_with_context<'a>(
287 &self,
288 input: &'a str,
289 ctx: &SpillContext<'_>,
290 ) -> TruncationOutcome<'a> {
291 if unquarantine_tool_output(input).is_some() {
292 return self.truncate_lossy_with_context(input, ctx);
293 }
294
295 let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
296 if input.len() <= inner_budget && self.max_bytes >= FRAME_OVERHEAD {
297 let sanitized = md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
298 if sanitized.len() <= inner_budget {
299 return TruncationOutcome {
300 text: Cow::Owned(format!("{OPEN_FRAME}{sanitized}{CLOSE_FRAME}")),
301 original_len: input.len(),
302 omitted: None,
303 spill_ref: None,
304 };
305 }
306 }
307
308 let spill = self.resolve_spill_logged(ctx, input);
309 self.truncate_and_wrap_quarantine(input, input.len(), 0, &spill)
310 }
311
312 pub fn truncate_detailed<'a>(
318 &self,
319 input: &'a str,
320 ) -> Result<TruncationOutcome<'a>, SpillError> {
321 self.truncate_detailed_with_context(input, &SpillContext::ANONYMOUS)
322 }
323
324 pub fn truncate_detailed_with_context<'a>(
330 &self,
331 input: &'a str,
332 ctx: &SpillContext<'_>,
333 ) -> Result<TruncationOutcome<'a>, SpillError> {
334 Self::passthrough(input, self.max_bytes).map_or_else(
335 || match self.resolve_spill(ctx, input) {
336 SpillDisposition::Failed(error) => Err(error),
337 spill => Ok(self.truncate_oversized_auto(input, &spill)),
338 },
339 Ok,
340 )
341 }
342
343 fn passthrough(input: &str, max_bytes: usize) -> Option<TruncationOutcome<'_>> {
348 let total_bytes = input.len();
349 (total_bytes <= max_bytes).then_some(TruncationOutcome {
350 text: Cow::Borrowed(input),
351 original_len: total_bytes,
352 omitted: None,
353 spill_ref: None,
354 })
355 }
356
357 fn resolve_spill(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
359 self.spill_sink
360 .as_ref()
361 .map_or(SpillDisposition::NotConfigured, |sink| {
362 match sink.spill(ctx, input) {
363 Ok(handle) => SpillDisposition::Spilled(handle),
364 Err(error) => SpillDisposition::Failed(error),
365 }
366 })
367 }
368
369 fn resolve_spill_logged(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
370 let spill = self.resolve_spill(ctx, input);
371 if let Some(error) = spill.error() {
372 tracing::error!(
373 error = %error,
374 tool = ctx.tool_name,
375 "spill sink failed; omitted bytes are unrecoverable and the truncation marker will say so"
376 );
377 }
378 spill
379 }
380
381 fn truncate_oversized_auto<'a>(
384 &self,
385 input: &'a str,
386 spill: &SpillDisposition,
387 ) -> TruncationOutcome<'a> {
388 if let Some(inner) = unquarantine_tool_output(input) {
389 return self.truncate_and_wrap_quarantine(inner, input.len(), OPEN_FRAME.len(), spill);
390 }
391 if !md_tmpl_core::has_untrusted_breakout(input, TOOL_OUTPUT_QUARANTINE_TAG) {
392 return self.truncate_oversized_sanitized(input, spill);
393 }
394 self.truncate_oversized(input, spill)
395 }
396
397 fn truncate_and_wrap_quarantine<'a>(
400 &self,
401 inner: &str,
402 outer_original_len: usize,
403 offset_bias: usize,
404 spill: &SpillDisposition,
405 ) -> TruncationOutcome<'a> {
406 let inner_len = inner.len();
407 let spill_ref = spill.spill_ref().cloned();
408 let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
409
410 if inner_budget < MINIMAL_TRUNCATION_MARKER.len() {
411 let notice = match spill {
412 SpillDisposition::Failed(_) => COMPACT_TRUNCATION_MARKER_SPILL_FAILED,
413 SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
414 COMPACT_TRUNCATION_MARKER
415 }
416 };
417 let mut head_end = inner_budget.saturating_sub(notice.len()).min(inner_len);
418 while head_end > 0 && !inner.is_char_boundary(head_end) {
419 head_end -= 1;
420 }
421 let mut body = String::with_capacity(head_end + notice.len());
422 body.push_str(&inner[..head_end]);
423 body.push_str(notice);
424 let sanitized_body =
425 md_tmpl_core::sanitize_untrusted_str(&body, TOOL_OUTPUT_QUARANTINE_TAG);
426 let final_body = if head_end > 0 && sanitized_body.len() > inner_budget {
427 notice
428 } else {
429 sanitized_body.as_ref()
430 };
431 return TruncationOutcome {
432 text: Cow::Owned(format!("{OPEN_FRAME}{final_body}{CLOSE_FRAME}")),
433 original_len: outer_original_len,
434 omitted: Some(offset_bias..(offset_bias + inner_len)),
435 spill_ref,
436 };
437 }
438
439 let inner_policy = Self {
440 max_bytes: inner_budget,
441 head_ratio: self.head_ratio,
442 alignment: self.alignment,
443 marker_template: self.marker_template.clone(),
444 spill_sink: None,
445 };
446 let inner_outcome = inner_policy.truncate_oversized_sanitized(inner, spill);
447 let omitted = inner_outcome
448 .omitted
449 .map(|r| (r.start + offset_bias)..(r.end + offset_bias))
450 .or(Some(offset_bias..(offset_bias + inner_len)));
451
452 TruncationOutcome {
453 text: Cow::Owned(format!("{OPEN_FRAME}{}{CLOSE_FRAME}", inner_outcome.text)),
454 original_len: outer_original_len,
455 omitted,
456 spill_ref,
457 }
458 }
459
460 fn truncate_oversized_sanitized<'a>(
465 &self,
466 input: &'a str,
467 spill: &SpillDisposition,
468 ) -> TruncationOutcome<'a> {
469 let total_bytes = input.len();
470 let spill_ref = spill.spill_ref().cloned();
471
472 if self.max_bytes == 0 {
473 return TruncationOutcome {
474 text: Cow::Borrowed(""),
475 original_len: total_bytes,
476 omitted: Some(0..total_bytes),
477 spill_ref,
478 };
479 }
480
481 let est_omitted = total_bytes.saturating_sub(self.max_bytes);
482 let sample_marker =
483 self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
484 let sample_marker_sanitized =
485 md_tmpl_core::sanitize_untrusted_str(&sample_marker, TOOL_OUTPUT_QUARANTINE_TAG);
486 let marker_len = sample_marker_sanitized.len();
487
488 if self.max_bytes <= marker_len {
489 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
490 }
491
492 let content_budget = self.max_bytes.saturating_sub(marker_len);
493 let (mut head_end, mut tail_start) =
494 self.compute_initial_boundaries(input, total_bytes, content_budget);
495
496 if head_end >= tail_start {
497 let sanitized_full =
498 md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
499 if sanitized_full.len() <= self.max_bytes {
500 return TruncationOutcome {
501 text: sanitized_full,
502 original_len: total_bytes,
503 omitted: None,
504 spill_ref,
505 };
506 }
507 let mut mid = total_bytes / 2;
508 while mid > 0 && !input.is_char_boundary(mid) {
509 mid -= 1;
510 }
511 head_end = mid;
512 tail_start = mid;
513 }
514
515 let (mut head_end, mut tail_start, mut marker, _) =
516 self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
517
518 loop {
519 let mut candidate =
520 String::with_capacity(head_end + marker.len() + (total_bytes - tail_start));
521 candidate.push_str(&input[..head_end]);
522 candidate.push_str(&marker);
523 candidate.push_str(&input[tail_start..]);
524
525 let sanitized =
526 md_tmpl_core::sanitize_untrusted_str(&candidate, TOOL_OUTPUT_QUARANTINE_TAG);
527 if sanitized.len() <= self.max_bytes {
528 return TruncationOutcome {
529 text: Cow::Owned(sanitized.into_owned()),
530 original_len: total_bytes,
531 omitted: Some(head_end..tail_start),
532 spill_ref,
533 };
534 }
535
536 if head_end == 0 && tail_start >= total_bytes {
537 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
538 }
539
540 if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
541 {
542 head_end = self.shrink_head_boundary(input, head_end);
543 } else if tail_start < total_bytes {
544 tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
545 } else {
546 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
547 }
548
549 let omitted_bytes = tail_start.saturating_sub(head_end);
550 marker = self.format_marker_detailed(
551 omitted_bytes,
552 total_bytes,
553 head_end,
554 tail_start,
555 spill,
556 );
557 }
558 }
559
560 fn truncate_oversized<'a>(
563 &self,
564 input: &'a str,
565 spill: &SpillDisposition,
566 ) -> TruncationOutcome<'a> {
567 let total_bytes = input.len();
568 let spill_ref = spill.spill_ref().cloned();
569
570 if self.max_bytes == 0 {
571 return TruncationOutcome {
572 text: Cow::Borrowed(""),
573 original_len: total_bytes,
574 omitted: Some(0..total_bytes),
575 spill_ref,
576 };
577 }
578
579 let est_omitted = total_bytes.saturating_sub(self.max_bytes);
581 let sample_marker =
582 self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
583 let marker_len = sample_marker.len();
584
585 if self.max_bytes <= marker_len {
586 return self.truncate_with_fallback_marker(input, spill, spill_ref);
587 }
588
589 let content_budget = self.max_bytes.saturating_sub(marker_len);
590 let (head_end, tail_start) =
591 self.compute_initial_boundaries(input, total_bytes, content_budget);
592
593 if head_end >= tail_start {
595 return TruncationOutcome {
596 text: Cow::Borrowed(input),
597 original_len: total_bytes,
598 omitted: None,
599 spill_ref,
600 };
601 }
602
603 let (head_end, tail_start, marker, current_total) =
604 self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
605
606 let mut output = String::with_capacity(current_total);
607 output.push_str(&input[..head_end]);
608 output.push_str(&marker);
609 output.push_str(&input[tail_start..]);
610
611 if output.len() > self.max_bytes {
612 let mut cut = self.max_bytes;
613 while cut > 0 && !output.is_char_boundary(cut) {
614 cut -= 1;
615 }
616 output.truncate(cut);
617 }
618
619 TruncationOutcome {
620 text: Cow::Owned(output),
621 original_len: total_bytes,
622 omitted: Some(head_end..tail_start),
623 spill_ref,
624 }
625 }
626
627 fn fallback_marker(spill: &SpillDisposition, max_bytes: usize) -> String {
629 if let SpillDisposition::Spilled(handle) = spill {
630 let with_ref = format!("\n[TRUNCATED: bytes omitted; ref=\"{handle}\"]\n");
631 if with_ref.len() <= max_bytes {
632 return with_ref;
633 }
634 }
635 match spill {
636 SpillDisposition::Failed(_) => MINIMAL_TRUNCATION_MARKER_SPILL_FAILED.into(),
637 SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
638 MINIMAL_TRUNCATION_MARKER.into()
639 }
640 }
641 }
642
643 fn truncate_with_fallback_marker_sanitized<'a>(
644 &self,
645 input: &str,
646 spill: &SpillDisposition,
647 spill_ref: Option<SpillRef>,
648 ) -> TruncationOutcome<'a> {
649 let total_bytes = input.len();
650 let raw_marker = Self::fallback_marker(spill, self.max_bytes);
651 let marker = md_tmpl_core::sanitize_untrusted_str(&raw_marker, TOOL_OUTPUT_QUARANTINE_TAG);
652
653 if marker.len() >= self.max_bytes {
654 let mut cut = self.max_bytes;
655 while cut > 0 && !marker.is_char_boundary(cut) {
656 cut -= 1;
657 }
658 let slice = &marker[..cut];
659 let clean = md_tmpl_core::sanitize_untrusted_str(slice, TOOL_OUTPUT_QUARANTINE_TAG);
660 return TruncationOutcome {
661 text: Cow::Owned(clean.into_owned()),
662 original_len: total_bytes,
663 omitted: Some(0..total_bytes),
664 spill_ref,
665 };
666 }
667
668 let mut head_end = self.max_bytes - marker.len();
669 while head_end > 0 && !input.is_char_boundary(head_end) {
670 head_end -= 1;
671 }
672
673 loop {
674 let mut text = String::with_capacity(head_end + marker.len());
675 text.push_str(&input[..head_end]);
676 text.push_str(&marker);
677 let sanitized = md_tmpl_core::sanitize_untrusted_str(&text, TOOL_OUTPUT_QUARANTINE_TAG);
678 if sanitized.len() <= self.max_bytes {
679 return TruncationOutcome {
680 text: Cow::Owned(sanitized.into_owned()),
681 original_len: total_bytes,
682 omitted: Some(head_end..total_bytes),
683 spill_ref,
684 };
685 }
686 if head_end == 0 {
687 let mut cut = self.max_bytes.min(marker.len());
688 while cut > 0 && !marker.is_char_boundary(cut) {
689 cut -= 1;
690 }
691 return TruncationOutcome {
692 text: Cow::Owned(marker[..cut].into()),
693 original_len: total_bytes,
694 omitted: Some(0..total_bytes),
695 spill_ref,
696 };
697 }
698 head_end -= 1;
699 while head_end > 0 && !input.is_char_boundary(head_end) {
700 head_end -= 1;
701 }
702 }
703 }
704
705 fn truncate_with_fallback_marker<'a>(
712 &self,
713 input: &str,
714 spill: &SpillDisposition,
715 spill_ref: Option<SpillRef>,
716 ) -> TruncationOutcome<'a> {
717 let total_bytes = input.len();
718 let marker = Self::fallback_marker(spill, self.max_bytes);
719
720 if marker.len() >= self.max_bytes {
721 let mut cut = self.max_bytes;
722 while cut > 0 && !marker.is_char_boundary(cut) {
723 cut -= 1;
724 }
725 return TruncationOutcome {
726 text: Cow::Owned(marker[..cut].into()),
727 original_len: total_bytes,
728 omitted: Some(0..total_bytes),
729 spill_ref,
730 };
731 }
732
733 let mut head_end = self.max_bytes - marker.len();
734 while head_end > 0 && !input.is_char_boundary(head_end) {
735 head_end -= 1;
736 }
737
738 let mut text = String::with_capacity(head_end + marker.len());
739 text.push_str(&input[..head_end]);
740 text.push_str(&marker);
741
742 TruncationOutcome {
743 text: Cow::Owned(text),
744 original_len: total_bytes,
745 omitted: Some(head_end..total_bytes),
746 spill_ref,
747 }
748 }
749
750 fn compute_initial_boundaries(
751 &self,
752 input: &str,
753 total_bytes: usize,
754 content_budget: usize,
755 ) -> (usize, usize) {
756 let head_budget = self.head_ratio.compute_head_budget(content_budget);
757 let tail_budget = content_budget.saturating_sub(head_budget);
758
759 let mut head_end = head_budget.min(total_bytes);
761 while head_end > 0 && !input.is_char_boundary(head_end) {
762 head_end -= 1;
763 }
764
765 let tail_target = total_bytes.saturating_sub(tail_budget);
767 let mut tail_start = tail_target.min(total_bytes);
768 while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
769 tail_start += 1;
770 }
771
772 if self.alignment == TruncationAlignment::LineBoundary {
774 let head_scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
775 if let Some(rel_nl) = input[head_scan_start..head_end].rfind('\n') {
776 head_end = head_scan_start + rel_nl + 1;
777 }
778
779 let tail_scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
780 if let Some(rel_nl) = input[tail_start..tail_scan_end].find('\n') {
781 tail_start = tail_start + rel_nl + 1;
782 }
783 }
784
785 (head_end, tail_start)
786 }
787
788 fn shrink_head_boundary(&self, input: &str, mut head_end: usize) -> usize {
789 if self.alignment == TruncationAlignment::LineBoundary {
790 let scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
791 if let Some(rel_nl) = input[scan_start..head_end.saturating_sub(1)].rfind('\n') {
792 return scan_start + rel_nl + 1;
793 }
794 }
795 head_end = head_end.saturating_sub(1);
796 while head_end > 0 && !input.is_char_boundary(head_end) {
797 head_end -= 1;
798 }
799 head_end
800 }
801
802 fn expand_tail_boundary(
803 &self,
804 input: &str,
805 total_bytes: usize,
806 mut tail_start: usize,
807 ) -> usize {
808 if self.alignment == TruncationAlignment::LineBoundary {
809 let scan_start = tail_start.saturating_add(1).min(total_bytes);
810 let scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
811 if scan_start < scan_end
812 && let Some(rel_nl) = input[scan_start..scan_end].find('\n')
813 {
814 return scan_start + rel_nl + 1;
815 }
816 }
817 tail_start = tail_start.saturating_add(1);
818 while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
819 tail_start += 1;
820 }
821 tail_start
822 }
823
824 fn converge_boundaries(
825 &self,
826 input: &str,
827 total_bytes: usize,
828 mut head_end: usize,
829 mut tail_start: usize,
830 spill: &SpillDisposition,
831 ) -> (usize, usize, String, usize) {
832 let mut omitted_bytes = tail_start.saturating_sub(head_end);
833 let mut marker =
834 self.format_marker_detailed(omitted_bytes, total_bytes, head_end, tail_start, spill);
835 let mut current_total = head_end + marker.len() + (total_bytes - tail_start);
836
837 while current_total > self.max_bytes && (head_end > 0 || tail_start < total_bytes) {
838 if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
839 {
840 head_end = self.shrink_head_boundary(input, head_end);
841 } else if tail_start < total_bytes {
842 tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
843 } else {
844 break;
845 }
846
847 omitted_bytes = tail_start.saturating_sub(head_end);
848 marker = self.format_marker_detailed(
849 omitted_bytes,
850 total_bytes,
851 head_end,
852 tail_start,
853 spill,
854 );
855 current_total = head_end + marker.len() + (total_bytes - tail_start);
856 }
857
858 (head_end, tail_start, marker, current_total)
859 }
860}
861
862#[must_use]
864pub fn truncate_middle<'a>(input: &'a str, policy: &TruncationPolicy) -> Cow<'a, str> {
865 policy.truncate(input)
866}
867
868pub fn truncate_middle_detailed<'a>(
874 input: &'a str,
875 policy: &TruncationPolicy,
876) -> Result<TruncationOutcome<'a>, SpillError> {
877 policy.truncate_detailed(input)
878}
879
880pub fn truncate_middle_detailed_with_context<'a>(
886 input: &'a str,
887 policy: &TruncationPolicy,
888 ctx: &SpillContext<'_>,
889) -> Result<TruncationOutcome<'a>, SpillError> {
890 policy.truncate_detailed_with_context(input, ctx)
891}
892
893pub fn truncate_output(output: &mut ToolOutput, policy: &TruncationPolicy) {
895 output.content = policy.truncate(&output.content).into_owned();
896}
897
898pub fn truncate_output_detailed(
904 output: &mut ToolOutput,
905 policy: &TruncationPolicy,
906) -> Result<TruncationOutcome<'static>, SpillError> {
907 truncate_output_detailed_with_context(output, policy, &SpillContext::ANONYMOUS)
908}
909
910pub fn truncate_output_detailed_with_context(
916 output: &mut ToolOutput,
917 policy: &TruncationPolicy,
918 ctx: &SpillContext<'_>,
919) -> Result<TruncationOutcome<'static>, SpillError> {
920 let outcome = policy.truncate_detailed_with_context(&output.content, ctx)?;
921 let text = outcome.text.into_owned();
922 let original_len = outcome.original_len;
923 let omitted = outcome.omitted;
924 let spill_ref = outcome.spill_ref;
925 output.content.clone_from(&text);
926 Ok(TruncationOutcome {
927 text: Cow::Owned(text),
928 original_len,
929 omitted,
930 spill_ref,
931 })
932}