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