1use std::cmp::Reverse;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LogSection {
28 pub name: String,
30 pub start: usize,
32 pub end: usize,
34 pub step: Option<usize>,
36}
37
38impl LogSection {
39 pub fn len(&self) -> usize {
41 self.end.saturating_sub(self.start)
42 }
43
44 pub fn is_empty(&self) -> bool {
45 self.len() == 0
46 }
47
48 pub fn contains(&self, line: usize) -> bool {
49 line >= self.start && line < self.end
50 }
51}
52
53enum Marker<'a> {
55 Open(&'a str),
56 Close,
59}
60
61pub fn strip_timestamp(line: &str) -> &str {
64 let line = line.strip_prefix('\u{feff}').unwrap_or(line);
65 let b = line.as_bytes();
66 let digits = |r: std::ops::Range<usize>| r.clone().all(|i| b.get(i).is_some_and(u8::is_ascii_digit));
67 let shape = b.len() >= 19
68 && digits(0..4)
69 && b[4] == b'-'
70 && digits(5..7)
71 && b[7] == b'-'
72 && digits(8..10)
73 && b[10] == b'T'
74 && digits(11..13)
75 && b[13] == b':'
76 && digits(14..16)
77 && b[16] == b':'
78 && digits(17..19);
79 if !shape {
80 return line;
81 }
82 let mut i = 19;
83 if b.get(i) == Some(&b'.') {
84 i += 1;
85 while b.get(i).is_some_and(u8::is_ascii_digit) {
86 i += 1;
87 }
88 }
89 if b.get(i) == Some(&b'Z') {
90 i += 1;
91 }
92 match b.get(i) {
93 Some(b' ') => &line[i + 1..],
94 None => "",
95 _ => line,
96 }
97}
98
99pub fn strip_ansi(s: &str) -> String {
101 let mut out = String::with_capacity(s.len());
102 let mut chars = s.chars().peekable();
103 while let Some(c) = chars.next() {
104 match c {
105 '\x1b' => {
106 if chars.peek() == Some(&'[') {
107 chars.next();
108 for c in chars.by_ref() {
110 if ('@'..='~').contains(&c) {
111 break;
112 }
113 }
114 } else {
115 chars.next();
116 }
117 }
118 '\r' => {}
119 c => out.push(c),
120 }
121 }
122 out
123}
124
125fn marker(line: &str) -> Option<Marker<'_>> {
126 let line = strip_timestamp(line);
127 if let Some(name) = line.strip_prefix("##[group]") {
128 return Some(Marker::Open(name.trim()));
129 }
130 if let Some(name) = line.strip_prefix("##[section]Starting: ") {
131 return Some(Marker::Open(name.trim()));
132 }
133 if line.starts_with("##[section]Finishing: ") {
134 return Some(Marker::Close);
135 }
136 None
137}
138
139enum GitLab {
141 Start { id: String, header: String },
142 End { id: String },
143}
144
145fn gitlab_markers(line: &str) -> Vec<GitLab> {
148 let mut out = Vec::new();
149 let mut rest = line;
150 loop {
151 let start = rest.find("section_start:");
152 let end = rest.find("section_end:");
153 let (at, is_start) = match (start, end) {
154 (Some(s), Some(e)) if s < e => (s, true),
155 (Some(_), Some(e)) => (e, false),
156 (Some(s), None) => (s, true),
157 (None, Some(e)) => (e, false),
158 (None, None) => break,
159 };
160 let body = &rest[at + if is_start { "section_start:".len() } else { "section_end:".len() }..];
161 let spec_end = body.find(['\r', '\x1b', '\n']).unwrap_or(body.len());
163 let spec = &body[..spec_end];
164 let id = spec.split_once(':').map(|(_, id)| id).unwrap_or(spec);
165 let id = id.split('[').next().unwrap_or(id).trim().to_string();
166 let after = &body[spec_end..];
167 let next = [after.find("section_start:"), after.find("section_end:")].into_iter().flatten().min();
169 let header_raw = &after[..next.unwrap_or(after.len())];
170 if is_start {
171 out.push(GitLab::Start { id, header: strip_ansi(header_raw).trim().to_string() });
172 } else {
173 out.push(GitLab::End { id });
174 }
175 match next {
176 Some(n) => rest = &after[n..],
177 None => break,
178 }
179 }
180 out
181}
182
183pub fn parse_sections(lines: &[String]) -> Vec<LogSection> {
187 parse_with(lines, true)
188}
189
190pub fn parse_step_sections(lines: &[String], steps: &[String]) -> Vec<LogSection> {
205 let azure = lines.iter().any(|l| strip_timestamp(l).starts_with("##[section]Starting: "));
206 let mut all = parse_with(lines, !azure);
207 if steps.is_empty() {
208 return all;
209 }
210 if azure {
211 let mut next = 0;
212 for sec in &mut all {
213 if let Some(j) = (next..steps.len()).find(|&j| name_matches(&sec.name, &steps[j])) {
214 sec.step = Some(j);
215 next = j + 1;
216 }
217 }
218 return all;
219 }
220 let score = |group: &str, step: &str| -> u8 {
223 if name_matches_strictly(group, step) {
224 3
225 } else if name_matches(group, step) {
226 if group.trim_start().starts_with("Run ") { 2 } else { 1 }
229 } else {
230 0
231 }
232 };
233 let mut owner: Vec<Option<usize>> = vec![None; all.len()];
234 let mut from = 0;
235 for (j, step) in steps.iter().enumerate() {
236 let horizon = (from..all.len())
237 .find(|&g| steps[j + 1..].iter().any(|later| name_matches_strictly(&all[g].name, later)))
238 .unwrap_or(all.len());
239 let best = (from..horizon).filter(|&g| score(&all[g].name, step) > 0).max_by_key(|&g| (score(&all[g].name, step), Reverse(g)));
240 if let Some(g) = best {
241 owner[g] = Some(j);
242 from = g + 1;
243 }
244 }
245 let mut kept: Vec<LogSection> = Vec::new();
246 for (sec, step) in all.iter().zip(&owner) {
247 match (step, kept.last_mut()) {
248 (Some(j), _) => kept.push(LogSection { step: Some(*j), ..sec.clone() }),
249 (None, Some(last)) if last.end == sec.start => last.end = sec.end,
251 (None, _) => {}
252 }
253 }
254 if kept.is_empty() {
255 return all;
256 }
257 kept
258}
259
260fn name_matches_strictly(section: &str, step: &str) -> bool {
263 let unrun = |s: &str| norm(s.trim_start().strip_prefix("Run ").unwrap_or(s));
264 norm(section) == norm(step) || unrun(section) == unrun(step)
265}
266
267fn name_matches(section: &str, step: &str) -> bool {
269 find_section(std::slice::from_ref(&LogSection { name: section.to_string(), start: 0, end: 0, step: None }), step).is_some()
270}
271
272fn parse_with(lines: &[String], groups: bool) -> Vec<LogSection> {
273 let mut out: Vec<LogSection> = Vec::new();
274 let mut open: Option<(String, usize)> = None;
275 let mut gitlab_stack: Vec<String> = Vec::new();
277
278 fn begin(out: &mut Vec<LogSection>, open: &mut Option<(String, usize)>, name: &str, at: usize) {
279 if let Some((prev, start)) = open.take() {
280 out.push(LogSection { name: prev, start, end: at.max(start + 1), step: None });
281 }
282 if let Some(last) = out.last_mut() {
285 if last.end > at {
286 if last.start < at {
287 last.end = at;
288 } else {
289 out.pop();
290 }
291 }
292 }
293 let name = if name.is_empty() { "(unnamed step)" } else { name };
294 *open = Some((name.to_string(), at));
295 }
296
297 for (i, raw) in lines.iter().enumerate() {
298 if raw.contains("section_start:") || raw.contains("section_end:") {
299 for m in gitlab_markers(raw) {
300 match m {
301 GitLab::Start { id, header } => {
302 if gitlab_stack.is_empty() {
303 let name = if header.is_empty() { id.clone() } else { header };
304 begin(&mut out, &mut open, &name, i);
305 }
306 gitlab_stack.push(id);
307 }
308 GitLab::End { id } => {
309 if let Some(pos) = gitlab_stack.iter().rposition(|s| *s == id) {
310 gitlab_stack.truncate(pos);
311 if gitlab_stack.is_empty() {
312 if let Some((name, start)) = open.take() {
313 out.push(LogSection { name, start, end: i + 1, step: None });
314 }
315 }
316 }
317 }
318 }
319 }
320 continue;
321 }
322 match marker(raw) {
323 Some(Marker::Open(name)) if groups || !strip_timestamp(raw).starts_with("##[group]") => {
324 begin(&mut out, &mut open, name, i)
325 }
326 Some(Marker::Open(_)) => {}
327 Some(Marker::Close) => {
328 if let Some((name, start)) = open.take() {
329 out.push(LogSection { name, start, end: i + 1, step: None });
330 }
331 }
332 None => {}
333 }
334 }
335 if let Some((name, start)) = open {
336 out.push(LogSection { name, start, end: lines.len().max(start + 1), step: None });
337 }
338 out
339}
340
341pub fn is_hidden_marker(line: &str) -> bool {
345 let t = strip_timestamp(line);
346 if t.starts_with("##[endgroup]") || t.starts_with("##[section]Finishing: ") {
347 return true;
348 }
349 if t.contains("section_end:") && !t.contains("section_start:") {
350 let mut rest = strip_ansi(t);
352 while let Some(at) = rest.find("section_end:") {
353 let tail = &rest[at + "section_end:".len()..];
354 let len = tail.find(char::is_whitespace).unwrap_or(tail.len());
355 rest = format!("{}{}", &rest[..at], &tail[len..]);
356 }
357 return rest.trim().is_empty();
358 }
359 false
360}
361
362fn norm(s: &str) -> String {
363 s.trim().to_lowercase()
364}
365
366pub fn find_section(sections: &[LogSection], step: &str) -> Option<usize> {
371 if let Some(i) = sections.iter().position(|s| s.name == step) {
372 return Some(i);
373 }
374 let want = norm(step);
375 if want.is_empty() {
376 return None;
377 }
378 if let Some(i) = sections.iter().position(|s| norm(&s.name) == want) {
379 return Some(i);
380 }
381 let unrun = |s: &str| norm(s.trim_start().strip_prefix("Run ").unwrap_or(s));
382 if let Some(i) = sections.iter().position(|s| unrun(&s.name) == unrun(step)) {
383 return Some(i);
384 }
385 if want.chars().count() >= 3 {
386 if let Some(i) = sections.iter().position(|s| {
387 let have = norm(&s.name);
388 have.contains(&want) || (have.chars().count() >= 3 && want.contains(&have))
389 }) {
390 return Some(i);
391 }
392 }
393 None
394}
395
396pub fn step_section(sections: &[LogSection], steps: &[String], index: usize) -> Option<usize> {
400 if let Some(i) = sections.iter().position(|s| s.step == Some(index)) {
401 return Some(i);
402 }
403 let step = steps.get(index)?;
404 find_section(sections, step).or_else(|| (sections.len() == steps.len()).then_some(index))
405}
406
407pub fn is_error_line(line: &str) -> bool {
414 if line.contains("##[error]") || line.contains("[FAIL]") || line.contains("npm ERR!") {
415 return true;
416 }
417 if has_token(line, "FAILED", |_| true) || has_token(line, "FAIL", |_| true) || has_token(line, "ERROR", |_| true) {
418 return true;
419 }
420 if has_token(line, "Failed", |next| matches!(next, Some('!' | ':'))) {
422 return true;
423 }
424 if has_token(line, "error", |next| matches!(next, Some(':' | '['))) || has_token(line, "Error", |next| next == Some(':')) {
425 return true;
426 }
427 nonzero_exit_code(line)
428}
429
430fn has_token(line: &str, word: &str, next_ok: impl Fn(Option<char>) -> bool) -> bool {
433 let is_word = |c: char| c.is_alphanumeric() || c == '_';
434 line.match_indices(word).any(|(i, _)| {
435 let before = line[..i].chars().next_back();
436 let rest = &line[i + word.len()..];
437 let after = rest.chars().next();
438 !before.is_some_and(is_word)
439 && !after.is_some_and(is_word)
440 && next_ok(after)
441 && !ends_with_zero(&line[..i])
442 && !starts_with_zero(rest)
443 })
444}
445
446fn ends_with_zero(text: &str) -> bool {
448 let t = text.trim_end();
449 t.ends_with('0') && !t[..t.len() - 1].ends_with(|c: char| c.is_ascii_digit())
450}
451
452fn starts_with_zero(text: &str) -> bool {
454 let t = text.trim_start_matches([':', '=', ' ']);
455 t.starts_with('0') && !t[1..].starts_with(|c: char| c.is_ascii_digit())
456}
457
458fn nonzero_exit_code(line: &str) -> bool {
460 let lower = line.to_ascii_lowercase();
461 ["exit code", "exited with code"].iter().any(|pat| {
462 lower.match_indices(pat).any(|(i, _)| {
463 let rest = lower[i + pat.len()..].trim_start_matches([':', ' ']);
464 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
465 !digits.is_empty() && !digits.trim_start_matches('0').is_empty()
466 })
467 })
468}
469
470pub fn first_error_line(lines: &[String]) -> Option<usize> {
472 lines.iter().position(|l| is_error_line(l))
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Default)]
479pub struct FailureSummary {
480 pub line: usize,
482 pub test: Option<String>,
483 pub location: Option<String>,
485 pub message: Option<String>,
486}
487
488fn location_of(s: &str) -> Option<String> {
490 let s = s.trim().trim_end_matches(':');
491 let mut parts = s.rsplitn(3, ':');
492 let last = parts.next()?;
493 let mid = parts.next()?;
494 let is_num = |p: &str| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit());
495 match parts.next() {
496 Some(path) if is_num(last) && is_num(mid) && !path.is_empty() => Some(format!("{path}:{mid}")),
498 Some(path) if is_num(last) && !path.is_empty() => Some(format!("{path}:{mid}:{last}")),
500 None if is_num(last) && !mid.is_empty() => Some(format!("{mid}:{last}")),
501 _ => None,
502 }
503}
504
505fn short_test(name: &str) -> String {
507 name.rsplit("::").next().unwrap_or(name).trim().to_string()
508}
509
510pub fn short_location(location: &str) -> String {
513 location.rsplit(['/', '\\']).next().unwrap_or(location).to_string()
514}
515
516pub fn failure_summary(lines: &[String]) -> Option<FailureSummary> {
521 let clean: Vec<String> = lines.iter().map(|l| strip_ansi(strip_timestamp(l))).collect();
522 let next_text = |from: usize| clean.iter().skip(from).take(3).map(|l| l.trim()).find(|l| !l.is_empty());
523 let arrow = |from: usize| {
525 clean.iter().skip(from + 1).take(6).find_map(|l| l.trim_start().strip_prefix("--> ").and_then(location_of))
526 };
527
528 let mut failed_test: Option<(usize, String)> = None;
529 let mut panic: Option<FailureSummary> = None;
530 let mut diagnostic: Option<FailureSummary> = None;
531 let mut generic: Option<FailureSummary> = None;
532 let mut ci: Option<FailureSummary> = None;
533
534 for (i, line) in clean.iter().enumerate() {
535 let t = line.trim();
536 if failed_test.is_none() {
537 if let Some(rest) = t.strip_prefix("test ") {
538 if let Some(name) = rest.strip_suffix(" ... FAILED") {
539 failed_test = Some((i, short_test(name)));
540 }
541 }
542 }
543 if panic.is_none() {
544 if let Some(at) = t.find("panicked at ") {
545 let thread = t[..at]
546 .trim()
547 .strip_prefix("thread '")
548 .and_then(|s| s.strip_suffix('\''))
549 .map(short_test)
550 .filter(|n| n != "main");
551 let rest = t[at + "panicked at ".len()..].trim();
552 let (location, message) = if rest.is_empty() {
553 let loc = clean.get(i + 1).and_then(|l| location_of(l));
555 let msg = if loc.is_some() { next_text(i + 2) } else { next_text(i + 1) };
556 (loc, msg.map(str::to_string))
557 } else if let Some(stripped) = rest.strip_prefix('\'') {
558 match stripped.rsplit_once("', ") {
560 Some((msg, loc)) => (location_of(loc), Some(msg.to_string())),
561 None => (None, Some(stripped.trim_end_matches('\'').to_string())),
562 }
563 } else {
564 (location_of(rest), next_text(i + 1).map(str::to_string))
565 };
566 panic = Some(FailureSummary { line: i, test: thread, location, message });
567 }
568 }
569 if diagnostic.is_none() && t.starts_with("error[") {
570 if let Some((_, msg)) = t.split_once("]: ") {
571 diagnostic = Some(FailureSummary {
572 line: i,
573 test: None,
574 location: arrow(i),
575 message: Some(msg.trim().to_string()),
576 });
577 }
578 }
579 if generic.is_none() {
580 if let Some(msg) = t.strip_prefix("error: ") {
581 generic = Some(FailureSummary { line: i, test: None, location: arrow(i), message: Some(msg.trim().to_string()) });
582 }
583 }
584 if ci.is_none() {
585 if let Some(at) = t.find("##[error]") {
586 let msg = t[at + "##[error]".len()..].trim();
587 ci = Some(FailureSummary { line: i, test: None, location: None, message: Some(msg.to_string()) });
588 }
589 }
590 }
591
592 let test_name = failed_test.as_ref().map(|(_, n)| n.clone());
593 if let Some(mut p) = panic {
594 if p.test.is_none() {
595 p.test = test_name;
596 }
597 return Some(p);
598 }
599 if let Some(d) = diagnostic {
600 return Some(d);
601 }
602 if let Some((line, name)) = failed_test {
603 return Some(FailureSummary { line, test: Some(name), location: None, message: None });
604 }
605 generic.or(ci)
606}
607
608pub fn median(values: &[i64]) -> Option<i64> {
613 if values.is_empty() {
614 return None;
615 }
616 let mut v = values.to_vec();
617 v.sort_unstable();
618 let mid = v.len() / 2;
619 Some(if v.len().is_multiple_of(2) { (v[mid - 1] + v[mid]).div_euclid(2) } else { v[mid] })
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 fn lines(text: &str) -> Vec<String> {
627 text.lines().map(str::to_owned).collect()
628 }
629
630 fn names(sections: &[LogSection]) -> Vec<&str> {
631 sections.iter().map(|s| s.name.as_str()).collect()
632 }
633
634 #[test]
635 fn timestamps_and_the_bom_are_stripped_and_nothing_else_is() {
636 assert_eq!(strip_timestamp("2024-05-01T10:00:00.1234567Z ##[group]Build"), "##[group]Build");
637 assert_eq!(strip_timestamp("\u{feff}2024-05-01T10:00:00Z hello"), "hello");
638 assert_eq!(strip_timestamp("2024-05-01T10:00:00.5Z"), "");
639 assert_eq!(strip_timestamp("2024-05-01 not a stamp"), "2024-05-01 not a stamp");
640 assert_eq!(strip_timestamp("plain"), "plain");
641 assert_eq!(strip_timestamp(""), "");
642 assert_eq!(strip_ansi("\x1b[32;1mok\x1b[0m\r"), "ok");
643 }
644
645 #[test]
646 fn github_groups_run_to_the_next_group_because_output_follows_the_endgroup() {
647 let log = lines(
648 "2024-05-01T10:00:00.0000000Z Current runner version: '2.3'\n\
649 2024-05-01T10:00:01.0000000Z ##[group]Run actions/checkout@v4\n\
650 2024-05-01T10:00:01.1000000Z with: x\n\
651 2024-05-01T10:00:01.2000000Z ##[endgroup]\n\
652 2024-05-01T10:00:02.0000000Z Syncing repository\n\
653 2024-05-01T10:00:03.0000000Z ##[group]Run cargo test\n\
654 2024-05-01T10:00:03.1000000Z ##[endgroup]\n\
655 2024-05-01T10:00:04.0000000Z test a ... FAILED",
656 );
657 let s = parse_sections(&log);
658 assert_eq!(names(&s), vec!["Run actions/checkout@v4", "Run cargo test"]);
659 assert_eq!((s[0].start, s[0].end), (1, 5), "the output after endgroup is the step's");
660 assert_eq!((s[1].start, s[1].end), (5, 8), "the last section runs to the end");
661 assert!(!s[0].contains(0), "the preamble belongs to no section");
662 assert!(is_hidden_marker(&log[3]));
663 assert!(!is_hidden_marker(&log[4]));
664 }
665
666 #[test]
667 fn azure_sections_close_on_finishing() {
668 let log = lines(
669 "##[section]Starting: Initialize job\n\
670 Agent name: x\n\
671 ##[section]Finishing: Initialize job\n\
672 between\n\
673 2024-05-01T10:00:00.1234567Z ##[section]Starting: dotnet test\n\
674 ##[error]boom\n\
675 2024-05-01T10:00:09.1234567Z ##[section]Finishing: dotnet test",
676 );
677 let s = parse_sections(&log);
678 assert_eq!(names(&s), vec!["Initialize job", "dotnet test"]);
679 assert_eq!((s[0].start, s[0].end), (0, 3));
680 assert!(!s.iter().any(|sec| sec.contains(3)), "a line between sections belongs to neither");
681 assert_eq!((s[1].start, s[1].end), (4, 7));
682 assert!(is_hidden_marker(&log[2]));
683 }
684
685 #[test]
686 fn gitlab_sections_use_their_header_and_fold_nested_ones() {
687 let log = lines(
688 "Running with gitlab-runner 16\n\
689 section_start:1700000000:prepare_script\r\x1b[0K\x1b[0;36mPreparing environment\x1b[0m\n\
690 Running on runner-1\n\
691 section_end:1700000001:prepare_script\r\x1b[0K\n\
692 section_start:1700000002:step_script[collapsed=true]\r\x1b[0KExecuting \"step_script\" stage\n\
693 section_start:1700000003:inner\r\x1b[0Knested\n\
694 $ cargo test\n\
695 section_end:1700000004:inner\r\x1b[0K\n\
696 error: boom\n\
697 section_end:1700000005:step_script\r\x1b[0Ksection_start:1700000006:cleanup\r\x1b[0K\n\
698 bye\n\
699 section_end:1700000007:cleanup\r\x1b[0K",
700 );
701 let s = parse_sections(&log);
702 assert_eq!(names(&s), vec!["Preparing environment", "Executing \"step_script\" stage", "cleanup"]);
703 assert_eq!((s[0].start, s[0].end), (1, 4));
704 assert_eq!((s[1].start, s[1].end), (4, 9), "the nested section folds into its parent");
705 assert_eq!((s[2].start, s[2].end), (9, 12), "an end and a start on one line don't overlap");
706 assert!(is_hidden_marker(&log[3]));
707 assert!(!is_hidden_marker(&log[9]), "a line that also opens a section is its header");
708 }
709
710 #[test]
711 fn a_log_without_markers_has_no_sections() {
712 assert!(parse_sections(&lines("one\ntwo")).is_empty());
713 assert!(parse_sections(&[]).is_empty());
714 let s = parse_sections(&lines("##[section]Starting: Build\nstill going"));
716 assert_eq!((s[0].start, s[0].end), (0, 2));
717 }
718
719 #[test]
720 fn steps_find_their_sections_by_name_then_loosely_then_by_position() {
721 let s = parse_sections(&lines("##[group]Set up job\n##[group]Run actions/checkout@v4\n##[group]Run cargo test --workspace\n##[group]Clippy"));
722 assert_eq!(find_section(&s, "Set up job"), Some(0), "exact");
723 assert_eq!(find_section(&s, "run ACTIONS/checkout@v4"), Some(1), "case-insensitive");
724 assert_eq!(find_section(&s, "actions/checkout@v4"), Some(1), "past GitHub's Run prefix");
725 assert_eq!(find_section(&s, "cargo test"), Some(2), "contains");
726 assert_eq!(find_section(&s, "clippy"), Some(3));
727 assert_eq!(find_section(&s, "Deploy"), None);
728 assert_eq!(find_section(&s, ""), None);
729 let steps: Vec<String> = ["a", "b", "Unit suite", "d"].iter().map(|s| s.to_string()).collect();
730 assert_eq!(step_section(&s, &steps, 2), Some(2), "one section per step: by position");
731 assert_eq!(step_section(&s, &steps[..3], 2), None, "counts differ: no guess");
732 }
733
734 fn strings(v: &[&str]) -> Vec<String> {
735 v.iter().map(|s| s.to_string()).collect()
736 }
737
738 #[test]
739 fn with_known_steps_a_group_inside_a_steps_output_stays_part_of_that_step() {
740 let log = lines(
741 "##[group]Run actions/checkout@v4\n\
742 ##[endgroup]\n\
743 ##[group]Run cargo test\n\
744 ##[endgroup]\n\
745 ##[group]Fetching crates\n\
746 inner output\n\
747 ##[endgroup]\n\
748 test a ... ok\n\
749 ##[group]Run cargo clippy\n\
750 ok",
751 );
752 let s = parse_step_sections(&log, &strings(&["Set up job", "actions/checkout@v4", "cargo test", "Clippy"]));
755 assert_eq!(names(&s), vec!["Run actions/checkout@v4", "Run cargo test", "Run cargo clippy"]);
756 assert_eq!((s[1].start, s[1].end, s[1].step), (2, 8, Some(2)), "the nested group is Test's");
757 assert_eq!(s[2].step, Some(3));
758 assert_eq!(step_section(&s, &strings(&["Set up job", "actions/checkout@v4", "cargo test", "Clippy"]), 2), Some(1));
759 assert_eq!(parse_sections(&log).len(), 4, "without steps every group is a section");
761 assert_eq!(parse_step_sections(&log, &strings(&["alpha", "beta"])).len(), 4);
763 }
764
765 #[test]
766 fn a_nested_group_that_loosely_names_a_later_step_does_not_take_its_place() {
767 let log = lines(
768 "##[group]Run cargo build\n\
769 ##[endgroup]\n\
770 ##[group]cargo test --no-run\n\
771 compiled test binaries\n\
772 ##[endgroup]\n\
773 ##[group]Run cargo test\n\
774 test a ... ok",
775 );
776 let s = parse_step_sections(&log, &strings(&["cargo build", "Test"]));
777 assert_eq!(names(&s), vec!["Run cargo build", "Run cargo test"], "the nested group stays Build's");
778 assert_eq!((s[0].start, s[0].end, s[0].step), (0, 5, Some(0)));
779 assert_eq!((s[1].start, s[1].step), (5, Some(1)));
780 let s = parse_step_sections(&log, &strings(&["Set up job", "cargo build", "cargo test"]));
782 assert_eq!(s.iter().map(|x| x.step).collect::<Vec<_>>(), vec![Some(1), Some(2)]);
783 }
784
785 #[test]
786 fn a_log_with_azure_sections_ignores_groups_for_sectioning() {
787 let log = lines(
788 "##[section]Starting: Build\n\
789 ##[group]Restore\n\
790 restored\n\
791 ##[endgroup]\n\
792 ##[section]Finishing: Build\n\
793 ##[section]Starting: Test\n\
794 ##[error]boom\n\
795 ##[section]Finishing: Test",
796 );
797 let s = parse_step_sections(&log, &strings(&["Build", "Test"]));
798 assert_eq!(names(&s), vec!["Build", "Test"]);
799 assert_eq!((s[0].start, s[0].end, s[0].step), (0, 5, Some(0)));
800 assert_eq!(s[1].step, Some(1));
801 }
802
803 #[test]
804 fn a_rust_panic_names_the_test_the_file_and_the_message() {
805 let log = lines(
806 "running 184 tests\n\
807 test cache::tests::rewrite_edits_in_place ... FAILED\n\
808 failures:\n\
809 ---- cache::tests::rewrite_edits_in_place stdout ----\n\
810 2024-05-01T10:00:00Z thread 'cache::tests::rewrite_edits_in_place' panicked at crates/forgetop-core/src/cache.rs:212:9:\n\
811 assertion `left == right` failed\n\
812 ##[error]Process completed with exit code 101.",
813 );
814 let f = failure_summary(&log).expect("summary");
815 assert_eq!(f.test.as_deref(), Some("rewrite_edits_in_place"));
816 assert_eq!(f.location.as_deref(), Some("crates/forgetop-core/src/cache.rs:212"));
817 assert_eq!(f.message.as_deref(), Some("assertion `left == right` failed"));
818 assert_eq!(f.line, 4);
819 assert_eq!(short_location(f.location.as_deref().unwrap()), "cache.rs:212");
820
821 let old = lines("test it_works ... FAILED\nthread 'main' panicked at 'boom', src/lib.rs:3:5");
823 let f = failure_summary(&old).unwrap();
824 assert_eq!((f.test.as_deref(), f.location.as_deref(), f.message.as_deref()), (Some("it_works"), Some("src/lib.rs:3"), Some("boom")));
825 }
826
827 #[test]
828 fn diagnostics_generic_errors_and_ci_errors_are_read_in_that_order() {
829 let rustc = lines(" Compiling x\nerror[E0308]: mismatched types\n --> src/main.rs:4:18\n |\nerror: could not compile `x`");
830 let f = failure_summary(&rustc).unwrap();
831 assert_eq!((f.location.as_deref(), f.message.as_deref(), f.line), (Some("src/main.rs:4"), Some("mismatched types"), 1));
832
833 let generic = lines("error: linking with `cc` failed\n##[error]Process completed with exit code 1.");
834 assert_eq!(failure_summary(&generic).unwrap().message.as_deref(), Some("linking with `cc` failed"));
835
836 let ci = lines("ok\n2024-05-01T10:00:00Z ##[error]Process completed with exit code 2.");
837 let f = failure_summary(&ci).unwrap();
838 assert_eq!((f.message.as_deref(), f.line), (Some("Process completed with exit code 2."), 1));
839
840 let only_test = lines("test a::b ... FAILED");
841 assert_eq!(failure_summary(&only_test).unwrap().test.as_deref(), Some("b"));
842
843 assert_eq!(failure_summary(&lines("all good\n0 errors")), None);
844 assert_eq!(failure_summary(&[]), None);
845 }
846
847 #[test]
848 fn error_lines_are_matched_past_zero_counts() {
849 assert!(is_error_line("##[error]boom"));
850 assert!(is_error_line("Process completed with exit code 2."));
851 assert!(!is_error_line("Process completed with exit code 0."));
852 assert!(!is_error_line("Failed: 0, Passed: 12"));
853 assert_eq!(first_error_line(&lines("ok\nerror: x")), Some(1));
854 }
855
856 #[test]
857 fn median_is_the_middle_value_or_the_mean_of_the_middle_two() {
858 assert_eq!(median(&[]), None);
859 assert_eq!(median(&[7]), Some(7));
860 assert_eq!(median(&[98, 95, 101]), Some(98));
861 assert_eq!(median(&[10, 40, 20, 30]), Some(25));
862 assert_eq!(median(&[1, 2]), Some(1), "rounded down");
863 }
864}