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 = match &self.marker_template {
162 Some(template) => {
163 let orig_str = original_bytes.to_string();
164 let start_str = offset_start.to_string();
165 let end_str = offset_end.to_string();
166 let ref_str = spill.spill_ref().map_or("", SpillRef::as_str);
167
168 template
169 .replace(MarkerPlaceholder::TOKEN_OMITTED_BYTES, &omitted_str)
170 .replace(MarkerPlaceholder::TOKEN_ORIGINAL_BYTES, &orig_str)
171 .replace(MarkerPlaceholder::TOKEN_OFFSET_START, &start_str)
172 .replace(MarkerPlaceholder::TOKEN_OFFSET_END, &end_str)
173 .replace(MarkerPlaceholder::TOKEN_SPILL_REF, ref_str)
174 .replace(MarkerPlaceholder::DEPRECATED_COUNT, &omitted_str)
175 .replace(MarkerPlaceholder::DEPRECATED_BYTES, &omitted_str)
176 .replace(MarkerPlaceholder::DEPRECATED_N, &omitted_str)
177 }
178 None => match spill {
179 SpillDisposition::Spilled(handle) => format!(
180 "\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"
181 ),
182 SpillDisposition::Failed(error) => format!(
183 "\n[TRUNCATED: {omitted_str} bytes omitted (bytes {offset_start}..{offset_end} of {original_bytes}).{notice}]\n",
184 notice = spill_failure_notice(error)
185 ),
186 SpillDisposition::NotConfigured => {
187 format!("\n[TRUNCATED: {omitted_str} bytes omitted]\n")
188 }
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 match Self::passthrough(input, self.max_bytes) {
335 Some(outcome) => Ok(outcome),
336 None => match self.resolve_spill(ctx, input) {
337 SpillDisposition::Failed(error) => Err(error),
338 spill => Ok(self.truncate_oversized_auto(input, &spill)),
339 },
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 match &self.spill_sink {
360 Some(sink) => match sink.spill(ctx, input) {
361 Ok(handle) => SpillDisposition::Spilled(handle),
362 Err(error) => SpillDisposition::Failed(error),
363 },
364 None => SpillDisposition::NotConfigured,
365 }
366 }
367
368 fn resolve_spill_logged(&self, ctx: &SpillContext<'_>, input: &str) -> SpillDisposition {
369 let spill = self.resolve_spill(ctx, input);
370 if let Some(error) = spill.error() {
371 tracing::error!(
372 error = %error,
373 tool = ctx.tool_name,
374 "spill sink failed; omitted bytes are unrecoverable and the truncation marker will say so"
375 );
376 }
377 spill
378 }
379
380 fn truncate_oversized_auto<'a>(
383 &self,
384 input: &'a str,
385 spill: &SpillDisposition,
386 ) -> TruncationOutcome<'a> {
387 if let Some(inner) = unquarantine_tool_output(input) {
388 return self.truncate_and_wrap_quarantine(inner, input.len(), OPEN_FRAME.len(), spill);
389 }
390 if !md_tmpl_core::has_untrusted_breakout(input, TOOL_OUTPUT_QUARANTINE_TAG) {
391 return self.truncate_oversized_sanitized(input, spill);
392 }
393 self.truncate_oversized(input, spill)
394 }
395
396 fn truncate_and_wrap_quarantine<'a>(
399 &self,
400 inner: &str,
401 outer_original_len: usize,
402 offset_bias: usize,
403 spill: &SpillDisposition,
404 ) -> TruncationOutcome<'a> {
405 let inner_len = inner.len();
406 let spill_ref = spill.spill_ref().cloned();
407 let inner_budget = self.max_bytes.saturating_sub(FRAME_OVERHEAD);
408
409 if inner_budget < MINIMAL_TRUNCATION_MARKER.len() {
410 let notice = match spill {
411 SpillDisposition::Failed(_) => COMPACT_TRUNCATION_MARKER_SPILL_FAILED,
412 SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
413 COMPACT_TRUNCATION_MARKER
414 }
415 };
416 let mut head_end = inner_budget.saturating_sub(notice.len()).min(inner_len);
417 while head_end > 0 && !inner.is_char_boundary(head_end) {
418 head_end -= 1;
419 }
420 let mut body = String::with_capacity(head_end + notice.len());
421 body.push_str(&inner[..head_end]);
422 body.push_str(notice);
423 let sanitized_body =
424 md_tmpl_core::sanitize_untrusted_str(&body, TOOL_OUTPUT_QUARANTINE_TAG);
425 let final_body = if head_end > 0 && sanitized_body.len() > inner_budget {
426 notice
427 } else {
428 sanitized_body.as_ref()
429 };
430 return TruncationOutcome {
431 text: Cow::Owned(format!("{OPEN_FRAME}{final_body}{CLOSE_FRAME}")),
432 original_len: outer_original_len,
433 omitted: Some(offset_bias..(offset_bias + inner_len)),
434 spill_ref,
435 };
436 }
437
438 let inner_policy = Self {
439 max_bytes: inner_budget,
440 head_ratio: self.head_ratio,
441 alignment: self.alignment,
442 marker_template: self.marker_template.clone(),
443 spill_sink: None,
444 };
445 let inner_outcome = inner_policy.truncate_oversized_sanitized(inner, spill);
446 let omitted = inner_outcome
447 .omitted
448 .map(|r| (r.start + offset_bias)..(r.end + offset_bias))
449 .or(Some(offset_bias..(offset_bias + inner_len)));
450
451 TruncationOutcome {
452 text: Cow::Owned(format!("{OPEN_FRAME}{}{CLOSE_FRAME}", inner_outcome.text)),
453 original_len: outer_original_len,
454 omitted,
455 spill_ref,
456 }
457 }
458
459 fn truncate_oversized_sanitized<'a>(
464 &self,
465 input: &'a str,
466 spill: &SpillDisposition,
467 ) -> TruncationOutcome<'a> {
468 let total_bytes = input.len();
469 let spill_ref = spill.spill_ref().cloned();
470
471 if self.max_bytes == 0 {
472 return TruncationOutcome {
473 text: Cow::Borrowed(""),
474 original_len: total_bytes,
475 omitted: Some(0..total_bytes),
476 spill_ref,
477 };
478 }
479
480 let est_omitted = total_bytes.saturating_sub(self.max_bytes);
481 let sample_marker =
482 self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
483 let sample_marker_sanitized =
484 md_tmpl_core::sanitize_untrusted_str(&sample_marker, TOOL_OUTPUT_QUARANTINE_TAG);
485 let marker_len = sample_marker_sanitized.len();
486
487 if self.max_bytes <= marker_len {
488 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
489 }
490
491 let content_budget = self.max_bytes.saturating_sub(marker_len);
492 let (mut head_end, mut tail_start) =
493 self.compute_initial_boundaries(input, total_bytes, content_budget);
494
495 if head_end >= tail_start {
496 let sanitized_full =
497 md_tmpl_core::sanitize_untrusted_str(input, TOOL_OUTPUT_QUARANTINE_TAG);
498 if sanitized_full.len() <= self.max_bytes {
499 return TruncationOutcome {
500 text: sanitized_full,
501 original_len: total_bytes,
502 omitted: None,
503 spill_ref,
504 };
505 }
506 let mut mid = total_bytes / 2;
507 while mid > 0 && !input.is_char_boundary(mid) {
508 mid -= 1;
509 }
510 head_end = mid;
511 tail_start = mid;
512 }
513
514 let (mut head_end, mut tail_start, mut marker, _) =
515 self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
516
517 loop {
518 let mut candidate =
519 String::with_capacity(head_end + marker.len() + (total_bytes - tail_start));
520 candidate.push_str(&input[..head_end]);
521 candidate.push_str(&marker);
522 candidate.push_str(&input[tail_start..]);
523
524 let sanitized =
525 md_tmpl_core::sanitize_untrusted_str(&candidate, TOOL_OUTPUT_QUARANTINE_TAG);
526 if sanitized.len() <= self.max_bytes {
527 return TruncationOutcome {
528 text: Cow::Owned(sanitized.into_owned()),
529 original_len: total_bytes,
530 omitted: Some(head_end..tail_start),
531 spill_ref,
532 };
533 }
534
535 if head_end == 0 && tail_start >= total_bytes {
536 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
537 }
538
539 if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
540 {
541 head_end = self.shrink_head_boundary(input, head_end);
542 } else if tail_start < total_bytes {
543 tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
544 } else {
545 return self.truncate_with_fallback_marker_sanitized(input, spill, spill_ref);
546 }
547
548 let omitted_bytes = tail_start.saturating_sub(head_end);
549 marker = self.format_marker_detailed(
550 omitted_bytes,
551 total_bytes,
552 head_end,
553 tail_start,
554 spill,
555 );
556 }
557 }
558
559 fn truncate_oversized<'a>(
562 &self,
563 input: &'a str,
564 spill: &SpillDisposition,
565 ) -> TruncationOutcome<'a> {
566 let total_bytes = input.len();
567 let spill_ref = spill.spill_ref().cloned();
568
569 if self.max_bytes == 0 {
570 return TruncationOutcome {
571 text: Cow::Borrowed(""),
572 original_len: total_bytes,
573 omitted: Some(0..total_bytes),
574 spill_ref,
575 };
576 }
577
578 let est_omitted = total_bytes.saturating_sub(self.max_bytes);
580 let sample_marker =
581 self.format_marker_detailed(est_omitted, total_bytes, 0, est_omitted, spill);
582 let marker_len = sample_marker.len();
583
584 if self.max_bytes <= marker_len {
585 return self.truncate_with_fallback_marker(input, spill, spill_ref);
586 }
587
588 let content_budget = self.max_bytes.saturating_sub(marker_len);
589 let (head_end, tail_start) =
590 self.compute_initial_boundaries(input, total_bytes, content_budget);
591
592 if head_end >= tail_start {
594 return TruncationOutcome {
595 text: Cow::Borrowed(input),
596 original_len: total_bytes,
597 omitted: None,
598 spill_ref,
599 };
600 }
601
602 let (head_end, tail_start, marker, current_total) =
603 self.converge_boundaries(input, total_bytes, head_end, tail_start, spill);
604
605 let mut output = String::with_capacity(current_total);
606 output.push_str(&input[..head_end]);
607 output.push_str(&marker);
608 output.push_str(&input[tail_start..]);
609
610 if output.len() > self.max_bytes {
611 let mut cut = self.max_bytes;
612 while cut > 0 && !output.is_char_boundary(cut) {
613 cut -= 1;
614 }
615 output.truncate(cut);
616 }
617
618 TruncationOutcome {
619 text: Cow::Owned(output),
620 original_len: total_bytes,
621 omitted: Some(head_end..tail_start),
622 spill_ref,
623 }
624 }
625
626 fn fallback_marker(spill: &SpillDisposition, max_bytes: usize) -> String {
628 if let SpillDisposition::Spilled(handle) = spill {
629 let with_ref = format!("\n[TRUNCATED: bytes omitted; ref=\"{handle}\"]\n");
630 if with_ref.len() <= max_bytes {
631 return with_ref;
632 }
633 }
634 match spill {
635 SpillDisposition::Failed(_) => MINIMAL_TRUNCATION_MARKER_SPILL_FAILED.into(),
636 SpillDisposition::NotConfigured | SpillDisposition::Spilled(_) => {
637 MINIMAL_TRUNCATION_MARKER.into()
638 }
639 }
640 }
641
642 fn truncate_with_fallback_marker_sanitized<'a>(
643 &self,
644 input: &str,
645 spill: &SpillDisposition,
646 spill_ref: Option<SpillRef>,
647 ) -> TruncationOutcome<'a> {
648 let total_bytes = input.len();
649 let raw_marker = Self::fallback_marker(spill, self.max_bytes);
650 let marker = md_tmpl_core::sanitize_untrusted_str(&raw_marker, TOOL_OUTPUT_QUARANTINE_TAG);
651
652 if marker.len() >= self.max_bytes {
653 let mut cut = self.max_bytes;
654 while cut > 0 && !marker.is_char_boundary(cut) {
655 cut -= 1;
656 }
657 let slice = &marker[..cut];
658 let clean = md_tmpl_core::sanitize_untrusted_str(slice, TOOL_OUTPUT_QUARANTINE_TAG);
659 return TruncationOutcome {
660 text: Cow::Owned(clean.into_owned()),
661 original_len: total_bytes,
662 omitted: Some(0..total_bytes),
663 spill_ref,
664 };
665 }
666
667 let mut head_end = self.max_bytes - marker.len();
668 while head_end > 0 && !input.is_char_boundary(head_end) {
669 head_end -= 1;
670 }
671
672 loop {
673 let mut text = String::with_capacity(head_end + marker.len());
674 text.push_str(&input[..head_end]);
675 text.push_str(&marker);
676 let sanitized = md_tmpl_core::sanitize_untrusted_str(&text, TOOL_OUTPUT_QUARANTINE_TAG);
677 if sanitized.len() <= self.max_bytes {
678 return TruncationOutcome {
679 text: Cow::Owned(sanitized.into_owned()),
680 original_len: total_bytes,
681 omitted: Some(head_end..total_bytes),
682 spill_ref,
683 };
684 }
685 if head_end == 0 {
686 let mut cut = self.max_bytes.min(marker.len());
687 while cut > 0 && !marker.is_char_boundary(cut) {
688 cut -= 1;
689 }
690 return TruncationOutcome {
691 text: Cow::Owned(marker[..cut].into()),
692 original_len: total_bytes,
693 omitted: Some(0..total_bytes),
694 spill_ref,
695 };
696 }
697 head_end -= 1;
698 while head_end > 0 && !input.is_char_boundary(head_end) {
699 head_end -= 1;
700 }
701 }
702 }
703
704 fn truncate_with_fallback_marker<'a>(
711 &self,
712 input: &str,
713 spill: &SpillDisposition,
714 spill_ref: Option<SpillRef>,
715 ) -> TruncationOutcome<'a> {
716 let total_bytes = input.len();
717 let marker = Self::fallback_marker(spill, self.max_bytes);
718
719 if marker.len() >= self.max_bytes {
720 let mut cut = self.max_bytes;
721 while cut > 0 && !marker.is_char_boundary(cut) {
722 cut -= 1;
723 }
724 return TruncationOutcome {
725 text: Cow::Owned(marker[..cut].into()),
726 original_len: total_bytes,
727 omitted: Some(0..total_bytes),
728 spill_ref,
729 };
730 }
731
732 let mut head_end = self.max_bytes - marker.len();
733 while head_end > 0 && !input.is_char_boundary(head_end) {
734 head_end -= 1;
735 }
736
737 let mut text = String::with_capacity(head_end + marker.len());
738 text.push_str(&input[..head_end]);
739 text.push_str(&marker);
740
741 TruncationOutcome {
742 text: Cow::Owned(text),
743 original_len: total_bytes,
744 omitted: Some(head_end..total_bytes),
745 spill_ref,
746 }
747 }
748
749 fn compute_initial_boundaries(
750 &self,
751 input: &str,
752 total_bytes: usize,
753 content_budget: usize,
754 ) -> (usize, usize) {
755 let head_budget = self.head_ratio.compute_head_budget(content_budget);
756 let tail_budget = content_budget.saturating_sub(head_budget);
757
758 let mut head_end = head_budget.min(total_bytes);
760 while head_end > 0 && !input.is_char_boundary(head_end) {
761 head_end -= 1;
762 }
763
764 let tail_target = total_bytes.saturating_sub(tail_budget);
766 let mut tail_start = tail_target.min(total_bytes);
767 while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
768 tail_start += 1;
769 }
770
771 if self.alignment == TruncationAlignment::LineBoundary {
773 let head_scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
774 if let Some(rel_nl) = input[head_scan_start..head_end].rfind('\n') {
775 head_end = head_scan_start + rel_nl + 1;
776 }
777
778 let tail_scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
779 if let Some(rel_nl) = input[tail_start..tail_scan_end].find('\n') {
780 tail_start = tail_start + rel_nl + 1;
781 }
782 }
783
784 (head_end, tail_start)
785 }
786
787 fn shrink_head_boundary(&self, input: &str, mut head_end: usize) -> usize {
788 if self.alignment == TruncationAlignment::LineBoundary {
789 let scan_start = head_end.saturating_sub(MAX_LINE_SCAN_WINDOW);
790 if let Some(rel_nl) = input[scan_start..head_end.saturating_sub(1)].rfind('\n') {
791 return scan_start + rel_nl + 1;
792 }
793 }
794 head_end = head_end.saturating_sub(1);
795 while head_end > 0 && !input.is_char_boundary(head_end) {
796 head_end -= 1;
797 }
798 head_end
799 }
800
801 fn expand_tail_boundary(
802 &self,
803 input: &str,
804 total_bytes: usize,
805 mut tail_start: usize,
806 ) -> usize {
807 if self.alignment == TruncationAlignment::LineBoundary {
808 let scan_start = tail_start.saturating_add(1).min(total_bytes);
809 let scan_end = (tail_start + MAX_LINE_SCAN_WINDOW).min(total_bytes);
810 if scan_start < scan_end
811 && let Some(rel_nl) = input[scan_start..scan_end].find('\n')
812 {
813 return scan_start + rel_nl + 1;
814 }
815 }
816 tail_start = tail_start.saturating_add(1);
817 while tail_start < total_bytes && !input.is_char_boundary(tail_start) {
818 tail_start += 1;
819 }
820 tail_start
821 }
822
823 fn converge_boundaries(
824 &self,
825 input: &str,
826 total_bytes: usize,
827 mut head_end: usize,
828 mut tail_start: usize,
829 spill: &SpillDisposition,
830 ) -> (usize, usize, String, usize) {
831 let mut omitted_bytes = tail_start.saturating_sub(head_end);
832 let mut marker =
833 self.format_marker_detailed(omitted_bytes, total_bytes, head_end, tail_start, spill);
834 let mut current_total = head_end + marker.len() + (total_bytes - tail_start);
835
836 while current_total > self.max_bytes && (head_end > 0 || tail_start < total_bytes) {
837 if head_end > 0 && (tail_start == total_bytes || head_end >= (total_bytes - tail_start))
838 {
839 head_end = self.shrink_head_boundary(input, head_end);
840 } else if tail_start < total_bytes {
841 tail_start = self.expand_tail_boundary(input, total_bytes, tail_start);
842 } else {
843 break;
844 }
845
846 omitted_bytes = tail_start.saturating_sub(head_end);
847 marker = self.format_marker_detailed(
848 omitted_bytes,
849 total_bytes,
850 head_end,
851 tail_start,
852 spill,
853 );
854 current_total = head_end + marker.len() + (total_bytes - tail_start);
855 }
856
857 (head_end, tail_start, marker, current_total)
858 }
859}
860
861#[must_use]
863pub fn truncate_middle<'a>(input: &'a str, policy: &TruncationPolicy) -> Cow<'a, str> {
864 policy.truncate(input)
865}
866
867pub fn truncate_middle_detailed<'a>(
873 input: &'a str,
874 policy: &TruncationPolicy,
875) -> Result<TruncationOutcome<'a>, SpillError> {
876 policy.truncate_detailed(input)
877}
878
879pub fn truncate_middle_detailed_with_context<'a>(
885 input: &'a str,
886 policy: &TruncationPolicy,
887 ctx: &SpillContext<'_>,
888) -> Result<TruncationOutcome<'a>, SpillError> {
889 policy.truncate_detailed_with_context(input, ctx)
890}
891
892pub fn truncate_output(output: &mut ToolOutput, policy: &TruncationPolicy) {
894 output.content = policy.truncate(&output.content).into_owned();
895}
896
897pub fn truncate_output_detailed(
903 output: &mut ToolOutput,
904 policy: &TruncationPolicy,
905) -> Result<TruncationOutcome<'static>, SpillError> {
906 truncate_output_detailed_with_context(output, policy, &SpillContext::ANONYMOUS)
907}
908
909pub fn truncate_output_detailed_with_context(
915 output: &mut ToolOutput,
916 policy: &TruncationPolicy,
917 ctx: &SpillContext<'_>,
918) -> Result<TruncationOutcome<'static>, SpillError> {
919 let outcome = policy.truncate_detailed_with_context(&output.content, ctx)?;
920 let text = outcome.text.into_owned();
921 let original_len = outcome.original_len;
922 let omitted = outcome.omitted;
923 let spill_ref = outcome.spill_ref;
924 output.content.clone_from(&text);
925 Ok(TruncationOutcome {
926 text: Cow::Owned(text),
927 original_len,
928 omitted,
929 spill_ref,
930 })
931}