Skip to main content

double_o/
classify.rs

1use crate::exec::CommandOutput;
2use crate::pattern::{self, Pattern};
3
4/// 4 KB — below this, output passes through verbatim.
5const SMALL_THRESHOLD: usize = 4096;
6
7/// Maximum lines to show in failure output before smart truncation kicks in.
8const TRUNCATION_THRESHOLD: usize = 80;
9
10/// Hard cap on total lines shown after truncation.
11const MAX_LINES: usize = 120;
12
13pub enum Classification {
14    /// Exit ≠ 0. Filtered failure output.
15    Failure { label: String, output: String },
16    /// Exit 0, output ≤ threshold. Verbatim.
17    Passthrough { output: String },
18    /// Exit 0, output > threshold, pattern matched with summary.
19    Success { label: String, summary: String },
20    /// Exit 0, output > threshold, no pattern. Content needs indexing.
21    Large {
22        label: String,
23        output: String,
24        size: usize,
25    },
26}
27
28/// Derive label from command string (first path component's filename or word).
29pub 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    // Failure path
45    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    // Success, small output → passthrough
63    if merged.len() <= SMALL_THRESHOLD {
64        return Classification::Passthrough { output: merged };
65    }
66
67    // Success, large output — try pattern
68    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    // Large, no pattern match → index
80    let size = merged.len();
81    Classification::Large {
82        label: lbl,
83        output: merged,
84        size,
85    }
86}
87
88/// Smart truncation: first 60% + marker + last 40%, capped at MAX_LINES.
89pub 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// ---------------------------------------------------------------------------
113// Tests
114// ---------------------------------------------------------------------------
115
116#[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); // > 4KB
154        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        // Should not exceed MAX_LINES + marker
199        let result_lines: Vec<&str> = result.lines().collect();
200        assert!(result_lines.len() <= MAX_LINES + 1); // +1 for truncation marker
201    }
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                // pytest failure uses tail 30
221                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()); // quiet success
244            }
245            _ => panic!("expected Success with empty summary"),
246        }
247    }
248}