Skip to main content

cli_denoiser/filters/
progress.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use super::{Filter, FilterResult};
5
6// Matches common progress bar patterns:
7// [=====>    ] 50%
8// ████████░░░░ 75%
9// 50% |=====     |
10// (3/10) Installing...
11// Downloading: 45.2 MB / 100.0 MB
12static PROGRESS_PERCENT: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(r"(?:^|\s)\d{1,3}(?:\.\d+)?%(?:\s|$|\|)").expect("progress percent regex valid")
14});
15
16static PROGRESS_BAR_CHARS: LazyLock<Regex> =
17    LazyLock::new(|| Regex::new(r"[=\-#>█▓▒░╸╺┃│\|]{5,}").expect("progress bar chars regex valid"));
18
19// Only Unicode spinners -- ASCII |/-\ are too common in code output
20// and would cause false positives on compiler errors, paths, etc.
21static SPINNER_CHARS: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷◐◓◑◒]\s").expect("spinner regex valid"));
23
24/// Detects and collapses progress bars, spinners, and download indicators.
25/// These are purely visual feedback -- an LLM needs only the final state.
26pub struct ProgressFilter;
27
28impl Filter for ProgressFilter {
29    fn name(&self) -> &'static str {
30        "progress"
31    }
32
33    fn filter_line(&self, line: &str) -> FilterResult {
34        if is_progress_line(line) {
35            FilterResult::Drop
36        } else {
37            FilterResult::Keep
38        }
39    }
40
41    /// Collapse consecutive progress lines into a single summary.
42    /// e.g., 50 lines of "Downloading... X%" become "[progress: Downloading]"
43    fn filter_block(&self, lines: &[String]) -> Vec<String> {
44        let mut result = Vec::with_capacity(lines.len());
45        let mut in_progress_run = false;
46        let mut progress_context: Option<String> = None;
47
48        for line in lines {
49            if is_progress_line(line) {
50                if !in_progress_run {
51                    in_progress_run = true;
52                    progress_context = extract_progress_context(line);
53                }
54            } else {
55                if in_progress_run {
56                    // Emit a single summary line for the collapsed progress block
57                    let ctx = progress_context.take().unwrap_or_default();
58                    if ctx.is_empty() {
59                        result.push("[progress collapsed]".to_string());
60                    } else {
61                        result.push(format!("[progress: {ctx}]"));
62                    }
63                    in_progress_run = false;
64                }
65                result.push(line.clone());
66            }
67        }
68
69        // Handle trailing progress run
70        if in_progress_run {
71            let ctx = progress_context.take().unwrap_or_default();
72            if ctx.is_empty() {
73                result.push("[progress collapsed]".to_string());
74            } else {
75                result.push(format!("[progress: {ctx}]"));
76            }
77        }
78
79        result
80    }
81}
82
83fn is_progress_line(line: &str) -> bool {
84    let trimmed = line.trim();
85    if trimmed.is_empty() {
86        return false;
87    }
88
89    // Spinner at start of line
90    if SPINNER_CHARS.is_match(trimmed) {
91        return true;
92    }
93
94    // Has both percentage and bar characters (high confidence)
95    let has_percent = PROGRESS_PERCENT.is_match(trimmed);
96    let has_bar = PROGRESS_BAR_CHARS.is_match(trimmed);
97    if has_percent && has_bar {
98        return true;
99    }
100
101    // Pure progress bar line (just bar characters and whitespace)
102    if has_bar && trimmed.len() < 120 {
103        let non_bar = trimmed
104            .chars()
105            .filter(|c| {
106                !matches!(
107                    c,
108                    '=' | '-'
109                        | '#'
110                        | '>'
111                        | '█'
112                        | '▓'
113                        | '▒'
114                        | '░'
115                        | '╸'
116                        | '╺'
117                        | '┃'
118                        | '│'
119                        | '|'
120                        | ' '
121                        | '['
122                        | ']'
123                )
124            })
125            .count();
126        // If <30% of chars are non-bar, it's a progress bar
127        if non_bar < trimmed.len() / 3 {
128            return true;
129        }
130    }
131
132    false
133}
134
135fn extract_progress_context(line: &str) -> Option<String> {
136    let trimmed = line.trim();
137    // Try to extract the action word before the progress indicator
138    // e.g., "Downloading packages... 50%" -> "Downloading packages"
139    let without_progress = PROGRESS_PERCENT.replace(trimmed, "").trim().to_string();
140    let without_bar = PROGRESS_BAR_CHARS
141        .replace_all(&without_progress, "")
142        .trim()
143        .to_string();
144    let cleaned = without_bar
145        .trim_matches(|c: char| c == '[' || c == ']' || c == '|' || c == ' ')
146        .trim_end_matches("...")
147        .trim()
148        .to_string();
149
150    if cleaned.is_empty() {
151        None
152    } else {
153        Some(cleaned)
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn detects_percent_with_bar() {
163        assert!(is_progress_line("[=====>    ] 50%"));
164        assert!(is_progress_line("████████░░░░ 75% "));
165    }
166
167    #[test]
168    fn detects_spinner() {
169        assert!(is_progress_line("⠋ Installing dependencies"));
170        assert!(is_progress_line("⣾ Building..."));
171        // ASCII spinners (|/-\) are NOT detected to avoid false positives
172        assert!(!is_progress_line("/ Building..."));
173        assert!(!is_progress_line("| something"));
174    }
175
176    #[test]
177    fn ignores_normal_text() {
178        assert!(!is_progress_line("error: something failed"));
179        assert!(!is_progress_line("warning: unused variable"));
180        assert!(!is_progress_line("src/main.rs:5:10"));
181    }
182
183    #[test]
184    fn ignores_code_with_pipes() {
185        // Code that happens to contain | chars should NOT be detected
186        assert!(!is_progress_line("match x { 1 => a, 2 => b }"));
187    }
188
189    #[test]
190    fn block_filter_collapses_progress_run() {
191        let filter = ProgressFilter;
192        let lines = vec![
193            "Starting download".to_string(),
194            "[=====>     ] 40%".to_string(),
195            "[========>  ] 80%".to_string(),
196            "[==========] 100%".to_string(),
197            "Download complete".to_string(),
198        ];
199        let result = filter.filter_block(&lines);
200        assert_eq!(result.len(), 3);
201        assert_eq!(result[0], "Starting download");
202        assert!(result[1].starts_with("[progress"));
203        assert_eq!(result[2], "Download complete");
204    }
205}