opendev_tools_impl/
truncation.rs1use std::path::{Path, PathBuf};
10
11pub const MAX_LINES: usize = 2000;
13
14pub const MAX_BYTES: usize = 50 * 1024;
16
17const RETENTION_SECS: u64 = 7 * 24 * 60 * 60;
19
20const MAX_OVERFLOW_BYTES: usize = 1_024 * 1_024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TruncateDirection {
27 Head,
29 Tail,
31}
32
33#[derive(Debug, Clone)]
35pub struct TruncateResult {
36 pub content: String,
38 pub truncated: bool,
40 pub output_path: Option<PathBuf>,
42}
43
44pub fn output_dir() -> PathBuf {
46 dirs::home_dir()
47 .unwrap_or_else(|| PathBuf::from("/tmp"))
48 .join(".opendev")
49 .join("tool-output")
50}
51
52pub fn truncate_output(
58 text: &str,
59 max_lines: Option<usize>,
60 max_bytes: Option<usize>,
61 direction: TruncateDirection,
62) -> TruncateResult {
63 let max_lines = max_lines.unwrap_or(MAX_LINES);
64 let max_bytes = max_bytes.unwrap_or(MAX_BYTES);
65 let lines: Vec<&str> = text.lines().collect();
66 let total_bytes = text.len();
67
68 if lines.len() <= max_lines && total_bytes <= max_bytes {
70 return TruncateResult {
71 content: text.to_string(),
72 truncated: false,
73 output_path: None,
74 };
75 }
76
77 let mut kept: Vec<&str> = Vec::new();
79 let mut bytes = 0usize;
80 let mut hit_bytes = false;
81
82 match direction {
83 TruncateDirection::Head => {
84 for (i, line) in lines.iter().enumerate() {
85 if i >= max_lines {
86 break;
87 }
88 let line_bytes = line.len() + if i > 0 { 1 } else { 0 }; if bytes + line_bytes > max_bytes {
90 hit_bytes = true;
91 break;
92 }
93 kept.push(line);
94 bytes += line_bytes;
95 }
96 }
97 TruncateDirection::Tail => {
98 for (idx, line) in lines.iter().rev().enumerate() {
100 if idx >= max_lines {
101 break;
102 }
103 let line_bytes = line.len() + if idx > 0 { 1 } else { 0 };
104 if bytes + line_bytes > max_bytes {
105 hit_bytes = true;
106 break;
107 }
108 kept.push(line);
109 bytes += line_bytes;
110 }
111 kept.reverse();
112 }
113 }
114
115 let removed = if hit_bytes {
116 total_bytes - bytes
117 } else {
118 lines.len() - kept.len()
119 };
120 let unit = if hit_bytes { "bytes" } else { "lines" };
121 let preview = kept.join("\n");
122
123 let dir = output_dir();
125 let output_path = match save_overflow(&dir, text) {
126 Ok(p) => Some(p),
127 Err(e) => {
128 tracing::warn!(error = %e, "failed to save truncated tool output");
129 None
130 }
131 };
132
133 let hint = if let Some(ref path) = output_path {
134 format!(
135 "The tool call succeeded but the output was truncated. Full output saved to: {}\n\
136 Use Grep to search the full content or Read with offset/limit to view specific sections.",
137 path.display()
138 )
139 } else {
140 "The tool call succeeded but the output was truncated.".to_string()
141 };
142
143 let content = match direction {
144 TruncateDirection::Head => {
145 format!("{preview}\n\n...{removed} {unit} truncated...\n\n{hint}")
146 }
147 TruncateDirection::Tail => {
148 format!("...{removed} {unit} truncated...\n\n{hint}\n\n{preview}")
149 }
150 };
151
152 TruncateResult {
153 content,
154 truncated: true,
155 output_path,
156 }
157}
158
159fn save_overflow(dir: &Path, text: &str) -> std::io::Result<PathBuf> {
164 std::fs::create_dir_all(dir)?;
165
166 let timestamp = std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .unwrap_or_default()
169 .as_millis();
170 let id = uuid::Uuid::new_v4().simple().to_string();
171 let filename = format!("tool_{timestamp}_{}", &id[..8]);
172 let filepath = dir.join(filename);
173
174 let to_write = if text.len() > MAX_OVERFLOW_BYTES {
175 let head_size = MAX_OVERFLOW_BYTES * 3 / 4;
176 let tail_size = MAX_OVERFLOW_BYTES - head_size;
177 let head: String = text.chars().take(head_size).collect();
178 let tail: String = text
179 .chars()
180 .rev()
181 .take(tail_size)
182 .collect::<Vec<_>>()
183 .into_iter()
184 .rev()
185 .collect();
186 let omitted = text.len() - head_size - tail_size;
187 format!("{head}\n\n[... {omitted} bytes omitted from overflow file ...]\n\n{tail}")
188 } else {
189 text.to_string()
190 };
191
192 std::fs::write(&filepath, &to_write)?;
193 Ok(filepath)
194}
195
196pub fn cleanup_old_files() {
201 let dir = output_dir();
202 let entries = match std::fs::read_dir(&dir) {
203 Ok(rd) => rd,
204 Err(_) => return, };
206
207 let cutoff = std::time::SystemTime::now()
208 .checked_sub(std::time::Duration::from_secs(RETENTION_SECS))
209 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
210
211 let mut cleaned = 0u32;
212 for entry in entries.flatten() {
213 let name = entry.file_name().to_string_lossy().into_owned();
214 if !name.starts_with("tool_") {
215 continue;
216 }
217 if let Ok(meta) = entry.metadata()
219 && let Ok(mtime) = meta.modified()
220 && mtime < cutoff
221 && std::fs::remove_file(entry.path()).is_ok()
222 {
223 cleaned += 1;
224 }
225 }
226 if cleaned > 0 {
227 tracing::debug!(count = cleaned, "cleaned up old tool output files");
228 }
229}
230
231#[cfg(test)]
232#[path = "truncation_tests.rs"]
233mod tests;