syncable_cli/agent/tools/
truncation.rs1pub struct TruncationLimits {
8 pub max_file_lines: usize,
10 pub shell_prefix_lines: usize,
12 pub shell_suffix_lines: usize,
14 pub max_line_length: usize,
16 pub max_dir_entries: usize,
18}
19
20impl Default for TruncationLimits {
21 fn default() -> Self {
22 Self {
23 max_file_lines: 2000,
24 shell_prefix_lines: 200,
25 shell_suffix_lines: 200,
26 max_line_length: 2000,
27 max_dir_entries: 500,
28 }
29 }
30}
31
32pub struct TruncatedFileContent {
34 pub content: String,
36 pub total_lines: usize,
38 pub returned_lines: usize,
40 pub was_truncated: bool,
42 pub lines_char_truncated: usize,
44}
45
46pub fn truncate_file_content(content: &str, limits: &TruncationLimits) -> TruncatedFileContent {
48 let lines: Vec<&str> = content.lines().collect();
49 let total_lines = lines.len();
50
51 let (selected_lines, was_truncated) = if total_lines <= limits.max_file_lines {
52 (lines.clone(), false)
53 } else {
54 (lines[..limits.max_file_lines].to_vec(), true)
56 };
57
58 let mut lines_char_truncated = 0;
59 let processed: Vec<String> = selected_lines
60 .iter()
61 .map(|line| {
62 if line.chars().count() > limits.max_line_length {
63 lines_char_truncated += 1;
64 let truncated: String = line.chars().take(limits.max_line_length).collect();
65 let extra = line.chars().count() - limits.max_line_length;
66 format!("{}...[{} chars truncated]", truncated, extra)
67 } else {
68 line.to_string()
69 }
70 })
71 .collect();
72
73 let returned_lines = processed.len();
74 let mut result = processed.join("\n");
75
76 if was_truncated {
78 result.push_str(&format!(
79 "\n\n[OUTPUT TRUNCATED: Showing first {} of {} lines. Use start_line/end_line to read specific sections.]",
80 returned_lines, total_lines
81 ));
82 }
83
84 TruncatedFileContent {
85 content: result,
86 total_lines,
87 returned_lines,
88 was_truncated,
89 lines_char_truncated,
90 }
91}
92
93pub struct TruncatedShellOutput {
95 pub stdout: String,
97 pub stderr: String,
99 pub stdout_total_lines: usize,
101 pub stderr_total_lines: usize,
103 pub stdout_truncated: bool,
105 pub stderr_truncated: bool,
107}
108
109pub fn truncate_shell_output(
112 stdout: &str,
113 stderr: &str,
114 limits: &TruncationLimits,
115) -> TruncatedShellOutput {
116 let stdout_result = truncate_stream(
117 stdout,
118 limits.shell_prefix_lines,
119 limits.shell_suffix_lines,
120 limits.max_line_length,
121 );
122
123 let stderr_result = truncate_stream(
124 stderr,
125 limits.shell_prefix_lines,
126 limits.shell_suffix_lines,
127 limits.max_line_length,
128 );
129
130 TruncatedShellOutput {
131 stdout: stdout_result.0,
132 stderr: stderr_result.0,
133 stdout_total_lines: stdout_result.1,
134 stderr_total_lines: stderr_result.1,
135 stdout_truncated: stdout_result.2,
136 stderr_truncated: stderr_result.2,
137 }
138}
139
140fn truncate_stream(
142 content: &str,
143 prefix_lines: usize,
144 suffix_lines: usize,
145 max_line_length: usize,
146) -> (String, usize, bool) {
147 let lines: Vec<&str> = content.lines().collect();
148 let total_lines = lines.len();
149 let max_total = prefix_lines + suffix_lines;
150
151 if total_lines <= max_total {
152 let processed: Vec<String> = lines
154 .iter()
155 .map(|line| truncate_line(line, max_line_length))
156 .collect();
157 return (processed.join("\n"), total_lines, false);
158 }
159
160 let mut result = Vec::new();
162
163 for line in lines.iter().take(prefix_lines) {
165 result.push(truncate_line(line, max_line_length));
166 }
167
168 let hidden = total_lines - prefix_lines - suffix_lines;
170 result.push(format!(
171 "\n... [{} lines hidden, showing first {} and last {} of {} total] ...\n",
172 hidden, prefix_lines, suffix_lines, total_lines
173 ));
174
175 for line in lines.iter().skip(total_lines - suffix_lines) {
177 result.push(truncate_line(line, max_line_length));
178 }
179
180 (result.join("\n"), total_lines, true)
181}
182
183fn truncate_line(line: &str, max_length: usize) -> String {
185 if line.chars().count() <= max_length {
186 line.to_string()
187 } else {
188 let truncated: String = line.chars().take(max_length).collect();
189 let extra = line.chars().count() - max_length;
190 format!("{}...[{} chars]", truncated, extra)
191 }
192}
193
194pub struct TruncatedDirListing {
196 pub entries: Vec<serde_json::Value>,
198 pub total_entries: usize,
200 pub was_truncated: bool,
202}
203
204pub fn truncate_dir_listing(
206 entries: Vec<serde_json::Value>,
207 max_entries: usize,
208) -> TruncatedDirListing {
209 let total_entries = entries.len();
210
211 if total_entries <= max_entries {
212 TruncatedDirListing {
213 entries,
214 total_entries,
215 was_truncated: false,
216 }
217 } else {
218 TruncatedDirListing {
219 entries: entries.into_iter().take(max_entries).collect(),
220 total_entries,
221 was_truncated: true,
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn test_truncate_file_no_truncation_needed() {
232 let content = "line1\nline2\nline3";
233 let limits = TruncationLimits::default();
234 let result = truncate_file_content(content, &limits);
235
236 assert_eq!(result.total_lines, 3);
237 assert_eq!(result.returned_lines, 3);
238 assert!(!result.was_truncated);
239 assert_eq!(result.content, content);
240 }
241
242 #[test]
243 fn test_truncate_file_exceeds_limit() {
244 let lines: Vec<String> = (0..100).map(|i| format!("line {}", i)).collect();
245 let content = lines.join("\n");
246 let limits = TruncationLimits {
247 max_file_lines: 10,
248 ..Default::default()
249 };
250 let result = truncate_file_content(&content, &limits);
251
252 assert_eq!(result.total_lines, 100);
253 assert_eq!(result.returned_lines, 10);
254 assert!(result.was_truncated);
255 assert!(result.content.contains("[OUTPUT TRUNCATED"));
256 }
257
258 #[test]
259 fn test_truncate_shell_prefix_suffix() {
260 let lines: Vec<String> = (0..500).map(|i| format!("output line {}", i)).collect();
261 let stdout = lines.join("\n");
262 let limits = TruncationLimits {
263 shell_prefix_lines: 5,
264 shell_suffix_lines: 5,
265 ..Default::default()
266 };
267 let result = truncate_shell_output(&stdout, "", &limits);
268
269 assert_eq!(result.stdout_total_lines, 500);
270 assert!(result.stdout_truncated);
271 assert!(result.stdout.contains("output line 0"));
272 assert!(result.stdout.contains("output line 499"));
273 assert!(result.stdout.contains("lines hidden"));
274 }
275
276 #[test]
277 fn test_truncate_long_line() {
278 let long_line = "x".repeat(3000);
279 let result = truncate_line(&long_line, 100);
280
281 assert!(result.len() < 200); assert!(result.contains("chars]"));
283 }
284
285 #[test]
286 fn test_truncate_dir_listing() {
287 let entries: Vec<serde_json::Value> = (0..100)
288 .map(|i| serde_json::json!({"name": format!("file{}", i)}))
289 .collect();
290
291 let result = truncate_dir_listing(entries, 10);
292
293 assert_eq!(result.total_entries, 100);
294 assert_eq!(result.entries.len(), 10);
295 assert!(result.was_truncated);
296 }
297}