Skip to main content

forgetop_core/
runlog.rs

1//! Reading a pipeline job's log text: per-step sections and the first error.
2//! Shared by both frontends; pure functions over the text `PipelineSource::logs` returns.
3//!
4//! Three section dialects are understood, one per forge that marks its steps in the log:
5//!
6//! * GitHub Actions — `##[group]Name` … `##[endgroup]`. The group only wraps a step's header
7//!   (the command it runs); the step's output follows the `##[endgroup]`. So a GitHub section
8//!   runs from its `##[group]` to the next one, not to its own `##[endgroup]`.
9//! * Azure Pipelines — `##[section]Starting: Name` … `##[section]Finishing: Name`.
10//! * GitLab CI — raw traces mark `section_start:<unix>:<id>[opts]\r\x1b[0K<header>` …
11//!   `section_end:<unix>:<id>\r\x1b[0K`; a nested section folds into its outermost one. (The
12//!   GitLab provider rewrites its top-level sections to `##[group]<header>` before the log gets
13//!   here and drops the inner ones, so this dialect is mostly for raw traces.)
14//!
15//! When the job's step names are known, [`parse_step_sections`] is the better reader: each step
16//! claims the GitHub `##[group]` that best names it, in order, so groups a step opens in its own
17//! output stay part of that step. And in a log with Azure markers, `##[group]` never
18//! starts a section — Azure uses it for folding inside a step.
19//!
20//! Lines may carry a leading ISO-8601 timestamp (`2024-05-01T10:00:00.1234567Z `), which GitHub
21//! and Azure both prepend; every matcher here looks past it.
22
23use std::cmp::Reverse;
24
25/// One step's stretch of a job log, as line indices into the text it was parsed from.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LogSection {
28    /// The step name the marker carries (GitLab: its header text, or its id without one).
29    pub name: String,
30    /// The line holding the opening marker.
31    pub start: usize,
32    /// One past the last line that belongs to the section (a closing marker included).
33    pub end: usize,
34    /// The job step this section was matched to, when [`parse_step_sections`] read it.
35    pub step: Option<usize>,
36}
37
38impl LogSection {
39    /// Lines in the section, its marker line included.
40    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
53/// What a line's first thing says, once its timestamp is out of the way.
54enum Marker<'a> {
55    Open(&'a str),
56    /// An explicit close (Azure `Finishing`); GitHub's `##[endgroup]` is not one — see the
57    /// module docs.
58    Close,
59}
60
61/// `line` without a leading byte-order mark and ISO-8601 timestamp (`2024-05-01T10:00:00Z `,
62/// fractional seconds optional). Anything else comes back unchanged.
63pub 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
99/// `s` without ANSI escape sequences (colours, GitLab's `\x1b[0K` erase) or carriage returns.
100pub 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                    // Parameters and intermediates, then one final byte in `@`..`~`.
109                    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
139/// One GitLab marker on a line, in the order they appear.
140enum GitLab {
141    Start { id: String, header: String },
142    End { id: String },
143}
144
145/// Every GitLab `section_start` / `section_end` marker on a line. A trace can put an end and the
146/// next start on one line, so this returns all of them.
147fn 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        // `<unix>:<id>[opts]` up to the `\r`, `\x1b` or end of line.
162        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        // The next marker, if any, bounds this one's header text.
168        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
183/// Splits a job log into its per-step sections, in order. Lines before the first marker (and,
184/// for Azure, between a `Finishing` and the next `Starting`) belong to no section. A log with no
185/// markers at all yields no sections, and callers show it as plain text.
186pub fn parse_sections(lines: &[String]) -> Vec<LogSection> {
187    parse_with(lines, true)
188}
189
190/// Like [`parse_sections`], using the job's `steps` (in the order they ran) to decide which
191/// markers are step boundaries:
192///
193/// * a log with Azure `##[section]Starting:` markers is sectioned by those alone — its
194///   `##[group]`s fold content inside a step;
195/// * otherwise each step, in order, takes the best-matching group still to come: an exact name
196///   (trimmed, case-insensitive, past GitHub's `Run `) over a loose one ([`find_section`]), and
197///   a `Run …` step header over a group a step's own output opened — so a `cargo test --no-run`
198///   group inside Build doesn't take the Test step's place. A step looks no further than the
199///   first group that exactly names a later step. Every other group is content of the step
200///   before it. When no group names any step the steps can't be told apart this way, and
201///   every group is a section, as in [`parse_sections`].
202///
203/// Each section it returns carries the step it was matched to.
204pub 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    // GitHub: give each step, in order, the best of the groups still to come — looking only as
221    // far as the first group that names a *later* step outright, which must be that step's.
222    let score = |group: &str, step: &str| -> u8 {
223        if name_matches_strictly(group, step) {
224            3
225        } else if name_matches(group, step) {
226            // Every step header GitHub writes starts `Run ` (`Run cargo test`, `Run actions/…`);
227            // a group opened by a step's own output usually doesn't.
228            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            // Content of the step before: that section now runs on through this one.
250            (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
260/// Whether a section name names `step` outright: the same name, trimmed and case-insensitive,
261/// also past GitHub's `Run ` prefix — no substring guessing.
262fn 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
267/// Whether a section name names `step`, by the passes [`find_section`] takes.
268fn 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    // GitLab nests sections; only the outermost one becomes a section here.
276    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        // A section that closed on this very line (GitLab puts an end and the next start on one
283        // line) gives the line up to the one opening here, so no line belongs to two sections.
284        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
341/// A marker line with nothing to read on it once sections are drawn as headers: GitHub's
342/// `##[endgroup]`, Azure's `##[section]Finishing: …`, and a line holding only GitLab
343/// `section_end` markers.
344pub 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        // Only markers and erase codes, no text of its own.
351        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
366/// The section a step's log lives in, by name: exact, then trimmed and case-insensitive (also
367/// past GitHub's `Run ` prefix), then either name containing the other. Azure names its sections
368/// after the step's display name, so its steps match exactly; GitHub's `run:` steps are titled by
369/// their command, which only the looser passes catch.
370pub 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
396/// The section for the `index`th of a job's `steps`: the one [`parse_step_sections`] matched to
397/// it, else by name ([`find_section`]), else by position when the log has exactly one section
398/// per step.
399pub 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
407// ---- errors ----
408
409/// Whether a log line reports a failure: CI annotations (`##[error]`), test-runner verdicts
410/// (`[FAIL]`, `FAILED`), compiler diagnostics (`error:`, `error[E…]`, `ERROR`, `Error:`),
411/// `npm ERR!`, and a non-zero exit code. Summary lines that count zero failures (`0 errors`,
412/// `Failed: 0`, `exit code 0`) don't qualify.
413pub 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    // Title-case verdicts (`Failed!`, `Tests Failed: 3`) — but not prose like `Failed to retry`.
421    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
430/// `word` as a whole token (not inside a longer identifier) whose following character passes
431/// `next_ok`, and which isn't a zero count (`0 FAILED`, `ERROR: 0`).
432fn 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
446/// `text` ends in the number 0 (ignoring trailing spaces), e.g. the `0 ` of `0 FAILED`.
447fn 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
452/// `text` is a count of 0 after optional `:`/`=`/spaces, e.g. the `: 0` of `ERROR: 0`.
453fn 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
458/// `exit code N` / `exited with code N` (any case) with N other than 0.
459fn 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
470/// Index of the first line [`is_error_line`] matches.
471pub fn first_error_line(lines: &[String]) -> Option<usize> {
472    lines.iter().position(|l| is_error_line(l))
473}
474
475/// What a failed log says went wrong, as far as it can be read: the failing test, where
476/// (`path:line`), and the message. Every part is optional; [`failure_summary`] returns `None`
477/// only when nothing at all was recognised.
478#[derive(Debug, Clone, PartialEq, Eq, Default)]
479pub struct FailureSummary {
480    /// The line the summary was read from — where "jump to the error" should land.
481    pub line: usize,
482    pub test: Option<String>,
483    /// `path:line`, as the log wrote the path.
484    pub location: Option<String>,
485    pub message: Option<String>,
486}
487
488/// `path:line[:col][:]` → `path:line`, when `s` looks like one.
489fn 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        // path:line:col
497        Some(path) if is_num(last) && is_num(mid) && !path.is_empty() => Some(format!("{path}:{mid}")),
498        // path:line (the rsplit took the path's own last segment as `mid`; rejoin it)
499        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
505/// The last `::` segment of a test path (`cache::tests::put` → `put`).
506fn short_test(name: &str) -> String {
507    name.rsplit("::").next().unwrap_or(name).trim().to_string()
508}
509
510/// A `path:line` shortened to the file's own name (`crates/core/src/cache.rs:212` →
511/// `cache.rs:212`), for one-line banners.
512pub fn short_location(location: &str) -> String {
513    location.rsplit(['/', '\\']).next().unwrap_or(location).to_string()
514}
515
516/// Reads the failure out of a job log. Recognised, strongest first: a Rust panic (`thread 't'
517/// panicked at path:line:col:` with its message on the next line, or the older one-line form),
518/// a compiler diagnostic (`error[E…]: msg` with its `--> path:line:col`), a test runner's
519/// `test name ... FAILED`, a generic `error: msg`, and a CI `##[error]msg`.
520pub 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    // The `--> path:line:col` a diagnostic points at, within its next few lines.
524    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                    // Wrapped: the location sits on the next line, the message after it.
554                    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                    // Old form: `panicked at 'message', path:line:col`.
559                    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
608// ---- durations ----
609
610/// The median of `values` (the mean of the middle two for an even count, rounded down), or
611/// `None` for none.
612pub 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        // An unclosed Azure section runs to the end of what has arrived so far.
715        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        // Checkout's group is `Run actions/checkout@v4`: matched loosely; `Fetching crates` names no
753        // step, so it is Test's output.
754        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        // A step matching nothing in order is skipped, not guessed.
760        assert_eq!(parse_sections(&log).len(), 4, "without steps every group is a section");
761        // No group names any step: every group stays a section.
762        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        // Skipping a step that logged no group takes an exact name.
781        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        // The old one-line form, on the main thread: the test comes from the FAILED line.
822        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}