Skip to main content

zeph_tools/filter/
cargo_build.rs

1use std::fmt::Write;
2use std::sync::LazyLock;
3
4use super::{
5    CargoBuildFilterConfig, CommandMatcher, FilterConfidence, FilterResult, OutputFilter,
6    make_result,
7};
8
9static CARGO_BUILD_MATCHER: LazyLock<CommandMatcher> = LazyLock::new(|| {
10    CommandMatcher::Custom(Box::new(|cmd| {
11        let c = cmd.to_lowercase();
12        let tokens: Vec<&str> = c.split_whitespace().collect();
13        if tokens.first() != Some(&"cargo") {
14            return false;
15        }
16        let dominated = ["test", "nextest", "clippy"];
17        !tokens.iter().skip(1).any(|t| dominated.contains(t))
18    }))
19});
20
21const NOISE_PREFIXES: &[&str] = &[
22    "Compiling ",
23    "Downloading ",
24    "Downloaded ",
25    "Updating ",
26    "Fetching ",
27    "Fresh ",
28    "Packaging ",
29    "Verifying ",
30    "Archiving ",
31    "Locking ",
32    "Adding ",
33    "Removing ",
34    "Checking ",
35    "Documenting ",
36    "Running ",
37    "Loaded ",
38    "Blocking ",
39    "Unpacking ",
40];
41
42/// Max lines to keep when output has no recognizable noise pattern.
43const LONG_OUTPUT_THRESHOLD: usize = 30;
44const KEEP_HEAD: usize = 10;
45const KEEP_TAIL: usize = 5;
46
47fn is_noise(line: &str) -> bool {
48    let trimmed = line.trim_start();
49    NOISE_PREFIXES.iter().any(|p| trimmed.starts_with(p))
50}
51
52/// Check if a line is cargo build/fetch noise (for reuse by other filters).
53pub fn is_cargo_noise(line: &str) -> bool {
54    let trimmed = line.trim_start();
55    trimmed.starts_with("Finished ") || is_noise(line)
56}
57
58pub struct CargoBuildFilter;
59
60impl CargoBuildFilter {
61    #[must_use]
62    pub fn new(_config: CargoBuildFilterConfig) -> Self {
63        Self
64    }
65}
66
67impl OutputFilter for CargoBuildFilter {
68    fn name(&self) -> &'static str {
69        "cargo_build"
70    }
71
72    fn matcher(&self) -> &CommandMatcher {
73        &CARGO_BUILD_MATCHER
74    }
75
76    fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult {
77        let mut noise_count = 0usize;
78        let mut kept = Vec::new();
79        let mut finished_line: Option<&str> = None;
80
81        for line in raw_output.lines() {
82            let trimmed = line.trim_start();
83            if trimmed.starts_with("Finished ") {
84                finished_line = Some(trimmed);
85                noise_count += 1;
86            } else if is_noise(line) {
87                noise_count += 1;
88            } else {
89                kept.push(line);
90            }
91        }
92
93        if noise_count > 0 {
94            return build_noise_result(raw_output, &kept, finished_line, noise_count);
95        }
96
97        if exit_code != 0 {
98            return make_result(
99                raw_output,
100                raw_output.to_owned(),
101                FilterConfidence::Fallback,
102            );
103        }
104
105        // No recognizable noise — apply generic long-output truncation
106        let lines: Vec<&str> = raw_output.lines().collect();
107        if lines.len() > LONG_OUTPUT_THRESHOLD {
108            return truncate_long(raw_output, &lines);
109        }
110
111        make_result(
112            raw_output,
113            raw_output.to_owned(),
114            FilterConfidence::Fallback,
115        )
116    }
117}
118
119fn build_noise_result(
120    raw: &str,
121    kept: &[&str],
122    finished_line: Option<&str>,
123    noise_count: usize,
124) -> FilterResult {
125    let mut output = String::new();
126    if let Some(fin) = finished_line {
127        let _ = writeln!(output, "{fin}");
128    }
129    let _ = writeln!(output, "({noise_count} compile/fetch lines removed)");
130    if !kept.is_empty() {
131        output.push('\n');
132        if kept.len() > LONG_OUTPUT_THRESHOLD {
133            let omitted = kept.len() - KEEP_HEAD - KEEP_TAIL;
134            for line in &kept[..KEEP_HEAD] {
135                let _ = writeln!(output, "{line}");
136            }
137            let _ = writeln!(output, "\n... ({omitted} lines omitted) ...\n");
138            for line in &kept[kept.len() - KEEP_TAIL..] {
139                let _ = writeln!(output, "{line}");
140            }
141        } else {
142            for line in kept {
143                let _ = writeln!(output, "{line}");
144            }
145        }
146    }
147    make_result(raw, output.trim_end().to_owned(), FilterConfidence::Full)
148}
149
150fn truncate_long(raw: &str, lines: &[&str]) -> FilterResult {
151    let total = lines.len();
152    let omitted = total - KEEP_HEAD - KEEP_TAIL;
153    let mut output = String::new();
154    for line in &lines[..KEEP_HEAD] {
155        let _ = writeln!(output, "{line}");
156    }
157    let _ = writeln!(output, "\n... ({omitted} lines omitted) ...\n");
158    for line in &lines[total - KEEP_TAIL..] {
159        let _ = writeln!(output, "{line}");
160    }
161    make_result(raw, output.trim_end().to_owned(), FilterConfidence::Partial)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn make_filter() -> CargoBuildFilter {
169        CargoBuildFilter::new(CargoBuildFilterConfig::default())
170    }
171
172    #[test]
173    fn matches_cargo_build_commands() {
174        let f = make_filter();
175        assert!(f.matcher().matches("cargo build"));
176        assert!(f.matcher().matches("cargo build --release"));
177        assert!(f.matcher().matches("cargo doc --no-deps"));
178        assert!(f.matcher().matches("cargo +nightly fmt --check"));
179        assert!(f.matcher().matches("cargo audit"));
180        assert!(f.matcher().matches("cargo tree --duplicates"));
181        assert!(f.matcher().matches("cargo bench"));
182    }
183
184    #[test]
185    fn skips_test_and_clippy() {
186        let f = make_filter();
187        assert!(!f.matcher().matches("cargo test"));
188        assert!(!f.matcher().matches("cargo nextest run"));
189        assert!(!f.matcher().matches("cargo clippy --workspace"));
190    }
191
192    #[test]
193    fn filters_compile_noise() {
194        let f = make_filter();
195        let raw = "    Compiling serde v1.0.200\n    Compiling zeph-core v0.9.9\n    Compiling zeph-tools v0.9.9\n    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.32s";
196        let result = f.filter("cargo build", raw, 0);
197        assert_eq!(result.confidence, FilterConfidence::Full);
198        assert!(result.output.contains("Finished"));
199        assert!(result.output.contains("4 compile/fetch lines removed"));
200        assert!(!result.output.contains("Compiling"));
201    }
202
203    #[test]
204    fn filters_audit_noise() {
205        let f = make_filter();
206        let raw = "    Fetching advisory database from `https://github.com/RustSec/advisory-db.git`\n      Loaded 920 security advisories (from /Users/rabax/.cargo/advisory-db)\n    Updating crates.io index\n0 vulnerabilities found";
207        let result = f.filter("cargo audit", raw, 1);
208        assert_eq!(result.confidence, FilterConfidence::Full);
209        assert!(result.output.contains("3 compile/fetch lines removed"));
210        assert!(result.output.contains("0 vulnerabilities found"));
211        assert!(!result.output.contains("Fetching"));
212    }
213
214    #[test]
215    fn truncates_long_tree_output() {
216        let f = make_filter();
217        let mut lines = Vec::new();
218        for i in 0..80 {
219            lines.push(format!("├── dep-{i} v0.1.{i}"));
220        }
221        let raw = lines.join("\n");
222        let result = f.filter("cargo tree", &raw, 0);
223        assert_eq!(result.confidence, FilterConfidence::Partial);
224        assert!(result.output.contains("lines omitted"));
225        assert!(result.output.contains("dep-0"));
226        assert!(result.output.contains("dep-79"));
227    }
228
229    #[test]
230    fn preserves_full_on_error() {
231        let f = make_filter();
232        let raw = "error[E0308]: mismatched types\n  --> src/main.rs:10:5";
233        let result = f.filter("cargo build", raw, 1);
234        assert_eq!(result.output, raw);
235        assert_eq!(result.confidence, FilterConfidence::Fallback);
236    }
237
238    #[test]
239    fn passthrough_short_output() {
240        let f = make_filter();
241        let raw = "some short output\nonly two lines";
242        let result = f.filter("cargo build", raw, 0);
243        assert_eq!(result.output, raw);
244        assert_eq!(result.confidence, FilterConfidence::Fallback);
245    }
246
247    #[test]
248    fn keeps_non_noise_lines() {
249        let f = make_filter();
250        let raw = "    Compiling zeph-core v0.9.9\nwarning: unused import\n  --> src/lib.rs:5:1\n    Finished `dev` profile target(s) in 2.00s";
251        let result = f.filter("cargo build", raw, 0);
252        assert!(result.output.contains("warning: unused import"));
253        assert!(result.output.contains("src/lib.rs:5:1"));
254        assert!(!result.output.contains("Compiling"));
255    }
256
257    #[test]
258    fn cargo_build_filter_snapshot() {
259        let f = make_filter();
260        let raw = "\
261   Compiling zeph-core v0.11.0
262   Compiling zeph-tools v0.11.0
263   Compiling zeph-llm v0.11.0
264warning: unused import: `std::fmt`
265  --> crates/zeph-core/src/lib.rs:3:5
266   |
2673  |     use std::fmt;
268   |         ^^^^^^^^
269   = note: `#[warn(unused_imports)]` on by default
270   Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.23s";
271        let result = f.filter("cargo build", raw, 0);
272        insta::assert_snapshot!(result.output);
273    }
274
275    #[test]
276    fn cargo_build_error_snapshot() {
277        let f = make_filter();
278        let raw = "\
279   Compiling zeph-core v0.11.0
280error[E0308]: mismatched types
281  --> crates/zeph-core/src/lib.rs:10:5
282   |
28310 |     return 42;
284   |            ^^ expected `()`, found integer
285error: could not compile `zeph-core` due to 1 previous error";
286        let result = f.filter("cargo build", raw, 1);
287        insta::assert_snapshot!(result.output);
288    }
289}