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 use std::time::Duration;
121
122 fn make_output(exit_code: i32, stdout: &str) -> CommandOutput {
123 CommandOutput {
124 stdout: stdout.as_bytes().to_vec(),
125 stderr: Vec::new(),
126 exit_code,
127 duration: Duration::from_millis(100),
128 }
129 }
130
131 #[test]
132 fn test_passthrough_small_output() {
133 let out = make_output(0, "hello world\n");
134 let result = classify(&out, "echo hello", &[]);
135 assert!(
136 matches!(result, Classification::Passthrough { output } if output == "hello world\n")
137 );
138 }
139
140 #[test]
141 fn test_failure_output() {
142 let out = make_output(1, "error: something broke\n");
143 let result = classify(&out, "some_cmd", &[]);
144 match result {
145 Classification::Failure { label, output } => {
146 assert_eq!(label, "some_cmd");
147 assert!(output.contains("something broke"));
148 }
149 _ => panic!("expected Failure"),
150 }
151 }
152
153 #[test]
154 fn test_large_output_no_pattern() {
155 let big = "x\n".repeat(3000); let out = make_output(0, &big);
157 let result = classify(&out, "unknown_cmd", &[]);
158 match result {
159 Classification::Large { label, size, .. } => {
160 assert_eq!(label, "unknown_cmd");
161 assert!(size > SMALL_THRESHOLD);
162 }
163 _ => panic!("expected Large"),
164 }
165 }
166
167 #[test]
168 fn test_large_output_with_pattern() {
169 let patterns = pattern::builtins();
170 let big = format!("{}\n47 passed in 3.2s\n", ".\n".repeat(3000));
171 let out = make_output(0, &big);
172 let result = classify(&out, "pytest tests/", patterns);
173 match result {
174 Classification::Success { label, summary } => {
175 assert_eq!(label, "pytest");
176 assert_eq!(summary, "47 passed, 3.2s");
177 }
178 _ => panic!("expected Success"),
179 }
180 }
181
182 #[test]
183 fn test_smart_truncation_short() {
184 let lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
185 let result = smart_truncate(&lines);
186 assert_eq!(result, lines);
187 assert!(!result.contains("truncated"));
188 }
189
190 #[test]
191 fn test_smart_truncation_long() {
192 let lines: String = (0..200)
193 .map(|i| format!("line {i}"))
194 .collect::<Vec<_>>()
195 .join("\n");
196 let result = smart_truncate(&lines);
197 assert!(result.contains("line 0"));
198 assert!(result.contains("line 199"));
199 assert!(result.contains("truncated"));
200 let result_lines: Vec<&str> = result.lines().collect();
202 assert!(result_lines.len() <= MAX_LINES + 1); }
204
205 #[test]
206 fn test_label_derivation() {
207 assert_eq!(label("pytest -x"), "pytest");
208 assert_eq!(label("cargo test"), "cargo");
209 assert_eq!(label("gh issue list"), "gh");
210 assert_eq!(label("/usr/bin/python test.py"), "python");
211 }
212
213 #[test]
214 fn test_failure_with_pattern() {
215 let patterns = pattern::builtins();
216 let big_fail: String = (0..100).map(|i| format!("error line {i}\n")).collect();
217 let out = make_output(1, &big_fail);
218 let result = classify(&out, "pytest -x", &patterns);
219 match result {
220 Classification::Failure { label, output } => {
221 assert_eq!(label, "pytest");
222 assert!(output.contains("error line 70"));
224 assert!(output.contains("error line 99"));
225 }
226 _ => panic!("expected Failure"),
227 }
228 }
229
230 #[test]
231 fn test_empty_output_passthrough() {
232 let out = make_output(0, "");
233 let result = classify(&out, "true", &[]);
234 assert!(matches!(result, Classification::Passthrough { output } if output.is_empty()));
235 }
236
237 #[test]
238 fn test_success_with_empty_summary_is_quiet() {
239 let patterns = pattern::builtins();
240 let big = "Compiling foo\n".repeat(500);
241 let out = make_output(0, &big);
242 let result = classify(&out, "cargo build --release", &patterns);
243 match result {
244 Classification::Success { summary, .. } => {
245 assert!(summary.is_empty()); }
247 _ => panic!("expected Success with empty summary"),
248 }
249 }
250}