cli_denoiser/filters/
progress.rs1use regex::Regex;
2use std::sync::LazyLock;
3
4use super::{Filter, FilterResult};
5
6static 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
19static SPINNER_CHARS: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷◐◓◑◒]\s").expect("spinner regex valid"));
23
24pub 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 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 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 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 if SPINNER_CHARS.is_match(trimmed) {
91 return true;
92 }
93
94 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 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 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 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 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 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}