1use crate::tool_output_sanitizer::{
8 clean_exec_output, output_verbosity_budget, priority_aware_truncate, resolve_auto_mode,
9 truncate_exec_stream,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ExecToolResultPayload {
14 pub stdout: String,
15 pub stderr: String,
16 pub exit_code: i32,
17 pub success: bool,
18 pub truncated: bool,
19 pub total_lines: usize,
20 pub raw_output: String,
21}
22
23impl ExecToolResultPayload {
24 pub fn new(stdout: &str, stderr: &str, exit_code: i32, output_mode: &str) -> Self {
25 let clean_stdout = clean_exec_output(stdout);
26 let clean_stderr = clean_exec_output(stderr);
27 let effective_mode = resolve_auto_mode(output_mode, exit_code);
32 let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
33 (
34 truncate_exec_stream(&clean_stdout, budget, exit_code),
35 priority_aware_truncate(&clean_stderr, budget.min(4096)),
36 )
37 } else {
38 (clean_stdout.clone(), clean_stderr.clone())
39 };
40 let truncated = stdout != clean_stdout || stderr != clean_stderr;
41 let total_lines = clean_stdout.lines().count();
42 let mut raw_output = clean_stdout;
43 if !clean_stderr.is_empty() {
44 raw_output.push_str("\n--- stderr ---\n");
45 raw_output.push_str(&clean_stderr);
46 }
47
48 Self {
49 stdout,
50 stderr,
51 exit_code,
52 success: exit_code == 0,
53 truncated,
54 total_lines,
55 raw_output,
56 }
57 }
58}
59
60#[cfg(test)]
61mod tests {
62 use super::ExecToolResultPayload;
63 use crate::tool_output_sanitizer::{AUTO_SUCCESS_BUDGET, NORMAL_BUDGET};
64
65 #[test]
66 fn preserves_full_raw_output_and_marks_truncation() {
67 let stdout = (0..400)
68 .map(|index| format!("line {index}"))
69 .collect::<Vec<_>>()
70 .join("\n");
71 let payload = ExecToolResultPayload::new(&stdout, "warn\n", 17, "concise");
72
73 assert_eq!(payload.exit_code, 17);
74 assert!(!payload.success);
75 assert!(payload.truncated);
76 assert_eq!(payload.total_lines, 400);
77 assert!(payload.stdout.len() < stdout.len());
78 assert!(payload.raw_output.contains("line 0"));
79 assert!(payload.raw_output.contains("line 399"));
80 assert!(payload.raw_output.contains("--- stderr ---"));
81 }
82
83 #[test]
84 fn full_mode_keeps_complete_output_inline() {
85 let payload = ExecToolResultPayload::new("alpha\nbeta\n", "", 0, "full");
86
87 assert_eq!(payload.stdout, "alpha\nbeta\n");
88 assert_eq!(payload.stderr, "");
89 assert!(!payload.truncated);
90 assert_eq!(payload.total_lines, 2);
91 assert_eq!(payload.raw_output, "alpha\nbeta\n");
92 }
93
94 #[test]
99 fn auto_success_produces_compact_inline_output() {
100 let stdout = (0..2000)
101 .map(|i| format!("success-line-{i}"))
102 .collect::<Vec<_>>()
103 .join("\n");
104 let payload = ExecToolResultPayload::new(&stdout, "", 0, "auto");
105
106 assert!(payload.success);
107 assert!(
108 payload.stdout.len() <= AUTO_SUCCESS_BUDGET,
109 "auto+success stdout should fit in AUTO_SUCCESS_BUDGET ({}), got {} bytes",
110 AUTO_SUCCESS_BUDGET,
111 payload.stdout.len()
112 );
113 assert_eq!(payload.total_lines, 2000);
115 assert!(payload.raw_output.contains("success-line-0"));
117 assert!(payload.raw_output.contains("success-line-1999"));
118 assert!(payload.truncated);
119 }
120
121 #[test]
122 fn auto_failure_uses_normal_diagnostic_budget() {
123 let mut lines = Vec::new();
126 for i in 0..200 {
127 lines.push(format!("building module {i}"));
128 }
129 lines.push("error: failed to compile".to_string());
130 lines.push(" --> src/main.rs:1:1".to_string());
131 for i in 0..2000 {
132 lines.push(format!("trailing diagnostic line {i}"));
133 }
134 let stdout = lines.join("\n");
135
136 let payload = ExecToolResultPayload::new(&stdout, "stderr details\n", 1, "auto");
137
138 assert!(!payload.success);
139 assert_eq!(payload.exit_code, 1);
140 assert!(
142 payload.stdout.len() > AUTO_SUCCESS_BUDGET,
143 "auto+failure stdout should exceed AUTO_SUCCESS_BUDGET to fit diagnostics, got {} bytes",
144 payload.stdout.len()
145 );
146 assert!(
147 payload.stdout.len() <= NORMAL_BUDGET,
148 "auto+failure stdout should be capped at NORMAL_BUDGET ({}), got {} bytes",
149 NORMAL_BUDGET,
150 payload.stdout.len()
151 );
152 assert!(
154 payload.stdout.contains("error: failed to compile"),
155 "error line must be preserved in failure diagnostic output"
156 );
157 assert!(payload.raw_output.contains("building module 0"));
159 assert!(payload.raw_output.contains("trailing diagnostic line 1999"));
160 assert!(payload.raw_output.contains("--- stderr ---"));
161 }
162
163 #[test]
164 fn auto_small_success_output_is_unchanged() {
165 let payload = ExecToolResultPayload::new("ok\n", "", 0, "auto");
166 assert!(payload.success);
167 assert_eq!(payload.stdout, "ok\n");
168 assert!(!payload.truncated);
169 }
170
171 #[test]
172 fn explicit_normal_still_uses_normal_budget_on_success() {
173 let stdout = (0..2000)
174 .map(|i| format!("line-{i}"))
175 .collect::<Vec<_>>()
176 .join("\n");
177 let payload = ExecToolResultPayload::new(&stdout, "", 0, "normal");
178
179 assert!(payload.success);
180 assert!(
183 payload.stdout.len() > AUTO_SUCCESS_BUDGET,
184 "explicit normal mode should not compact to auto-success budget on success"
185 );
186 assert!(payload.stdout.len() <= NORMAL_BUDGET);
187 }
188
189 #[test]
190 fn successful_source_search_preserves_leading_matches() {
191 let mut lines = vec![
192 "src/runtime.rs:10: first relevant match".to_string(),
193 "src/runtime.rs:11: second relevant match".to_string(),
194 ];
195 lines.extend((0..1200).map(|i| format!("src/module_{i}.rs: ordinary source line")));
196 lines.extend((0..300).map(|i| format!("src/errors_{i}.rs: struct ErrorContext{i}")));
197 let stdout = lines.join("\n");
198
199 let payload = ExecToolResultPayload::new(&stdout, "", 0, "normal");
200
201 assert!(payload.truncated);
202 assert!(payload.stdout.contains("first relevant match"));
203 assert!(payload.stdout.contains("second relevant match"));
204 }
205
206 #[test]
207 fn raw_output_preserved_across_modes() {
208 let stdout = (0..5000)
209 .map(|i| format!("line-{i}"))
210 .collect::<Vec<_>>()
211 .join("\n");
212
213 for mode in ["auto", "silent", "concise", "normal", "verbose", "full"] {
214 for exit_code in [0, 1] {
215 let payload = ExecToolResultPayload::new(&stdout, "err\n", exit_code, mode);
216 assert!(
217 payload.raw_output.contains("line-0"),
218 "raw_output should contain head for mode={mode} exit={exit_code}"
219 );
220 assert!(
221 payload.raw_output.contains("line-4999"),
222 "raw_output should contain tail for mode={mode} exit={exit_code}"
223 );
224 assert!(
225 payload.raw_output.contains("--- stderr ---"),
226 "raw_output should contain stderr marker for mode={mode} exit={exit_code}"
227 );
228 }
229 }
230 }
231}