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    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); // > 4KB
156        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        // Should not exceed MAX_LINES + marker
201        let result_lines: Vec<&str> = result.lines().collect();
202        assert!(result_lines.len() <= MAX_LINES + 1); // +1 for truncation marker
203    }
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                // pytest failure uses tail 30
223                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()); // quiet success
246            }
247            _ => panic!("expected Success with empty summary"),
248        }
249    }
250}