Skip to main content

devboy_format_pipeline/
truncation.rs

1//! Truncation utilities for limiting output size.
2//!
3//! Provides smart truncation that:
4//! - Preserves meaningful content boundaries (lines, words)
5//! - Adds truncation markers
6//! - Creates agent hints about hidden content
7
8/// Truncate a string to max_chars, preserving word boundaries.
9/// The returned string will be at most max_chars characters long (including ellipsis).
10/// Safe for non-ASCII (UTF-8 multi-byte characters).
11pub fn truncate_string(s: &str, max_chars: usize) -> String {
12    // Bounded check: only walk up to max_chars+1 chars (not the whole string)
13    if s.chars().nth(max_chars).is_none() {
14        return s.to_string();
15    }
16
17    // Account for ellipsis in the limit
18    let content_limit = max_chars.saturating_sub(3);
19    if content_limit == 0 {
20        return "...".to_string();
21    }
22
23    // Find byte offset for content_limit characters (safe char boundary)
24    let byte_limit = s
25        .char_indices()
26        .nth(content_limit)
27        .map(|(i, _)| i)
28        .unwrap_or(s.len());
29    let truncated = &s[..byte_limit];
30
31    // Try to break at newline first
32    if let Some(pos) = truncated.rfind('\n')
33        && pos > byte_limit / 2
34    {
35        return format!("{}...", &s[..pos]);
36    }
37
38    // Fall back to word boundary
39    if let Some(pos) = truncated.rfind(' ')
40        && pos > byte_limit / 2
41    {
42        return format!("{}...", &s[..pos]);
43    }
44
45    // Hard truncate if no good boundary found
46    format!("{truncated}...")
47}
48
49/// Truncate diff content with context preservation.
50///
51/// Keeps the beginning and end of the diff to show what changed,
52/// hiding the middle if too long.
53pub fn truncate_diff(diff: &str, max_chars: usize) -> String {
54    // Bounded check: only walk up to max_chars+1 chars
55    if diff.chars().nth(max_chars).is_none() {
56        return diff.to_string();
57    }
58
59    let lines: Vec<&str> = diff.lines().collect();
60    if lines.len() <= 10 {
61        return truncate_string(diff, max_chars);
62    }
63
64    // Keep first 5 and last 5 lines, hide the middle
65    let head: String = lines[..5].join("\n");
66    let tail: String = lines[lines.len() - 5..].join("\n");
67    let hidden_count = lines.len() - 10;
68
69    format!(
70        "{}\n\n... [{} lines hidden] ...\n\n{}",
71        head, hidden_count, tail
72    )
73}
74
75/// Configuration for truncation plugin.
76#[derive(Debug, Clone)]
77pub struct TruncationConfig {
78    /// Maximum number of items in a list
79    pub max_items: usize,
80    /// Maximum characters for the entire output
81    pub max_total_chars: usize,
82    /// Maximum characters per item (e.g., description, diff)
83    pub max_item_chars: usize,
84    /// Whether to show truncation indicators
85    pub show_indicators: bool,
86}
87
88impl Default for TruncationConfig {
89    fn default() -> Self {
90        Self {
91            max_items: 20,
92            max_total_chars: 4000,
93            max_item_chars: 500,
94            show_indicators: true,
95        }
96    }
97}
98
99/// Truncation plugin for limiting output size.
100pub struct TruncationPlugin {
101    config: TruncationConfig,
102}
103
104impl TruncationPlugin {
105    /// Create a new truncation plugin with default config.
106    pub fn new() -> Self {
107        Self {
108            config: TruncationConfig::default(),
109        }
110    }
111
112    /// Create a truncation plugin with custom limits.
113    pub fn with_limits(max_items: usize, max_chars: usize) -> Self {
114        Self {
115            config: TruncationConfig {
116                max_items,
117                max_total_chars: max_chars,
118                ..Default::default()
119            },
120        }
121    }
122
123    /// Create a truncation plugin with custom config.
124    pub fn with_config(config: TruncationConfig) -> Self {
125        Self { config }
126    }
127
128    /// Get the maximum number of items.
129    pub fn max_items(&self) -> usize {
130        self.config.max_items
131    }
132
133    /// Get the maximum total characters.
134    pub fn max_total_chars(&self) -> usize {
135        self.config.max_total_chars
136    }
137
138    /// Get the maximum characters per item.
139    pub fn max_item_chars(&self) -> usize {
140        self.config.max_item_chars
141    }
142
143    /// Truncate a string using the plugin's config.
144    pub fn truncate(&self, s: &str) -> String {
145        truncate_string(s, self.config.max_total_chars)
146    }
147
148    /// Truncate an item's content (e.g., description).
149    pub fn truncate_item(&self, s: &str) -> String {
150        truncate_string(s, self.config.max_item_chars)
151    }
152
153    /// Create a truncation summary for agent hint.
154    pub fn create_summary(&self, total: usize, shown: usize, item_type: &str) -> String {
155        if shown >= total {
156            return String::new();
157        }
158
159        let remaining = total - shown;
160        format!(
161            "📊 Showing {}/{} {}. {} more available. Use `offset={}` and `limit={}` for next page.",
162            shown, total, item_type, remaining, shown, self.config.max_items
163        )
164    }
165}
166
167impl Default for TruncationPlugin {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_truncate_string_short() {
179        let s = "Hello, world!";
180        assert_eq!(truncate_string(s, 100), s);
181    }
182
183    #[test]
184    fn test_truncate_string_at_word() {
185        let s = "Hello world this is a test";
186        let result = truncate_string(s, 15);
187        assert!(result.ends_with("..."));
188        assert!(result.len() <= 15);
189    }
190
191    #[test]
192    fn test_truncate_string_at_newline() {
193        let s = "Line 1\nLine 2\nLine 3\nLine 4";
194        let result = truncate_string(s, 15);
195        assert!(result.contains("Line 1"));
196        assert!(result.contains("[truncated]") || result.ends_with("..."));
197    }
198
199    #[test]
200    fn test_truncate_diff() {
201        let diff = (1..=20)
202            .map(|i| format!("Line {}", i))
203            .collect::<Vec<_>>()
204            .join("\n");
205
206        // Use a smaller limit to trigger truncation
207        let result = truncate_diff(&diff, 50);
208        assert!(result.contains("Line 1"));
209        assert!(result.contains("Line 20"));
210        assert!(result.contains("lines hidden"));
211    }
212
213    #[test]
214    fn test_truncate_diff_short() {
215        let diff = "Line 1\nLine 2\nLine 3";
216        assert_eq!(truncate_diff(diff, 1000), diff);
217    }
218
219    #[test]
220    fn test_plugin_create_summary() {
221        let plugin = TruncationPlugin::with_limits(10, 1000);
222        let summary = plugin.create_summary(25, 10, "issues");
223
224        assert!(summary.contains("10/25"));
225        assert!(summary.contains("15 more"));
226        assert!(summary.contains("offset=10"));
227    }
228
229    #[test]
230    fn test_plugin_no_summary_when_all_shown() {
231        let plugin = TruncationPlugin::new();
232        let summary = plugin.create_summary(5, 5, "issues");
233        assert!(summary.is_empty());
234    }
235
236    #[test]
237    fn test_truncate_string_very_small_limit() {
238        let s = "Hello, world!";
239        let result = truncate_string(s, 3);
240        assert_eq!(result, "...");
241    }
242
243    #[test]
244    fn test_truncate_string_zero_limit() {
245        let s = "Hello, world!";
246        let result = truncate_string(s, 0);
247        assert_eq!(result, "...");
248    }
249
250    #[test]
251    fn test_truncate_string_hard_truncate() {
252        // String with no spaces or newlines — forces hard truncate
253        let s = "abcdefghijklmnopqrstuvwxyz";
254        let result = truncate_string(s, 10);
255        assert_eq!(result.len(), 10);
256        assert_eq!(result, "abcdefg...");
257    }
258
259    #[test]
260    fn test_truncate_diff_few_lines() {
261        // <= 10 lines, should use truncate_string
262        let diff = "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8";
263        let result = truncate_diff(diff, 10);
264        assert!(result.ends_with("...") || result == diff);
265    }
266
267    #[test]
268    fn test_plugin_with_config() {
269        let config = TruncationConfig {
270            max_items: 5,
271            max_total_chars: 200,
272            max_item_chars: 50,
273            show_indicators: false,
274        };
275        let plugin = TruncationPlugin::with_config(config);
276
277        assert_eq!(plugin.max_items(), 5);
278        assert_eq!(plugin.max_total_chars(), 200);
279        assert_eq!(plugin.max_item_chars(), 50);
280    }
281
282    #[test]
283    fn test_plugin_with_limits() {
284        let plugin = TruncationPlugin::with_limits(15, 2000);
285
286        assert_eq!(plugin.max_items(), 15);
287        assert_eq!(plugin.max_total_chars(), 2000);
288        assert_eq!(plugin.max_item_chars(), 500); // default
289    }
290
291    #[test]
292    fn test_plugin_truncate() {
293        let plugin = TruncationPlugin::with_limits(10, 20);
294
295        let short = "Hello";
296        assert_eq!(plugin.truncate(short), "Hello");
297
298        let long = "This is a much longer string that will be truncated";
299        let result = plugin.truncate(long);
300        assert!(result.len() <= 20);
301        assert!(result.ends_with("..."));
302    }
303
304    #[test]
305    fn test_plugin_truncate_item() {
306        let config = TruncationConfig {
307            max_item_chars: 10,
308            ..Default::default()
309        };
310        let plugin = TruncationPlugin::with_config(config);
311
312        let long = "This is a long item description";
313        let result = plugin.truncate_item(long);
314        assert!(result.len() <= 10);
315        assert!(result.ends_with("..."));
316    }
317
318    #[test]
319    fn test_plugin_default() {
320        let plugin = TruncationPlugin::default();
321        assert_eq!(plugin.max_items(), 20);
322        assert_eq!(plugin.max_total_chars(), 4000);
323    }
324
325    #[test]
326    fn test_truncation_config_default() {
327        let config = TruncationConfig::default();
328        assert_eq!(config.max_items, 20);
329        assert_eq!(config.max_total_chars, 4000);
330        assert_eq!(config.max_item_chars, 500);
331        assert!(config.show_indicators);
332    }
333}