Skip to main content

cli_denoiser/filters/
docker.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use super::{Filter, FilterResult};
5
6static DOCKER_LAYER_CACHE: LazyLock<Regex> = LazyLock::new(|| {
7    Regex::new(r"^\s*(?:--->\s+[a-f0-9]{12}|---> Using cache|Removing intermediate container)")
8        .expect("docker layer regex valid")
9});
10
11static DOCKER_PULL_PROGRESS: LazyLock<Regex> = LazyLock::new(|| {
12    Regex::new(
13        r"^[a-f0-9]{12}:\s+(?:Pulling|Waiting|Downloading|Extracting|Verifying|Pull complete)",
14    )
15    .expect("docker pull regex valid")
16});
17
18static DOCKER_DIGEST: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(r"^(?:Digest:\s+sha256:|Status:\s+Downloaded newer image)")
20        .expect("docker digest regex valid")
21});
22
23/// Docker-specific noise filter.
24///
25/// Strips: layer cache hits, intermediate container IDs, pull progress,
26/// image digest lines.
27///
28/// Preserves: build step commands (Step N/M : RUN ...), errors,
29/// container output, final image ID, COPY/ADD context.
30pub struct DockerFilter;
31
32impl Filter for DockerFilter {
33    fn name(&self) -> &'static str {
34        "docker"
35    }
36
37    fn filter_line(&self, line: &str) -> FilterResult {
38        let trimmed = line.trim();
39
40        if trimmed.is_empty() {
41            return FilterResult::Keep;
42        }
43
44        // Layer cache and intermediate container noise
45        if DOCKER_LAYER_CACHE.is_match(trimmed) {
46            return FilterResult::Drop;
47        }
48
49        // Pull progress per-layer
50        if DOCKER_PULL_PROGRESS.is_match(trimmed) {
51            return FilterResult::Drop;
52        }
53
54        // Digest/status lines
55        if DOCKER_DIGEST.is_match(trimmed) {
56            return FilterResult::Drop;
57        }
58
59        FilterResult::Keep
60    }
61
62    fn filter_block(&self, lines: &[String]) -> Vec<String> {
63        let mut result = Vec::with_capacity(lines.len());
64        let mut pull_count: usize = 0;
65        let mut cache_count: usize = 0;
66
67        for line in lines {
68            let trimmed = line.trim();
69
70            if DOCKER_PULL_PROGRESS.is_match(trimmed) || DOCKER_DIGEST.is_match(trimmed) {
71                pull_count += 1;
72                continue;
73            }
74
75            if DOCKER_LAYER_CACHE.is_match(trimmed) {
76                cache_count += 1;
77                continue;
78            }
79
80            emit_summaries(&mut result, &mut pull_count, &mut cache_count);
81            result.push(line.clone());
82        }
83
84        emit_summaries(&mut result, &mut pull_count, &mut cache_count);
85        result
86    }
87}
88
89fn emit_summaries(result: &mut Vec<String>, pull_count: &mut usize, cache_count: &mut usize) {
90    if *pull_count > 0 {
91        result.push(format!("[pulled {pull_count} layers]"));
92        *pull_count = 0;
93    }
94    if *cache_count > 0 {
95        result.push(format!("[{cache_count} cached layers]"));
96        *cache_count = 0;
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn drops_layer_cache() {
106        let filter = DockerFilter;
107        assert_eq!(filter.filter_line("---> Using cache"), FilterResult::Drop);
108        assert_eq!(filter.filter_line("---> a1b2c3d4e5f6"), FilterResult::Drop);
109    }
110
111    #[test]
112    fn drops_pull_progress() {
113        let filter = DockerFilter;
114        assert_eq!(
115            filter.filter_line("a1b2c3d4e5f6: Downloading  45.2MB/100.0MB"),
116            FilterResult::Drop
117        );
118        assert_eq!(
119            filter.filter_line("a1b2c3d4e5f6: Pull complete"),
120            FilterResult::Drop
121        );
122    }
123
124    #[test]
125    fn keeps_build_steps() {
126        let filter = DockerFilter;
127        assert_eq!(
128            filter.filter_line("Step 3/10 : RUN apt-get update"),
129            FilterResult::Keep
130        );
131    }
132
133    #[test]
134    fn keeps_errors() {
135        let filter = DockerFilter;
136        assert_eq!(
137            filter.filter_line("ERROR: failed to solve: process did not complete"),
138            FilterResult::Keep
139        );
140    }
141
142    #[test]
143    fn block_collapses_pull() {
144        let filter = DockerFilter;
145        let lines = vec![
146            "Using default tag: latest".to_string(),
147            "a1b2c3d4e5f6: Pulling fs layer".to_string(),
148            "b2c3d4e5f6a1: Pulling fs layer".to_string(),
149            "a1b2c3d4e5f6: Downloading  45MB/100MB".to_string(),
150            "a1b2c3d4e5f6: Pull complete".to_string(),
151            "b2c3d4e5f6a1: Pull complete".to_string(),
152            "Digest: sha256:abc123".to_string(),
153            "Status: Downloaded newer image for node:20".to_string(),
154        ];
155        let result = filter.filter_block(&lines);
156        assert_eq!(result.len(), 2);
157        assert_eq!(result[0], "Using default tag: latest");
158        assert!(result[1].contains("pulled"));
159    }
160}