1use std::collections::hash_map::DefaultHasher;
22use std::collections::{HashSet, VecDeque};
23use std::hash::{Hash, Hasher};
24
25pub const DEFAULT_BOUND: usize = 16 * 1024;
27pub const MAX_BOUND: usize = 48 * 1024;
29
30const HELD: usize = 60 * 1024;
33const LINE_CAP: usize = 1024;
34const RING_LINES: usize = 40;
35const SUMMARY_LINES: usize = 12;
36
37#[derive(Clone, Copy, PartialEq, Eq)]
38enum Mode {
39 Idle,
40 Block,
42 Section,
44}
45
46pub struct Accumulator {
47 bound: usize,
48 partial: Vec<u8>,
49 partial_cut: bool,
50 ring: VecDeque<String>,
51 ring_bytes: usize,
52 mode: Mode,
53 block: Vec<String>,
54 block_bytes: usize,
55 block_dropped: usize,
56 seen: HashSet<u64>,
57 excerpt: Vec<String>,
58 excerpt_bytes: usize,
59 dropped: usize,
60 lines: usize,
64 block_first: usize,
65 block_last: usize,
66 ring_printed: VecDeque<bool>,
67 #[cfg(feature = "test-hooks")]
68 peak: usize,
69}
70
71impl Accumulator {
72 pub fn new(bound: usize) -> Self {
73 Accumulator {
74 bound: bound.clamp(1, MAX_BOUND),
75 partial: Vec::new(),
76 partial_cut: false,
77 ring: VecDeque::new(),
78 ring_bytes: 0,
79 mode: Mode::Idle,
80 block: Vec::new(),
81 block_bytes: 0,
82 block_dropped: 0,
83 seen: HashSet::new(),
84 excerpt: Vec::new(),
85 excerpt_bytes: 0,
86 dropped: 0,
87 lines: 0,
88 block_first: 0,
89 block_last: 0,
90 ring_printed: VecDeque::new(),
91 #[cfg(feature = "test-hooks")]
92 peak: 0,
93 }
94 }
95
96 #[cfg(test)]
98 pub fn held(&self) -> usize {
99 self.partial.len() + self.ring_bytes + self.excerpt_bytes + self.block_bytes
100 }
101
102 #[cfg(feature = "test-hooks")]
103 pub fn peak(&self) -> usize {
104 self.peak
105 }
106
107 pub fn feed(&mut self, bytes: &[u8]) {
108 for &b in bytes {
109 if b == b'\n' {
110 self.end_line();
111 } else if self.partial.len() < LINE_CAP {
112 self.partial.push(b);
113 } else {
114 self.partial_cut = true;
115 }
116 }
117 #[cfg(feature = "test-hooks")]
118 {
119 let now = self.partial.len() + self.ring_bytes + self.excerpt_bytes + self.block_bytes;
120 self.peak = self.peak.max(now);
121 }
122 }
123
124 fn end_line(&mut self) {
125 let raw = std::mem::take(&mut self.partial);
126 let cut = std::mem::take(&mut self.partial_cut);
127 let mut text = clean(&String::from_utf8_lossy(&raw));
128 if cut {
129 text.push_str(" …[line cut]");
130 }
131 self.line(text);
132 }
133
134 fn line(&mut self, text: String) {
135 self.lines += 1;
136 self.ring_bytes += text.len() + 1;
137 self.ring.push_back(text.clone());
138 self.ring_printed.push_back(false);
139 let room = HELD.saturating_sub(self.bound);
140 while self.ring.len() > RING_LINES || (self.ring_bytes > room && self.ring.len() > 1) {
141 if let Some(old) = self.ring.pop_front() {
142 self.ring_bytes -= old.len() + 1;
143 self.ring_printed.pop_front();
144 }
145 }
146 let lower = text.to_lowercase();
150 let trim = lower.trim();
151
152 match self.mode {
153 Mode::Section => {
154 if trim.starts_with("test result:") {
155 self.flush();
156 self.mode = Mode::Idle;
157 } else {
158 self.push_block(text);
159 }
160 }
161 Mode::Block => {
162 if trim.is_empty() {
163 self.flush();
164 self.mode = Mode::Idle;
165 } else if trim == "failures:" {
166 self.flush();
167 self.mode = Mode::Section;
168 self.push_block(text);
169 } else if is_header(&text, trim) {
170 self.flush();
171 self.mode = Mode::Idle;
172 self.maybe_start(text, trim);
173 } else {
174 self.push_block(text);
175 }
176 }
177 Mode::Idle => {
178 if trim == "failures:" {
179 self.mode = Mode::Section;
180 self.push_block(text);
181 } else {
182 self.maybe_start(text, trim);
183 }
184 }
185 }
186 }
187
188 fn maybe_start(&mut self, text: String, trim: &str) {
189 if trim.starts_with("test result:") || trim.starts_with("---- ") {
190 return;
191 }
192 if matches(&text, trim) {
193 self.mode = Mode::Block;
194 self.push_block(text);
195 }
196 }
197
198 fn push_block(&mut self, text: String) {
199 let room = self
200 .bound
201 .saturating_sub(self.excerpt_bytes + self.block_bytes);
202 if self.block.is_empty() && self.block_dropped == 0 {
203 self.block_first = self.lines;
204 }
205 self.block_last = self.lines;
206 if text.len() < room {
207 self.block_bytes += text.len() + 1;
208 self.block.push(text);
209 } else {
210 self.block_dropped += 1;
211 }
212 }
213
214 fn flush(&mut self) {
215 let lines = std::mem::take(&mut self.block);
216 let dropped = std::mem::take(&mut self.block_dropped);
217 self.block_bytes = 0;
218 if lines.is_empty() && dropped == 0 {
219 return;
220 }
221 if self.excerpt_bytes >= self.bound {
222 self.dropped += lines.len() + dropped;
224 return;
225 }
226 let mut h = DefaultHasher::new();
227 lines.hash(&mut h);
228 dropped.hash(&mut h);
229 let first_in_ring = self.lines + 1 - self.ring.len();
232 for (i, printed) in self.ring_printed.iter_mut().enumerate() {
233 let n = first_in_ring + i;
234 if (self.block_first..=self.block_last).contains(&n) {
235 *printed = true;
236 }
237 }
238 if !self.seen.insert(h.finish()) {
239 return;
240 }
241 self.dropped += dropped;
242 for l in lines {
243 if self.excerpt_bytes + l.len() < self.bound {
244 self.excerpt_bytes += l.len() + 1;
245 self.excerpt.push(l);
246 } else {
247 self.dropped += 1;
248 }
249 }
250 }
251
252 pub fn finish(&mut self, log: &str) -> String {
254 if !self.partial.is_empty() || self.partial_cut {
255 self.end_line();
256 }
257 self.flush();
258 self.mode = Mode::Idle;
259
260 let mut out = String::new();
261 if self.excerpt.is_empty() && self.dropped == 0 {
262 for l in tail(&self.ring, RING_LINES) {
263 out.push_str(&l);
264 out.push('\n');
265 }
266 } else {
267 for l in &self.excerpt {
268 out.push_str(l);
269 out.push('\n');
270 }
271 if self.dropped > 0 {
272 out.push_str(&format!("… {} more lines, full log: {log}\n", self.dropped));
273 }
274 let start = self.ring.len().saturating_sub(SUMMARY_LINES);
276 let unprinted: VecDeque<String> = self
277 .ring
278 .iter()
279 .zip(&self.ring_printed)
280 .skip(start)
281 .filter(|(_, printed)| !**printed)
282 .map(|(l, _)| l.clone())
283 .collect();
284 for l in tail(&unprinted, SUMMARY_LINES) {
285 out.push_str(&l);
286 out.push('\n');
287 }
288 }
289 if out.is_empty() {
290 out.push_str("(no output)\n");
291 }
292 out
293 }
294}
295
296fn tail(ring: &VecDeque<String>, n: usize) -> Vec<String> {
298 let skip = ring.len().saturating_sub(n);
299 let mut v: Vec<String> = ring.iter().skip(skip).cloned().collect();
300 while v.last().is_some_and(|l| l.trim().is_empty()) {
301 v.pop();
302 }
303 let lead = v.iter().take_while(|l| l.trim().is_empty()).count();
304 v.drain(..lead);
305 v
306}
307
308pub fn clean(line: &str) -> String {
311 let mut out = String::with_capacity(line.len());
312 let mut it = line.chars().peekable();
313 while let Some(c) = it.next() {
314 if c != '\u{1b}' {
315 out.push(c);
316 continue;
317 }
318 match it.next() {
319 Some('[') => {
320 for n in it.by_ref() {
321 if ('\u{40}'..='\u{7e}').contains(&n) {
322 break;
323 }
324 }
325 }
326 Some(']') => {
327 while let Some(n) = it.next() {
328 if n == '\u{7}' {
329 break;
330 }
331 if n == '\u{1b}' {
332 it.next();
333 break;
334 }
335 }
336 }
337 _ => {}
338 }
339 }
340 let last = out
341 .split('\r')
342 .rfind(|s| !s.is_empty())
343 .unwrap_or("")
344 .to_string();
345 last.trim_end().to_string()
346}
347
348fn is_header(text: &str, trim: &str) -> bool {
349 trim.starts_with("---- ")
350 || trim == "failures:"
351 || trim.starts_with("test result:")
352 || trim.starts_with("=====")
353 || text.trim_start().starts_with("FAIL ")
354}
355
356fn is_noise(trim: &str) -> bool {
357 [
358 "compiling ",
359 "downloaded ",
360 "downloading ",
361 "finished ",
362 "fresh ",
363 "updating ",
364 "locking ",
365 "blocking ",
366 "running ",
367 ]
368 .iter()
369 .any(|p| trim.starts_with(p))
370}
371
372fn is_word(c: char) -> bool {
373 c.is_alphanumeric() || c == '_'
374}
375
376fn matches(text: &str, trim: &str) -> bool {
380 if is_noise(trim) {
381 return false;
382 }
383 if trim.starts_with('✓') || trim.starts_with('✔') {
384 return false;
385 }
386 if trim.starts_with("test ") && trim.ends_with("... ok") {
387 return false;
388 }
389 if text.contains('✗') || text.contains('×') {
390 return true;
391 }
392 let mut prev = String::new();
393 for tok in text.split(|c: char| !is_word(c)).filter(|t| !t.is_empty()) {
394 let lower = tok.to_lowercase();
395 let keyword = matches!(
396 lower.as_str(),
397 "error"
398 | "errors"
399 | "failed"
400 | "failure"
401 | "failures"
402 | "fail"
403 | "panicked"
404 | "assert"
405 | "assertion"
406 | "assertionerror"
407 | "traceback"
408 | "exception"
409 ) || (tok.len() > 5
410 && (tok.ends_with("Error") || tok.ends_with("Exception")));
411 if keyword && prev != "0" && prev != "no" {
412 return true;
413 }
414 prev = lower;
415 }
416 false
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 fn run(input: &str) -> String {
424 let mut a = Accumulator::new(DEFAULT_BOUND);
425 a.feed(input.as_bytes());
426 a.finish("LOG")
427 }
428
429 #[test]
430 fn a_block_near_the_end_is_not_printed_twice() {
431 let out = run("building\nthread 'x' panicked at src/a.rs:3:5:\n assertion left == right\n\ntest result: FAILED. 1 passed; 1 failed\n");
432 assert_eq!(out.matches("panicked at").count(), 1, "{out}");
433 assert!(out.contains("test result: FAILED"), "{out}");
434 }
435
436 #[test]
437 fn success_lines_do_not_match() {
438 for l in [
439 "test result: ok. 3 passed; 0 failed; 0 ignored",
440 "no errors found",
441 "0 errors, 0 warnings",
442 "test parse::handles_error_variants ... ok",
443 " Compiling error-chain v0.12.4",
444 " ✓ reports the error",
445 ] {
446 let lower = l.to_lowercase();
447 assert!(!matches(l, lower.trim()), "{l}");
448 }
449 }
450
451 #[test]
452 fn failure_lines_match() {
453 for l in [
454 "thread 'x' panicked at src/a.rs:1:2:",
455 "test a::b ... FAILED",
456 "E AssertionError: no",
457 "tests/t.py:14: AssertionError",
458 "error[E0308]: mismatched types",
459 " 3:7 error 'unused' is assigned",
460 "× formats currency",
461 "FAIL src/format.test.ts",
462 "TypeError: x is not a function",
463 ] {
464 let lower = l.to_lowercase();
465 assert!(matches(l, lower.trim()), "{l}");
466 }
467 }
468
469 #[test]
470 fn nothing_matched_gives_the_last_lines() {
471 let input: String = (0..100).map(|i| format!("line {i}\n")).collect();
472 let out = run(&input);
473 assert_eq!(out.lines().count(), 40);
474 assert!(out.ends_with("line 99\n"));
475 }
476
477 #[test]
478 fn empty_output_says_so() {
479 assert_eq!(run(""), "(no output)\n");
480 }
481
482 #[test]
483 fn a_long_line_is_cut_with_a_marker() {
484 let out = run(&format!("error {}\n", "x".repeat(5000)));
485 assert!(out.contains("…[line cut]"));
486 assert!(out.len() < 3000);
487 }
488
489 #[test]
490 fn ansi_and_carriage_returns_are_stripped() {
491 assert_eq!(clean("\u{1b}[31merror\u{1b}[0m: x"), "error: x");
492 assert_eq!(clean("10%\r50%\r100% done"), "100% done");
493 }
494
495 #[test]
496 fn the_accumulator_stays_bounded() {
497 for bound in [1024, DEFAULT_BOUND, MAX_BOUND] {
498 let mut a = Accumulator::new(bound);
499 let chunk: String = (0..2000)
500 .map(|i| {
501 if i % 3 == 0 {
502 format!("error: failure number {i} {}\n", "y".repeat(900))
503 } else {
504 format!("plain line {i} {}\n", "z".repeat(1500))
505 }
506 })
507 .collect();
508 for _ in 0..4 {
510 a.feed(chunk.as_bytes());
511 assert!(a.held() <= 64 * 1024, "held {} at bound {bound}", a.held());
512 }
513 let out = a.finish("LOG");
514 assert!(out.contains("more lines, full log: LOG"));
515 }
516 }
517}