1use crate::exec::CommandOutput;
2use crate::pattern::{self, Pattern};
3
4const SMALL_THRESHOLD: usize = 4096;
6
7const TRUNCATION_THRESHOLD: usize = 80;
9
10const MAX_LINES: usize = 120;
12
13pub enum Classification {
14 Failure { label: String, output: String },
16 Passthrough { output: String },
18 Success { label: String, summary: String },
20 Large {
22 label: String,
23 output: String,
24 size: usize,
25 },
26}
27
28pub fn label(command: &str) -> String {
30 command
31 .split_whitespace()
32 .next()
33 .unwrap_or("command")
34 .rsplit('/')
35 .next()
36 .unwrap_or("command")
37 .to_string()
38}
39
40pub fn classify(output: &CommandOutput, command: &str, patterns: &[Pattern]) -> Classification {
41 let merged = output.merged_lossy();
42 let lbl = label(command);
43
44 if output.exit_code != 0 {
46 let filtered = match pattern::find_matching(command, patterns) {
47 Some(pat) => {
48 if let Some(failure) = &pat.failure {
49 pattern::extract_failure(failure, &merged)
50 } else {
51 smart_truncate(&merged)
52 }
53 }
54 None => smart_truncate(&merged),
55 };
56 return Classification::Failure {
57 label: lbl,
58 output: filtered,
59 };
60 }
61
62 if merged.len() <= SMALL_THRESHOLD {
64 return Classification::Passthrough { output: merged };
65 }
66
67 if let Some(pat) = pattern::find_matching(command, patterns) {
69 if let Some(sp) = &pat.success {
70 if let Some(summary) = pattern::extract_summary(sp, &merged) {
71 return Classification::Success {
72 label: lbl,
73 summary,
74 };
75 }
76 }
77 }
78
79 let size = merged.len();
81 Classification::Large {
82 label: lbl,
83 output: merged,
84 size,
85 }
86}
87
88pub fn smart_truncate(output: &str) -> String {
90 let lines: Vec<&str> = output.lines().collect();
91 let total = lines.len();
92
93 if total <= TRUNCATION_THRESHOLD {
94 return output.to_string();
95 }
96
97 let budget = total.min(MAX_LINES);
98 let head_count = (budget as f64 * 0.6).ceil() as usize;
99 let tail_count = budget - head_count;
100 let truncated = total - head_count - tail_count;
101
102 let mut result = lines[..head_count].join("\n");
103 if truncated > 0 {
104 result.push_str(&format!("\n... [{truncated} lines truncated] ...\n"));
105 }
106 if tail_count > 0 {
107 result.push_str(&lines[total - tail_count..].join("\n"));
108 }
109 result
110}
111
112#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::exec::CommandOutput;
120
121 fn make_output(exit_code: i32, stdout: &str) -> CommandOutput {
122 CommandOutput {
123 stdout: stdout.as_bytes().to_vec(),
124 stderr: Vec::new(),
125 exit_code,
126 }
127 }
128
129 #[test]
130 fn test_passthrough_small_output() {
131 let out = make_output(0, "hello world\n");
132 let result = classify(&out, "echo hello", &[]);
133 assert!(
134 matches!(result, Classification::Passthrough { output } if output == "hello world\n")
135 );
136 }
137
138 #[test]
139 fn test_failure_output() {
140 let out = make_output(1, "error: something broke\n");
141 let result = classify(&out, "some_cmd", &[]);
142 match result {
143 Classification::Failure { label, output } => {
144 assert_eq!(label, "some_cmd");
145 assert!(output.contains("something broke"));
146 }
147 _ => panic!("expected Failure"),
148 }
149 }
150
151 #[test]
152 fn test_large_output_no_pattern() {
153 let big = "x\n".repeat(3000); let out = make_output(0, &big);
155 let result = classify(&out, "unknown_cmd", &[]);
156 match result {
157 Classification::Large { label, size, .. } => {
158 assert_eq!(label, "unknown_cmd");
159 assert!(size > SMALL_THRESHOLD);
160 }
161 _ => panic!("expected Large"),
162 }
163 }
164
165 #[test]
166 fn test_large_output_with_pattern() {
167 let patterns = pattern::builtins();
168 let big = format!("{}\n47 passed in 3.2s\n", ".\n".repeat(3000));
169 let out = make_output(0, &big);
170 let result = classify(&out, "pytest tests/", patterns);
171 match result {
172 Classification::Success { label, summary } => {
173 assert_eq!(label, "pytest");
174 assert_eq!(summary, "47 passed, 3.2s");
175 }
176 _ => panic!("expected Success"),
177 }
178 }
179
180 #[test]
181 fn test_smart_truncation_short() {
182 let lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
183 let result = smart_truncate(&lines);
184 assert_eq!(result, lines);
185 assert!(!result.contains("truncated"));
186 }
187
188 #[test]
189 fn test_smart_truncation_long() {
190 let lines: String = (0..200)
191 .map(|i| format!("line {i}"))
192 .collect::<Vec<_>>()
193 .join("\n");
194 let result = smart_truncate(&lines);
195 assert!(result.contains("line 0"));
196 assert!(result.contains("line 199"));
197 assert!(result.contains("truncated"));
198 let result_lines: Vec<&str> = result.lines().collect();
200 assert!(result_lines.len() <= MAX_LINES + 1); }
202
203 #[test]
204 fn test_label_derivation() {
205 assert_eq!(label("pytest -x"), "pytest");
206 assert_eq!(label("cargo test"), "cargo");
207 assert_eq!(label("gh issue list"), "gh");
208 assert_eq!(label("/usr/bin/python test.py"), "python");
209 }
210
211 #[test]
212 fn test_failure_with_pattern() {
213 let patterns = pattern::builtins();
214 let big_fail: String = (0..100).map(|i| format!("error line {i}\n")).collect();
215 let out = make_output(1, &big_fail);
216 let result = classify(&out, "pytest -x", &patterns);
217 match result {
218 Classification::Failure { label, output } => {
219 assert_eq!(label, "pytest");
220 assert!(output.contains("error line 70"));
222 assert!(output.contains("error line 99"));
223 }
224 _ => panic!("expected Failure"),
225 }
226 }
227
228 #[test]
229 fn test_empty_output_passthrough() {
230 let out = make_output(0, "");
231 let result = classify(&out, "true", &[]);
232 assert!(matches!(result, Classification::Passthrough { output } if output.is_empty()));
233 }
234
235 #[test]
236 fn test_success_with_empty_summary_is_quiet() {
237 let patterns = pattern::builtins();
238 let big = "Compiling foo\n".repeat(500);
239 let out = make_output(0, &big);
240 let result = classify(&out, "cargo build --release", &patterns);
241 match result {
242 Classification::Success { summary, .. } => {
243 assert!(summary.is_empty()); }
245 _ => panic!("expected Success with empty summary"),
246 }
247 }
248}